The unit of work — staged writes, one short transaction, no flush
One unit of work spans one request or one job. Business logic reads and writes through
BusinessObjectManager, and every write lands in a per-business-object staging buffer —
nothing reaches Postgres until the unit commits. UnitOfWork.commit() opens the
only SQL transaction in the codebase, writes every dirty repository, runs the
onCommit callbacks, and ends.
The transaction is therefore only as wide as the write. No validation, no determination pass, no HTTP call and no LLM round-trip happens with a transaction open, because at those moments there is no transaction at all. That is the whole design: row locks are held until the end of a transaction, so the way to hold them briefly is to have nothing else inside it.
The precedent is SAP’s own current programming model. ABAP RAP holds changes in an in-memory transactional buffer through an interaction phase during which the data “does not have to be in a consistent state”, then writes them in a save sequence. The names differ; the shape does not.
Which reads see a staged write
Section titled “Which reads see a staged write”This is the one thing worth memorising, because it is not uniform:
| Read | Sees staged writes? |
|---|---|
bom.get(schema, id) |
Yes — the repository consults deleted, then dirty, before Postgres |
bom.list, bom.count, findReferencing |
No — straight to SQL |
| A domain function | No |
Raw SQL, including inside onCommit |
No, until the flush earlier in the same commit has run |
So a validator that fetches its target by id sees a sibling staged in the same unit; one
that queries for it does not. A rule that must see co-staged records is either written
against get, or enforced by a database constraint that fails the commit — which is why
every generated foreign key is DEFERRABLE INITIALLY DEFERRED. It is also why a
rollup adds the delta an event describes instead of re-summing
its source: a re-sum through list would miss every source record staged beside it.
One save, in order
Section titled “One save, in order”Every write — a form save, an action’s create, a draft’s activation — crosses the same
apply pipeline, and the order decides what each kind of rule can see:
flowchart TD
U[the update, defaults on create] --> R{every referenced\nrecord exists?}
R -- no --> X1[ValidationError\nnothing staged]
R -- yes --> D[determinations run to a fixpoint]
D --> V[every validator, against the final state]
V --> B{issues, and the\nrecord will be active?}
B -- yes --> X2[ValidationError\nnothing staged]
B -- no --> S[the write is staged in the unit]
S --> L[listeners, one at a time\nrollups stage their deltas here]
L --> C[commit: one transaction\nflushes every staged write]
C --> O[after commit: outbound events, workflows]
Three consequences worth holding onto:
- A validator sees computed fields, because it runs after the fixpoint; the reference-target check runs before it and sees only what the caller sent.
- A listener runs inside the write that triggered it, before
createorupdatereturns, and stages its own writes in the same unit. When an action creates a record and then reads throughbom.get, it sees what the listeners staged — a rollup has already added the new record’s contribution to its target. An action judging a bound must therefore judge against the subject it was handed, which predates that. - A throw anywhere before commit discards everything staged, the action’s own earlier writes and every listener’s included. That is what lets an action create a record, read the result the determinations computed, and still refuse.
There is no flush(), and there will not be one
Section titled “There is no flush(), and there will not be one”A public flush() — Doctrine’s, Hibernate’s, EF Core’s — writes staged changes without
ending the unit. That requires a transaction to already be open and to stay open for the
rest of the request, which is exactly the cost this design exists to avoid. Every system
that ships flush() also holds a request-long transaction; adopting one here would trade
the design for read-your-own-writes in queries.
It would also cost a property that is hard to get any other way. getNextNumber takes a
SELECT … FOR UPDATE on the number series row inside the commit, assigning a document
number as late as possible — gap-free by construction, and affordable only because the
transaction holding that lock is short. Postgres sequences cannot do this: they “cannot
be used to obtain ‘gapless’ sequences”.
The accepted cost is that an optimistic-lock conflict can only surface at commit.
ConcurrentModificationError is a conflict a human resolves, not a retry loop.
Nesting throws
Section titled “Nesting throws”Opening a unit of work is an entry-point act. Six places do it: the HTTP interceptor,
the job processor, a workflow step, an agent tool, a seed run, the test harness. Domain
code never calls uowStore.run() — it joins the ambient unit implicitly by reading and
writing through BusinessObjectManager. A nested run() is therefore always a bug, and
it throws rather than silently merging one frame’s writes into a commit of unknowable
size.
Forking instead was rejected: a forked unit commits independently, so an outer failure
could not roll it back, and two identity maps of the same record would race each other
into a spurious version conflict. The precedent is again SAP’s — a reusable component
must not declare a transaction boundary, and RAP forbids COMMIT ENTITIES inside a BO
provider. The mainstream (Spring, Rails, EF Core, Doctrine) defaults to joining
instead, because there any component may open a boundary and composition demands it.
Here the framework owns every boundary, so the divergence is deliberate: Spring’s
Propagation.NEVER, made the only mode.
The frame an entry point opens
Section titled “The frame an entry point opens”A unit of work is the innermost of four things an entry point establishes, and they nest in exactly one order. There is deliberately no shared helper wrapping them: a caller that opens a unit is already an entry point, and one framework method every entry point calls would hide the decision each of them has to make. Two honest copies beat one hidden helper — an entry point that opens several units in a row is free to keep its own private wrapper, as the demo seeder does. Copy this:
await metaService.get(tenantId); // primed first, outside every frame
await tenantStore.run( () => actorStore.run( () => uowStore.run(() => work()), // innermost: one commit per run() actor ), tenantId);Four things to keep, in this order:
metaService.get(tenantId)comes first, outside the tenant frame. It takes the tenant id explicitly and never reads the ambient frame, so it works either side — but a reader should not have to check that, and priming meta before entering the tenant is the order every entry point uses.- The actor is a real
Actor—{ kind: "system", name },{ kind: "user", … }or{ kind: "service-account", … }— never a bare string a frame rebuilds into one. A job restores the actor who enqueued it; a seeder names itselfseeder:<plugin>. causeStore.run(cause, …)sits between actor and unit, and only where a realCauseexists.Causeisevent,joboraction; a job sets one, seeding has none, and inventing a fourth kind to fill the slot is worse than leaving it out.uowStore.runis innermost, and onerun()is one commit. It throws on a nested call, so several units are sequential, never stacked. A caller that needs a step committed before the next step can read it — the demo dataset’s price list, before any order can be priced against it — runs the frame once per step rather than wrapping the lot.
The three callback seams
Section titled “The three callback seams”uow.onCommit(cb); // inside the commit transaction, after every repo has flusheduow.onAfterCommit(cb); // after it closes — only if the write actually committeduow.onRollback(cb); // after a failure — undo side effects taken before the writeonCommit is the seam for writes that must be atomic with the business data and
cannot go through a business object: ledger rows, balance upserts, audit entries. It is
not an escape hatch — the callback runs after business logic is over, so it cannot make a
staged record visible to a query, and it adds to the commit rather than dividing it. A
callback belongs to the commit that follows it: the commit drops its callbacks once they
have run, so a unit that commits again runs only what was registered since.
Outbound delivery to external sinks is not a fourth seam — the EventBus is an ordinary
consumer of onAfterCommit: each emitted event registers one callback, so a rolled-back
unit forwards nothing and the unit itself knows nothing about events. Sinks subscribe
with eventBus.onOutbound(handler). Delivery runs after the transaction, so an event
whose delivery fails after a successful commit is lost. That is a known gap; a durable
outbox is the fix, and it is a one-place change — emit swaps its onAfterCommit
registration for an onCommit outbox insert, atomic with the write.