Unit of Work Overview
pnpm add @effect-server-utils/unit-of-work @effect-server-utils/cqrs effect@effect-server-utils/unit-of-work is the atomicity boundary a write-side use case declares once, at
the end of its pipe:
import { withUnitOfWork } from "@effect-server-utils/unit-of-work";
export const placeOrder = (input: PlaceOrderInput) => Effect.gen(function* () { const order = yield* orders.create(input); yield* inventory.reserve(order); yield* eventBus.dispatch([OrderPlaced.make({ orderId: order.id })]); return order.id; }).pipe(withUnitOfWork);Every repository write inside commits together or is discarded together, every immediate event subscriber inherits that same boundary, and every after-commit subscriber waits for the commit.
Its own package, on purpose
Section titled “Its own package, on purpose”@effect-server-utils/cqrs has no opinion about transactions. Its event
bus knows that some subscriptions want to run after whatever produced the event has finished; it does
not know what “finished” means. This package answers that — and if you never install it, the bus still
works, running those handlers at the end of each dispatch instead. See
Events for what changes and what does not.
The pieces
Section titled “The pieces”The boundary — withUnitOfWork, the combinator a use
case applies, and UnitOfWork, the port it resolves. Where it goes in the pipe is part of the contract.
The transaction driver — the one thing a host supplies. Three members, no connection handle in sight.
Re-entrancy — a boundary inside a boundary nests rather than opening a second connection, and whether a nested failure is fatal is the caller’s choice.
After commit — the DeferralSink this package
installs, the commit sequence it guarantees, and the drain that runs once the scope has committed.
Errors and defects — what propagates to a use case, what is demoted to a defect, and why the split falls where it does.
Testing — the real boundary over an in-memory driver.
How the pieces fit
Section titled “How the pieces fit” TransactionDriver what you supply — withTransaction / withSavepoint / isActive │ ▼ makeUnitOfWork() Layer<UnitOfWork | DeferralSink, never, TransactionDriver> │ ├──► UnitOfWork the port; `withUnitOfWork` is the combinator over it │ └──► DeferralSink what EventBus.subscribeAfterCommit ends up waiting forThe two services ship together because they are one decision: “after commit” has no meaning until something owns a commit, and this is the thing that owns one.
const runtime = Layer.mergeAll(makeEventBus(), makeUnitOfWork()).pipe( Layer.provide(PostgresTransactionDriver),);Design commitments
Section titled “Design commitments”The port names no datastore. A use case depends on UnitOfWork, so it can be unit-tested against a
pass-through implementation with no database in sight.
transactional would have leaked the SQL implementation the abstraction exists to hide.
The requirement channel is untouched. The boundary makes its scope handle ambient rather than
routing it through R, so an effect handed to run never declared a requirement for one and there is
nothing to discharge. Pinned by a test, because a host adapter whose internals do name a scope service
could otherwise narrow R for every use case.
Sequencing is the commit guarantee. A failed scope never reaches the drain, so reactions to work that was discarded never fire. A rolled-back nested scope truncates the buffer back to its length on entry, for the same reason.
The drain is uninterruptible. The transaction has committed; an interrupt arriving then must not discard every reaction to work that is already durable.
Wiring mistakes are tagged defects. EventDispatchedOutsideUnitOfWork and DeferralWithoutEventBus
arrive as values a boot check or a test can match on, not as sentences inside a generic error.
Start with the boundary, then supply a driver.