A factory for an Account aggregate needs to enforce that the account's opening balance is never negative AND that its owner's email is not already used by another account. What's the difference in how a factory should handle these two invariants, and why does that difference matter?
answer
- local invariant: pure, synchronous, factory guarantees it absolutely
- cross-aggregate invariant: needs repository, only a best-effort snapshot check
- TOCTOU race between check and commit
- DB unique constraint is the real enforcement, factory check is UX
basics
~20 sThe balance check only needs the data you're already given, so the factory can check it instantly by itself. The email-uniqueness check needs to look at other accounts somewhere else, so the factory has to ask something outside itself - and that check can go stale between asking and actually saving.
solid answer
~40 sThe non-negative balance check is a local invariant - everything needed to validate it is already in the inputs, so the factory checks it synchronously and rejects bad input immediately, with no external dependency. The uniqueness check is a cross-aggregate invariant - it depends on the state of other Account aggregates, so it needs a repository or domain service, and it can only ever be a best-effort check at factory time: between the factory's uniqueness query and the eventual commit, another request could grab the same email (a race/TOCTOU condition). The real, unconditional guarantee for uniqueness has to live at the persistence boundary - e.g., a unique database constraint - with the factory-time check serving as an early, friendly rejection rather than the sole source of truth.
go deeper
Should recognize that some checks (like balance >= 0) can be done instantly while others (like uniqueness) need to look elsewhere.
Should name the repository/external dependency needed for cross-aggregate checks and that they happen inside the factory.
Should identify the TOCTOU race explicitly and explain why a database unique constraint (or equivalent) is the real enforcement mechanism.
Should generalize to distributed/eventually-consistent systems (reservation services, sagas) and discuss designing factories so cross-aggregate checks are advisory, not authoritative.
## Two kinds of invariant hide behind one phrase DDD factories are often described as guaranteeing that an aggregate satisfies its invariants **'at construction time,'** but that phrase hides an important distinction between two very different kinds of invariants, and conflating them leads to real bugs. A **local (or self-contained) invariant** is a rule that can be fully evaluated using only the data already inside the aggregate being built: - 'balance must not be negative' - 'end date must be after start date' - 'an order must have at least one line item' A factory can check these purely, synchronously, with no external calls, and the guarantee it provides is absolute: if the factory returns an `Account` object at all, that object's balance is provably non-negative, full stop, because nothing outside the function call could have changed that between the check and the return. ## The cross-aggregate kind, and its race A **cross-aggregate (or cross-entity) invariant** is different in kind: it depends on state that lives outside the aggregate being constructed. - 'this email must be unique across all accounts' - 'this seat number must not already be booked for this flight' - 'the total allocated budget across all projects in this department must not exceed the department's budget' Checking these requires the factory (or, more often, a standalone factory that takes a repository or domain service as a dependency) to query that external state before proceeding. The catch is that this check is inherently a snapshot in time: the factory asks 'is this email free?', gets an answer, and only later does the resulting aggregate actually get persisted, usually inside a separate transaction or unit of work. Between the query and the commit, another concurrent request can come in, see the same 'yes it's free' answer, and also proceed - both aggregates believe they've satisfied the uniqueness rule, and only one commit ultimately succeeds, or worse, if there's no enforcement at the persistence layer, both commits succeed and the invariant is silently violated. This is a classic **time-of-check-to-time-of-use race condition**, and it is not a bug in the factory - it's a structural limit of any in-memory invariant check that involves data outside the aggregate's own transactional boundary. ## The practical consequence The practical consequence is that a factory-level uniqueness (or any cross-aggregate) check should be understood as a **UX optimization, not the sole enforcement mechanism**: it lets the system reject obviously-bad input early and return a fast, friendly error ('that email is already taken') before doing any expensive work. But the actual, unconditional guarantee has to be enforced somewhere that sees all concurrent attempts atomically: - almost always a unique constraint or unique index at the database level - or, in distributed/eventually-consistent systems, a dedicated arbitration mechanism (a reservation service, an idempotency key, a saga with compensation) If a team relies solely on the factory-time repository check for uniqueness and skips the database constraint, they will eventually see duplicate emails in production under concurrent load, typically during a marketing campaign or load spike when signup traffic is high enough for two requests to race within the same tens-of-milliseconds window. ## How the distinction shapes the factory This distinction also shapes how factories are designed structurally. | Invariant | Where it belongs | |---|---| | Local | belongs naturally on a factory method on the aggregate root itself, since no external dependency is needed - they're pure functions of their inputs | | Cross-aggregate | push toward a standalone factory (or an explicit domain service used by the factory) that can accept a repository, precisely because the aggregate root itself shouldn't hold infrastructure dependencies | This is one of the concrete triggers, alongside identity generation and polymorphic creation, for choosing a standalone factory over a plain factory method. ## Where it shows up A concrete real-world example: many web frameworks and ORMs explicitly document that their application-level uniqueness validation is a convenience check that reduces, but does not eliminate, the chance of a duplicate, and recommend pairing it with a database-level unique index or constraint for the actual guarantee, precisely because of this race condition; DDD factories doing a repository lookup for uniqueness are the same pattern at the domain layer. A well-run interview answer on this topic distinguishes **'the factory can promise X absolutely'** (for local invariants) from **'the factory can only ask and hope, with the database as the real referee'** (for cross-aggregate invariants) - failing to draw that line is a common and consequential gap, because it leads teams to trust factory-time checks as if they were transactionally airtight when they are not.
- Why can't a factory's uniqueness check alone be trusted as the sole enforcement of a cross-aggregate invariant?Because the check and the eventual persistence commit happen at different points in time, usually across a transaction boundary, so a concurrent request can pass the same check before either one commits - a time-of-check-to-time-of-use race. The only way to close that gap fully is an atomic enforcement mechanism at the persistence layer, like a unique database constraint, that sees all concurrent writers at once.
- Where should a local invariant like 'balance must not be negative' be checked, and does it have the same race-condition risk?It belongs in the factory (or the aggregate's own validation) and can be checked purely from the constructor's inputs with no external dependency, so it has no race condition at all - there's nothing outside the function call that could invalidate the result between checking and returning the object.
Checking that a form's password and confirm-password fields match is something you can verify the instant someone submits it; checking that a chosen username is still free is like asking a bouncer if a seat is open right before you sit down - by the time you actually sit, someone else might have taken it, so the club still needs a hard rule enforced at the door, not just your quick glance across the room.
saying these in an interview costs you the question
- Treats all invariants as equally 'safe' to check purely in the factory
- Doesn't recognize the TOCTOU race for cross-aggregate checks
- Thinks a repository uniqueness check in the factory is sufficient on its own with no DB constraint
- Can't distinguish local vs cross-aggregate invariants at all