ADR-0046: One aggregate instance per Unit of Work
Status: Accepted
Date: October 2026
Context
An aggregate instance carries three counters that the framework uses for
optimistic concurrency. _version is the version stored for it, and
_next_version is the version its next save writes. _event_position is the
position of the last event in its stream, and each event it raises expects
the stream to be at that position when the event is appended.
A state-based repository.get() inside a Unit of Work built a new instance on
every call. Each instance read its counters from the store. The store had not
seen the changes still pending on other copies of the same aggregate in that
transaction, so two copies disagreed about where the stream and the record
stood. Three things went wrong:
- An event raised on one copy was lost when a later read replaced that copy in the identity map.
- Two copies that both raised events claimed the same stream position, so the
second append failed with
ExpectedVersionErroralthough no other writer existed. - After a rollback, a copy could keep a version that only an earlier copy's uncommitted write had produced, and its next save failed.
An event-sourced get() already returned the tracked instance, but only once
add() had saved it with new events. Before that, two get() calls returned
two instances, with the same problems.
The identity map was also keyed by provider and id alone, so two aggregate types that shared an id replaced each other's entry.
Related: #1744
Decision
A Unit of Work holds one instance of each aggregate for the whole transaction.
- The identity map is keyed by provider, then by aggregate class and id.
- When a
get()or a query inside a Unit of Work loads an aggregate that the map already holds, the repository returns the instance in the map, with any changes made to it since. The query still runs against the store, so which aggregates match is decided by the store. Only the object returned for a match changes. - An aggregate is added to the map on its first read or save in the transaction. This applies to state-based and event-sourced aggregates.
- Being in the map does not make an event-sourced aggregate's events commit.
Its events are its state, and
add()raises its fact event, so its events commit only onceadd()has saved it. A state-based aggregate's events commit once it is read, as before. - A read through a DAO set to work outside the Unit of Work (
outside_uow()) and a temporal query (at_versionoras_of) do not use the map.
With one instance there is one set of counters, so nothing has to reconcile them across copies.
Consequences
- Events raised on an aggregate in a transaction are all gathered at commit,
however many times the aggregate was read. An event-sourced aggregate's
events are gathered once
add()has saved it. - Version and event-position conflicts inside one transaction come only from another writer.
- A second
get()of an aggregate in a Unit of Work returns the first result, with any changes made to it that were not saved. This is how the identity map in SQLAlchemy's session and in Hibernate behaves. Code that read an aggregate again to throw its changes away now gets them back. Saving it withadd()writes those changes. The events raised with them are saved at commit even withoutadd()on a state-based aggregate, and only throughadd()on an event-sourced one. To abandon a change, code raises an exception so the Unit of Work rolls back, or reads the stored state in a new Unit of Work. A change that breaks an invariant needs neither, because Protean undoes the assignment when the check fails. - A query returns tracked instances whose in-memory fields can differ from the stored row that made them match. A filter on a field changed in memory but not yet flushed can return an instance whose field no longer meets the filter. SQLAlchemy's session behaves the same way.
Alternatives Considered
- Keep a copy per read and reconcile the counters. A copy read later takes the higher event position of the copy it replaces, and the Unit of Work keeps every saved copy so it can settle their versions. Rejected: each rule needed another. An inherited position was wrong when the earlier copy was never saved, when its events included a fact event, and when the two copies were different aggregate types with one id.
- Keep the latest copy in the map, as before. Rejected: it loses the events of every earlier copy without an error.
- ADR-0027 rejected serving reads from the identity map in place of SQL, since
filter,countandexistscannot see unflushed writes that way. This decision keeps SQL for every read and changes only which object a match returns, so that concern does not apply.