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.
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)
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(()))
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)))
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)))
checkout cannot tell local from remote or asynchronous. The same scenarios test every step of the migration.
Retries, timeouts, idempotency keys and circuit breakers wrap the remote handler, and the business code stays the same.
Split Shop into one effect per service, and move each service out when it is ready. The rest stays local.
Each of these usually comes as a framework. With effects, each is a handler.
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.*
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(()))
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))
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.
The pieces this needs are already in okay-effects:
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.
okay-simple has retry and fallback, and async with timeout, race and cancellation. It interoperates with ZIO (including layers), cats-effect, fs2 and kyo.
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.