ADR-0022: Pre-Persist Aggregate Enricher for Lifecycle/Audit Fields
Status: Accepted
Date: July 2026
Context
Applications routinely need cross-cutting technical fields on their aggregates:
created_at/updated_at timestamps and created_by/updated_by audit fields
that record the acting user. Protean already made timestamps possible — an
abstract base aggregate with created_at: DateTime(default=utc_now) — but the
population story was incomplete:
created_atwas set once at construction (a callable field default), which is fine.updated_athad no auto-refresh. There was noauto_now/onupdate, so every mutating method had to set it by hand.created_by/updated_byhad no mechanism at all. Protean has a domain context (domain.domain_context(current_user=...)lands values ong) and event/command enrichers, but those enrichers write to event metadata (metadata.extensions), not to aggregate columns. There was no hook that runs in the persistence path with access to the acting user.
We wanted a low-magic way to stamp these fields, consistent with patterns already in the framework, without turning it into a general "mutate the aggregate however you like before save" escape hatch.
Decision
We add two complementary mechanisms.
1. Declarative timestamps. DateTime and Date fields gain Django-parity
auto_now_add (stamp on the create save) and auto_now (stamp on every save)
flags. The flags are carried as FieldSpec metadata and read back on the
resolved field; the persistence layer stamps them. The two are mutually
exclusive, are rejected on non-temporal or required fields at declaration time,
and only apply to the Repository.add → save write path (see Consequences).
2. A registered pre-persist aggregate enricher. Domains gain
register_aggregate_enricher(fn) and the @domain.aggregate_enricher
decorator, symmetric with the existing register_event_enricher /
register_command_enricher. Both new mechanisms fire in BaseDAO.save — the
write path that Repository.add funnels through, and the same spot where the
optimistic-concurrency version is advanced — before the entity is frozen into a
model (from_entity), on both the create and update paths.
The aggregate enricher differs from the event/command enrichers in one important
way: an event/command enricher returns a dict that the framework merges into
metadata.extensions, whereas an aggregate enricher mutates the aggregate in
place (its return value is ignored) so it can stamp real columns. It receives
just the aggregate, runs inside an active domain context (so it can read
g.current_user), and executes in registration order (FIFO). If it raises, the
exception propagates and the save is aborted; on an update the version advance
is rolled back (a failed create is simply never persisted).
The enricher is scoped to aggregates (child entities persisted through the same path are not handed to it) and is documented as being for cross-cutting lifecycle/audit metadata only. The framework supplies the hook and the stamping mechanism; the audit user model stays app-defined.
Consequences
updated_atrefreshes automatically, andcreated_by/updated_byhave a single, central place to be stamped instead of being re-implemented in every mutator.- The mechanism is symmetric with the enrichers developers already know, so there is little new surface to learn.
- Because the enricher mutates the aggregate through normal attribute
assignment, invariants still run on the stamped fields — the hook does not
bypass validation. It also does not touch
_events, so event raising is unaffected. - The enricher fires on every aggregate persisted through the repository, so it
must stay fast (read from
g, not from I/O). This is the same constraint the event/command enrichers already carry. - The hook is powerful enough to be misused (an enricher can mutate business fields or raise). We rely on documentation and the narrow, opt-in framing to keep it in its lane rather than enforcing restrictions in code; enforcing them would add complexity for little practical gain.
auto_now/auto_now_addfields are Optional and unset until the first save (unlike a construction-timedefault=utc_now). Apps that need the value populated in memory before persistence should keep using a field default.- Both mechanisms fire on the
Repository.add→savepath, alongside version management — and only there. They do not fire on the escape hatches that bypass that path: a set-basedrepository.query.filter(...).update(...)runs no per-row Python (matching Django, whereauto_nowfires onModel.save()but notQuerySet.update()), and an event-sourced aggregate persists through the event store rather than a DAO, so it carries audit data in its events instead. This is why the audit recipe is a DDD/CQRS pattern, not an ES one.
Alternatives Considered
An overridable _before_save() method on the aggregate. Simpler and
per-aggregate, but less composable than a registered enricher (you cannot layer
several concerns), and it invites exactly the "general mutation hook" abuse we
want to discourage — a method on the aggregate reads as part of the aggregate's
own behavior. A registered, domain-level enricher keeps the cross-cutting
concern visibly separate from domain logic.
Leaving it entirely to the application. Works, but every mutator
re-implements updated_at and audit stamping, and there is no central place to
read the acting user — which is the gap this ADR closes.
Reusing the event/command enricher return-a-dict contract. Rejected because
those enrichers target metadata.extensions, not aggregate columns. Forcing the
aggregate enricher into the same shape (return a dict of field→value that the
framework applies) added indirection without benefit; direct in-place mutation
is clearer for stamping fields.