Skip to content

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.

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.

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 create or update returns, and stages its own writes in the same unit. When an action creates a record and then reads through bom.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.

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.

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 itself seeder:<plugin>.
  • causeStore.run(cause, …) sits between actor and unit, and only where a real Cause exists. Cause is event, job or action; a job sets one, seeding has none, and inventing a fourth kind to fill the slot is worse than leaving it out.
  • uowStore.run is innermost, and one run() 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.
uow.onCommit(cb); // inside the commit transaction, after every repo has flushed
uow.onAfterCommit(cb); // after it closes — only if the write actually committed
uow.onRollback(cb); // after a failure — undo side effects taken before the write

onCommit 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.