okay-effects

From a monolith to services, one handler at a time.

Splitting a monolith usually means rewriting the code that matters most while it keeps running. With algebraic effects the business logic is written once, against effects, and where each part runs is a handler. First local, then remote, then asynchronous. The logic does not change, and the same tests check every step.

A shop's checkout, in three steps

The checkout reserves the item, charges the card and sends a receipt. In a monolith this is three blocking calls into modules the function reaches directly:

def checkout(user: String, item: String, sum: Int): Option[String] =
  if !Inventory.reserve(item) then None
  else
    val id = Payments.charge(user, sum)
    Mail.send(user, s"paid $sum")
    Some(id)

Step 1. Boundaries inside the monolith

The calls become an effect, as plain data, and the logic keeps its shape:

enum Shop[+A]:
  case Reserve(item: String) extends Shop[Boolean]
  case Charge(user: String, sum: Int) extends Shop[String]
  case Send(user: String, text: String) extends Shop[Unit]
import Shop.*

def checkout(user: String, item: String, sum: Int): Eff[Shop, Option[String]] = for
  ok <- perform(Reserve(item))
  id <- if ok then perform(Charge(user, sum)).map(Some(_)) else Return(None)
  _ <- if ok then perform(Send(user, s"paid $sum")) else Return(())
yield id

The modules are given at the top by a handler. It is still a monolith, but its boundaries are now explicit:

def local[G[_], A] = Handler[Shop, A, A, G](Return(_), [X] => (op, k) => op match
  case Reserve(i) => k(Inventory.reserve(i))
  case Charge(u, s) => k(Payments.charge(u, s))
  case Send(u, t) => k(Mail.send(u, t)))

Tests are handlers too. In the "out of stock" scenario a mock records the calls, and the test checks that only Reserve was made: no charge, no mail.

def mock[G[_], A](stock: Boolean, calls: collection.mutable.Buffer[Shop[Any]]) =
  Handler[Shop, A, A, G](Return(_), [X] => (op, k) =>
    calls += op
    op match
      case Reserve(_) => k(stock)
      case Charge(u, _) => k("test-" + u)
      case Send(_, _) => k(()))

Step 2. Services

A handler sends each operation over the wire, and the answer goes to the rest of the program, the continuation:

trait Transport: // HTTP, gRPC, a queue: serialize, send, deserialize
  def call[X](op: Shop[X]): X
  def send[X](op: Shop[X]): Future[X]

def remote[G[_], A](t: Transport) = Handler[Shop, A, A, G](Return(_), [X] => (op, k) => k(t.call(op)))

Step 3. Asynchronous

Each operation becomes an Await of a Future. runAsync resumes the continuation when the answer arrives:

enum Async[+A]:
  case Await[X](f: Future[X]) extends Async[X]
import Async.Await

def runAsync[A](p: Eff[Async, A]): Future[A] = p match
  case Return(a) => Future.successful(a)
  case Perform(Await(f)) => f
  case Bind(m, f) => m match
    case Return(x) => runAsync(f(x))
    case Perform(Await(fx)) => fx.flatMap(x => runAsync(f(x)))
    case Bind(m, g) => runAsync(Bind(m, x => Bind(g(x), f)))

def remoteAsync[A](t: Transport) =
  Handler[Shop, A, A, Async](Return(_), [X] => (op, k) => perform(Await(t.send(op))).flatMap(k))

runAsync(handle(remoteAsync(http))(checkout("ann", "book", 20)))

The logic never changed

checkout cannot tell local from remote or asynchronous. The same scenarios test every step of the migration.

Failures are handlers

Retries, timeouts, idempotency keys and circuit breakers wrap the remote handler, and the business code stays the same.

Move parts out one by one

Split Shop into one effect per service, and move each service out when it is ready. The rest stays local.

Growing the shop: CQRS, event sourcing, sharding, CRDTs

Each of these usually comes as a framework. With effects, each is a handler.

CQRS: commands and queries are separate effects

enum Command[+A](val item: String):
  case Order(user: String, i: String, sum: Int) extends Command[Boolean](i)
  case Refund(user: String, i: String, sum: Int) extends Command[Unit](i)
enum Query[+A]:
  case Revenue extends Query[Int]
import Command.*, Query.*

Event sourcing: state is a fold of events

The state is recovered once from the log. Commands decide on it and append to it:

enum Event:
  case Ordered(user: String, item: String, sum: Int)
  case Refunded(user: String, item: String, sum: Int)
import Event.*

trait EventLog:
  def append(e: Event): Unit
  def events: Seq[Event]

def evolve(sold: Map[String, Int], e: Event) = e match
  case Ordered(_, i, _) => sold.updated(i, sold(i) + 1)
  case Refunded(_, i, _) => sold.updated(i, sold(i) - 1)

class Shard(log: EventLog, stock: Map[String, Int]):
  var sold = log.events.foldLeft(Map.empty[String, Int].withDefaultValue(0))(evolve)
  def record(e: Event) = { log.append(e); sold = evolve(sold, e) }
  def handler[G[_], A] = Handler[Command, A, A, G](Return(_), [X] => (op, k) => op match
    case Order(u, i, s) =>
      val ok = sold(i) < stock.getOrElse(i, 0)
      if ok then record(Ordered(u, i, s))
      k(ok)
    case Refund(u, i, s) => record(Refunded(u, i, s)); k(()))

Sharding, by the invariant's key

The shard is chosen by the item, the key the stock invariant is about. Rendezvous hashing means a new shard moves only about 1/n of the keys:

def owner(key: String, n: Int) =
  (0 until n).maxBy(s => scala.util.hashing.MurmurHash3.stringHash(s"$key/$s"))

def sharded[G[_], A](shards: Vector[Shard]) = Handler[Command, A, A, G](Return(_),
  [X] => (op, k) => shards(owner(op.item, shards.size)).handler.eff(op, k))

Eventual consistency: a CRDT view

Shards project their events into a PN-counter CRDT. Its merge is commutative and idempotent, so the views converge:

import cats.Monoid

def maxMerge(a: Map[Int, Int], b: Map[Int, Int]) =
  (a.keySet ++ b.keySet).map(k => k -> (a.getOrElse(k, 0) max b.getOrElse(k, 0))).toMap
case class PNCounter(up: Map[Int, Int], down: Map[Int, Int]):
  def value = up.values.sum - down.values.sum
given Monoid[PNCounter] with
  def empty = PNCounter(Map.empty, Map.empty)
  def combine(a: PNCounter, b: PNCounter) = PNCounter(maxMerge(a.up, b.up), maxMerge(a.down, b.down))

def project(shard: Int, log: EventLog) = PNCounter(
  Map(shard -> log.events.collect { case Ordered(_, _, s) => s }.sum),
  Map(shard -> log.events.collect { case Refunded(_, _, s) => s }.sum))

def reads[G[_], A](view: () => PNCounter) = Handler[Query, A, A, G](Return(_), [X] => (op, k) => op match
  case Revenue => k(view().value))

val shards = logs.map(Shard(_, stock))
def shop[G[_], A] = sharded[G, A](shards)

run(handle(shop)(perform(Order("ann", "book", 20))))  // true
run(handle(shop)(perform(Order("bob", "book", 20))))  // false: sold out

The program only performs effects. Storage, history, scale and consistency are all handlers. The code on this page is from the author's posts and runs on a small Eff monad. okay-effects is that idea as a library.

In okay

The pieces this needs are already in okay-effects:

Handlers across the wire

okay-kernel's protocol runs a program under handlers in another process or another language. In the demo, a shop in Go has its inventory, payments and mail as Scala handlers.

Resilience and asynchrony

okay-simple has retry and fallback, and async with timeout, race and cancellation. It interoperates with ZIO (including layers), cats-effect, fs2 and kyo.

The platform

okay has HTTP, SQL, Kafka, streams, a cluster and durable workflows on the same model. okay-audit shows in the bytecode where each module reaches the outside, which is where a service boundary goes.

Help with splitting a monolith is available, as a team member or as a contract consultant: the author.