Browse pages
Domain Layer
The Domain layer holds a product's business rules in code that depends on nothing but itself, so the rules are tested on their own. An aggregate is one unit of business state that stays consistent and changes in one step, and this page states how to model aggregates, their lifecycles, closed sets of values, and the domain repositories that load them. It also states the boundary that keeps technical concerns out of business rules.
Stack: any Layer: domain
Concepts#
Aggregates and the Business Model#
In code, the aggregate root is the single type through which outside code changes the aggregate: it guards every mutation, holds the lifecycle state, and records domain events. Child entities live inside the boundary and expose no mutable state outside the root. One aggregate references another by typed identifier rather than by object reference, and one domain repository per aggregate loads and stages it. A store aggregate has no root in code; it reaches code through a port under litespec/backend/application#expose-store-state-through-ports.
Lifecycles as State Types#
Every aggregate exposes its lifecycle as state types from its first implementation: one state property backed by one closed hierarchy with one type per state. A one-state hierarchy gives later states and state-specific facts a defined home, so adding a state extends the hierarchy instead of replacing an enumeration, status string, or flag-based lifecycle. In the Claimwick product, an expense is Submitted, Approved, Refused, or Reimbursed, and each state carries its own facts, such as the approver or the reimbursed time.
Closed Sets#
A closed set is a fixed menu of values where the type system knows every case, such as a refund outcome or an expense state. Domain models each one as a type hierarchy with one type per case. Each case is a named type that a switch can name in its own arm, and a case with no arm reaches the switch fallback at run time. Each outer layer mirrors the same shape in its own form: a result union in Application, a transport model in WebApi, and a stable string discriminator in storage.
Standards#
Keep Domain Independent of Outer Layers#
ruleruleguidancelitespec/backend/domain#keep-domain-independent-of-outer-layersThe Domain code MUST reference only the language standard library and project-owned Domain types.
Applies toartifact: any
Why
Domain that references only the language standard library and its own types compiles and tests with no infrastructure at all. Each banned reference would drag one outer concern into the business model: persistence metadata couples a rule to a store format, and dispatch plumbing couples it to delivery. Infrastructure registers a persisted discriminator instead, so Domain carries no serialization metadata.
Checka reviewer answers
Reviewed forWhich Domain references reach beyond the language standard library and project-owned Domain types? Look at the references in the Domain code.
Marked guidance: a reviewer answers this question.
Use One Ubiquitous Language#
ruleruleguidancelitespec/backend/domain#use-one-ubiquitous-languageA Domain name MUST match a term in the project glossary.
Applies toartifact: any
Why
When code and specification share names, a reader moves between them without translation. A business action named Publish in the glossary is not SetStatus in code, because the second name hides which business action ran. The glossary holds each term meaning, labels per language, rejected words, and broader and narrower links; records cite the terms they use, following litespec/core/domain-model#define-every-behavior-term-in-the-glossary.
Checka reviewer answers
Reviewed forWhich Domain names match no term in the project glossary? Look at the Domain names beside glossary.yaml.
Marked guidance: a reviewer answers this question.
Treat Aggregates as Consistency Boundaries#
ruleruleguidancelitespec/backend/domain#treat-aggregates-as-consistency-boundariesAn aggregate root MUST be the only public mutation entry point for every invariant its transaction protects.
Applies toartifact: aggregate
Why
If outside code could set properties or call child mutations, it could change data without passing the root's checks. The root stands as the only public mutation entry point, so every change passes the invariant the transaction protects. A command with consistency: atomic changes every aggregate its changes names in one transaction, citing the rule in cites when it changes more than one.
Checka reviewer answers
Reviewed forWhich invariants admit a public mutation outside their aggregate root? Look at the public mutation entry points of each aggregate.
Marked guidance: a reviewer answers this question.
Take Identity and Events from the Project Root Contract#
ruleruleguidancelitespec/backend/domain#take-identity-and-events-from-the-project-root-contractAn aggregate root MUST take its identity and event mechanics from the project-owned root contract.
Applies toartifact: aggregate
Why
One root contract gives every aggregate the same identity and event mechanics, so Infrastructure collects events from any aggregate the same way. The contract owns typed identity, pending events, recording, and clearing, and carries the aggregate version only as persistence metadata that Domain never reads or sets: no timestamp, audit, tenant, lifecycle, or other persistence behavior, because any of those would couple every aggregate to a concern only some need.
Checka reviewer answers
Reviewed forWhich aggregate roots take identity or event mechanics outside the project-owned root contract? Look at each aggregate root declaration.
Marked guidance: a reviewer answers this question.
Model Every Aggregate Lifecycle with State Types#
ruleruleguidancelitespec/backend/domain#model-every-aggregate-lifecycle-with-state-typesAn aggregate MUST expose its lifecycle only through one state property backed by one closed hierarchy of per-state types.
Applies toartifact: state-record
Why
Each lifecycle state can carry different facts: a published post carries its publication time, a draft does not. An enumeration, string discriminator, boolean flag, parallel nullable field, or computed state carries no state-specific data. State types hold that data, and aggregate methods own the transitions between them. A switch over the state types names each case in its own arm where the language requires a fallback, and a case with no arm reaches that fallback at run time.
Example
A post defines one abstract post state with draft and published cases, where the published case carries its publication time. Even an aggregate with one current state defines its hierarchy.
In the Claimwick product, aggregate/spending.expense records the Submitted, Approved, Refused, and Reimbursed states with their transitions: Submit from use-case/spending.submit-expense creates Submitted, Approve from use-case/spending.approve-expense moves Submitted to Approved, Refuse from use-case/spending.refuse-expense moves Submitted to Refused, and Reimburse from use-case/spending.reimburse-expense moves Approved to Reimbursed. The approved case carries the approver and the decided time, and the reimbursed case carries the reimbursed time.
Checka reviewer answers
Reviewed forWhich aggregates expose their lifecycle outside one state property and one closed hierarchy of per-state types? Look at the lifecycle declarations in each aggregate.
Marked guidance: a reviewer answers this question.
Model Every Closed Set of Domain Values as a Closed Type Hierarchy#
ruleruleguidancelitespec/backend/domain#model-every-closed-set-of-domain-values-as-a-closed-type-hierarchyDomain code MUST model a closed set as one type hierarchy whose cases the type system can enumerate.
Applies toartifact: value-object
Why
A closed set with case data needs a type per case: an enumeration carries no case data, admits undefined values, and forces parallel nullable fields when one case gains data. A closed hierarchy gives each case a named type that a switch can name in its own arm, and a case with no arm reaches the switch fallback at run time. A boundary converts a label to its case through a named lookup rather than through a string conversion, and an unrecognized label is a validation failure rather than a default case.
Example
A refund outcome defines pending, succeeded, failed, and reversed cases, where each case carries its own data.
This provision owns the Domain form. The same set is mirrored in Application under litespec/backend/application#mirror-a-domain-closed-set-in-the-result, on the wire under litespec/backend/api#mirror-a-domain-closed-set-as-a-transport-model-of-the-same-shape, and in storage under litespec/backend/persistence#keep-serialization-behavior-out-of-domain. Label-only sets cross the boundary as plain enumerations under litespec/backend/api#publish-label-only-sets-as-string-enums. A transport label converts to its type case before it reaches a Domain method.
Checka reviewer answers
Reviewed forWhich closed sets escape one type hierarchy with named label lookups? Look at each closed set and its boundary lookups.
Marked guidance: a reviewer answers this question.
Create Valid Aggregates Through Named Factories#
ruleruleguidancelitespec/backend/domain#create-valid-aggregates-through-named-factoriesAn aggregate root MUST create instances only through a factory named for its business action.
Applies toartifact: aggregate
Why
A constructor carries no business name, so construction through constructors spreads creation rules across call sites. The factory is named for its business action, accepts typed identifiers, rejects empty identity and violated creation rules, selects the initial state, records required events, and returns a complete aggregate.
A root created with its children composes them inside the same factory call. The children are built from typed values the caller supplies: a caller holding an already constructed child could mutate it after the invariant was checked, so the factory builds the children itself from those values.
Example
In the Claimwick product, use-case/spending.submit-expense creates its expense through a factory named for submitting: the factory takes the expense and employee identifiers, rejects an empty identity, selects the submitted state, and records the submitted event.
Checka reviewer answers
Reviewed forWhich aggregate roots create instances outside a factory named for their business action? Look at the creation paths of each root.
Marked guidance: a reviewer answers this question.
Express Transitions Through Business Methods#
ruleruleguidancelitespec/backend/domain#express-transitions-through-business-methodsA public aggregate mutation method MUST check current state and protect its invariants, replacing state and recording its transition event when it moves the lifecycle.
Applies toartifact: aggregate
Why
A business-named method lets the handler call the publish behavior without testing the state first: the method checks the current state and protects its invariants, then replaces state and records the transition event when it moves the lifecycle. A generic Set, Update, Process, or Handle name hides which business action ran. A mutation that changes no lifecycle state, such as ChangeTitle on a draft, checks state and assigns without recording a transition.
Checks run before replacement, and replacement runs before recording. A method that replaces state before checking an invariant leaves a rejected aggregate holding the new state.
Example
In the Claimwick product, use-case/spending.reimburse-expense ends with result/reimburse-expense.reimbursed when the expense is approved, and with result/reimburse-expense.not-approved when it is not. The handler calls one business method, and the method checks the state, replaces it, and records event/expense.reimbursed.
An unapproved expense keeps its state, and the refusal carries the stable code spending.not-approved.
Checka reviewer answers
Reviewed forWhich public mutations skip the state check or the invariant, or which lifecycle moves skip replacement or the transition event? Look at the public mutation methods of each aggregate.
Marked guidance: a reviewer answers this question.
Call the Marked Act from Each Command Handler#
rulerulecheckablelitespec/backend/domain#call-the-marked-act-from-each-command-handlerA command handler changing an entity or saga aggregate MUST change it by calling a root method marked with the handler's use case.
Applies toartifact: command-handler
Why
The use case names an act, and the aggregate performs it: the mark on the root method says which act the method is. A handler that changes its aggregate only through methods marked for other use cases performs an act its record never named, and a reader cannot tell from the use case which method to open. The use-case verb, the method name, and the raised event's tense agree under litespec/backend/dotnet/domain#raise-the-past-tense-of-a-methods-own-verb. This provision states the marked form of litespec/backend/application#keep-command-handlers-to-load-call-and-stage. A command that changes only stores, or that only runs other commands and lists nothing in changes, performs no act on a root, so it needs no marked call.
Example
The reimburse-expense handler calls Reimburse on the expense root, and Reimburse carries the mark use-case/spending.reimburse-expense.
Checkchecked by a tool
Checked bylitespec model check reports use-case-act-missing (level S) for a command handler changing an entity or saga aggregate that calls no root method marked with its use case. The check runs in the CLI only, with no analyzer rule: the handler and the root method sit in different projects, and one compilation sees one project.
Marked checkable: the tool named here checks it.
Give Each Use Case Its Own Act#
rulerulecheckablelitespec/backend/domain#give-each-use-case-its-own-actEach aggregate root method that changes state MUST perform the act of exactly one use case.
Applies toartifact: aggregate
Why
Each use case is its own path: its own caller, setup, refusals, events and scenarios. A method two use cases share couples them, so a change made for one silently changes the other, and neither scenario set describes the method. One method per act keeps each path readable on its own; logic the acts share moves into a private method of the root that each act calls. A method that only reads or computes performs no act and carries no mark.
Example
In the Claimwick product, a manager approving an expense and a delegate approving it for an absent manager both record an approval. The expense root offers Approve marked use-case/spending.approve-expense and ApproveOnBehalf marked use-case/spending.approve-expense-on-behalf, each raising its own event, and both call a private RecordApproval.
Checkchecked by a tool
Checked bylitespec model check reports mark-count-invalid (level S), and dotnet build reports LS5003, for a root method carrying more than one use-case mark; mark-unknown and LS5002 report a mark on a method that changes no state.
Marked checkable: the tool named here checks it.
Raise the Declared Events from the Marked Act#
rulerulecheckablelitespec/backend/domain#raise-the-declared-events-from-the-marked-actThe root methods marked with a use case MUST together raise exactly the events that use case lists in raises.
Applies toartifact: aggregate
Why
The use case record states the facts its act leaves behind, and reactions subscribe to those facts. A marked method that raises an event the record does not list starts a consequence nobody reviewed, and a listed event that no marked method raises is a promise the code does not keep.
Example
use-case/spending.submit-expense raises event/expense.submitted, and the Submit method marked with it raises ExpenseSubmittedEvent and nothing else.
Checkchecked by a tool
Checked bylitespec model check reports act-event-undeclared (level S) for a marked method raising an event its use case does not list, and act-event-unimplemented (level S) for a listed event no marked method raises.
Marked checkable: the tool named here checks it.
Keep Child Entities Inside the Aggregate Boundary#
ruleruleguidancelitespec/backend/domain#keep-child-entities-inside-the-aggregate-boundaryA child entity MUST expose no mutable state outside its aggregate root, surfacing its collection as a read-only view.
Applies toartifact: aggregate
Why
A child that exposed mutable state would let outside code change aggregate data without passing the root's checks. The root creates, finds, and changes children through business methods, and collections surface as read-only views. A child with lifecycle states uses the same state types and no enumeration.
Checka reviewer answers
Reviewed forWhich child entities expose mutable state outside their root? Look at the child entities beside their root, including collection views.
Marked guidance: a reviewer answers this question.
Identify Aggregates with Strongly Typed Version 7 Values#
ruleruleguidancelitespec/backend/domain#identify-aggregates-with-strongly-typed-version-7-valuesAn aggregate identity MUST be a dedicated type wrapping one version 7 UUID, with no conversion, alias, or creation path that bypasses its validation.
Applies toartifact: aggregate, value-object
Why
A dedicated identity type stops one aggregate's identifier from passing where another's is expected, and with no conversion, alias, or creation path around its validation, there is no quiet route back to a bare value. The type rejects the empty value, and each factory also rejects its default. New values arrive from the caller: a factory takes its identifier as an argument, and a method that needs several identifiers takes a generator. Domain reads no clock and calls no static source of time or randomness.
Version 7 values come from the caller-supplied generator, so Domain observes no business time and decides nothing about clocks. Persistence rejects a duplicate key and the write fails, which is the same path as any other write conflict, with the violation mapped to the use case conflict refusal under litespec/backend/persistence#map-a-constraint-violation-to-its-refusal.
Example
A post identifier wraps one version 7 UUID, rejects the empty value, and parses from text through a named method.
Checka reviewer answers
Reviewed forWhich aggregate identities escape the dedicated version 7 type, or which conversions, aliases, or creation paths bypass its validation? Look at each identity type and its creation paths.
Marked guidance: a reviewer answers this question.
Use Immutable Value Objects for Domain Concepts#
ruleruleguidancelitespec/backend/domain#use-immutable-value-objects-for-domain-conceptsA value object MUST be immutable, compare by its complete value, and validate every value at creation.
Applies toartifact: value-object
Why
Immutability with comparison by complete value lets two instances with the same data stand in for each other. The value exposes no mutation and no implicit conversion, because a primitive conversion hides validation and a value conversion erases the domain type. Reading uses the wrapped value or its text form, and creation through a named creation path keeps every value under the validation the type declares.
Example
A post title accepts only non-blank text up to 200 characters: blank or longer input fails creation, and two titles with the same text compare as equal.
Checka reviewer answers
Reviewed forWhich value objects mutate, compare by less than their complete value, or skip validation at creation? Look at each value object and its creation paths.
Marked guidance: a reviewer answers this question.
Define Collection Value Semantics Explicitly#
ruleruleguidancelitespec/backend/domain#define-collection-value-semantics-explicitlyA value object containing a collection MUST implement content equality and a matching hash for its declared ordering rule.
Applies toartifact: value-object
Why
Default structural equality compares collection references rather than contents, so two value objects holding equal lists compare as different. Content equality with a matching hash compares what the value means. Creation copies incoming mutable collections and members return read-only views, so later mutation by the caller leaves the value unchanged. The module terms state whether order is part of the value.
Example
A post-tags value holding an ordered list compares element by element with a matching hash. If order is not part of the value, creation normalizes to the documented comparison order before storing values.
Checka reviewer answers
Reviewed forWhich collection values lack content equality with a matching hash under their declared ordering rule? Look at each collection value beside the module terms.
Marked guidance: a reviewer answers this question.
Model Money with Exact Decimal Values#
ruleruleguidancelitespec/backend/domain#model-money-with-exact-decimal-valuesA monetary amount MUST use an exact decimal type inside one shared Money value object carrying an ISO 4217 currency code and serializing as { "amount": "12.50", "currency": "EUR" }.
Applies toartifact: value-object
Why
Money needs exact arithmetic and an explicit currency on every value, and one shared Money object gives both. The owning module record defines supported currencies, scale, rounding, sign rules, and cross-currency arithmetic, and Infrastructure maps that precision explicitly. A use case fixed to one currency accepts an amount alone and states that currency in its record.
Example
{ "amount": "12.50", "currency": "EUR" }The amount serializes as a string, so no reader parses it as binary floating point.
Checka reviewer answers
Reviewed forWhich monetary amounts escape the shared Money value with its currency and JSON shape? Look at each monetary amount in Domain.
Marked guidance: a reviewer answers this question.
Keep Binary Floating Point Out of Exact Quantities#
rulerulecheckablelitespec/backend/domain#keep-binary-floating-point-out-of-exact-quantitiesA Domain value MUST NOT use binary floating point for a monetary amount, a rate applied to one, or a quantity compared for exact equality.
Applies toartifact: value-object
Why
Binary floating point represents most decimal fractions only approximately, so two amounts a person reads as equal compare as different, and totals drift by fractions of a cent. Exact decimal types keep equality and arithmetic exact.
Example
A tax rate multiplied into a Money amount uses an exact decimal type. A measured latency in a diagnostic log is not a Domain quantity and is outside this rule.
Checkchecked by a tool
Checked bydotnet build reports LS1008 for a double or float type in Domain.
Marked checkable: the tool named here checks it.
Use Stateless Domain Services for Ownerless Rules#
ruleruleguidancelitespec/backend/domain#use-stateless-domain-services-for-ownerless-rulesA domain service MUST be stateless, accept and return Domain values, and perform no persistence, messaging, logging, clock, authorization, or provider call.
Applies toartifact: value-object
Why
Some calculations span concepts with no natural aggregate owner; forcing them into one aggregate couples it to another's data. A stateless service holds that calculation, accepting and returning Domain values with no persistence, messaging, logging, clock, authorization, or provider call. Application loads the aggregates, calls the service, and passes its result into aggregate behavior.
A domain service is stateless and takes no port, so its tests are Domain tests. They sit beside the aggregate tests in the unit tests mirroring the module tree, with no harness, no substitute, and no fixture.
Example
An order-pricing service takes order lines and a currency and returns their total as Money, with no domain repository, clock, logger, or provider client. Application supplies the lines and passes the returned total into aggregate behavior.
Checka reviewer answers
Reviewed forWhich domain services hold state, take non-Domain values, or reach persistence, messaging, logging, clock, authorization, or a provider? Look at each domain service and its calls.
Marked guidance: a reviewer answers this question.
Expose Repositories for One Aggregate Only#
ruleruleguidancelitespec/backend/domain#expose-repositories-for-one-aggregate-onlyA domain repository MUST serve exactly one aggregate root, exposing only aggregate and Domain types, with no composable query, session, or generic CRUD surface.
Applies toartifact: repository
Why
One domain repository per aggregate keeps the write side shaped by the aggregates it loads: a required load looks the aggregate up by identity and reports a missing aggregate through the missing-resource refusal, so a handler repeats no absence check, while an optional lookup is reserved for cases where absence is normal. A flow that finds a record or creates it is two use cases that the boundary owning the flow chooses between, following litespec/backend/application#implement-one-outcome-path-per-command. A composable query, session, or generic CRUD surface would expose store mechanics to every handler.
This boundary is the write side only. Reading for a query uses a read model owned by Application and shaped by Infrastructure, and that path declares no Domain contract. A repository that gained a projection method would put query shapes in Domain, which is the coupling this layer exists to prevent.
Example
A post repository loads a post by identity and reports a missing post through the missing-resource refusal, plus a staging call that stages the aggregate.
The Infrastructure implementation owns that refusal. The command pipeline owns the commit. The staging call stages the aggregate through the selected provider.
Checka reviewer answers
Reviewed forWhich domain repositories serve more than one aggregate, expose non-Domain types, or offer a composable query, session, or generic CRUD surface? Look at each repository interface.
Marked guidance: a reviewer answers this question.
Place Every Constituent Type in Its Aggregate's Folder#
rulerulecheckablelitespec/backend/domain#place-every-constituent-type-in-its-aggregates-folderA Domain constituent type, meaning an identifier, a state type, a closed set, a child entity, a value object, or a repository interface, MUST sit in the Domain/ folder of the aggregate it belongs to, or in the product Domain/ folder.
Applies toartifact: any
Why
A constituent serves no record of its own: it is part of its aggregate, whose record already states the states, transitions, and data the constituent holds. A mark on each would write that fact a second time, and the two copies would drift. The folder already says which aggregate a constituent belongs to, so the builder derives the link from it, and a type in a folder that holds no aggregate root and is not the product Domain/ folder is the one place the derivation fails. A domain service that holds one rule calculation is not a constituent: it carries its rule/ mark.
Example
Claimwick's ExpenseState and IExpenseRepository sit beside the Expense root in Spending/Expenses/Domain/ and carry no mark; the builder lists them as constituents of aggregate/spending.expense. Money sits in the product Domain/ folder and belongs to no aggregate.
Checkchecked by a tool
Checked bylitespec model check reports constituent-parent-missing (level A) for an unmarked Domain type whose folder holds no aggregate root and is not the product Domain/ folder, and the model output lists each constituent under its aggregate.
Marked checkable: the tool named here checks it.
Declare Cross-Record Keys on the Aggregate#
ruleruleguidancelitespec/backend/domain#declare-cross-record-keys-on-the-aggregateAn aggregate needing a cross-record uniqueness key MUST expose its current keys through UniquenessKeys.
Applies toartifact: aggregate
Why
That method is the only uniqueness code in Domain: the aggregate declares its current keys, and persistence claims and releases them in the commit that changes the aggregate, following litespec/backend/persistence#claim-a-cross-record-key-at-commit. The aggregate computes keys from its own state and validates nothing about other records, because the store arbitrates the race.
Example
In the library product, a book holding a list of copy barcodes returns each barcode from UniquenessKeys, so adding a copy claims the new barcode at commit. In the Claimwick product, a payout carrying a reference that is unique across payouts and adjustments returns that reference, so a duplicate reference fails the commit on either record.
Checka reviewer answers
Reviewed forWhich aggregates with a cross-record uniqueness rule miss UniquenessKeys, or carry other uniqueness code in Domain? Look at each such aggregate.
Marked guidance: a reviewer answers this question.
Raise Immutable Facts as Domain Events#
ruleruleguidancelitespec/backend/domain#raise-immutable-facts-as-domain-eventsA domain event MUST be immutable and carry business data only.
Applies toartifact: domain-event
Why
A domain event records a completed fact for reactions to consume. Its name states the aggregate and the completed fact, following litespec/workspace/naming#anchor-aggregate-owned-types-on-the-aggregate-root and litespec/workspace/naming#use-architectural-suffixes, so a reader knows which aggregate raised the fact without opening the file. The event carries business data only: typed identifiers and the values its reactions need, with no aggregate, session, service, mutable collection, or thrown error, because any of those would couple reactions to the raising aggregate. Envelope identity, such as message identity and causation identity, lives on the published integration event that carries the fact outside the process. That integration event names the domain event in translates.
Checka reviewer answers
Reviewed forWhich domain events mutate, misname their aggregate or fact, or carry more than business data? Look at each domain event.
Marked guidance: a reviewer answers this question.
Reject Business Violations with Dedicated Refusals#
ruleruleguidancelitespec/backend/domain#reject-business-violations-with-dedicated-refusalsA rejected business rule MUST end its use case in a dedicated refusal carrying its stable code.
Applies toartifact: aggregate, value-object
Why
One refusal per rejected rule lets each boundary map outcomes without parsing messages: the refusal names the failed rule and the stable code travels to the caller. The name leads with the aggregate root. Domain raises no standard library, validation, transport, or provider failure for a business rejection, and carries no transport status. Refusal names follow litespec/workspace/naming#name-exceptions-by-failed-rule; this provision owns the one-refusal-per-rule shape and the code each refusal carries.
Example
A refund allocation rule ends its use case in its own refusal, rather than sharing one refusal with a code passed at the call site.
Two rules that share a caller-visible refusal code because a boundary maps them to one response still get two refusals. The shared code lives in the two refusals, not in a value passed by the aggregate. One refusal serves more than one input only when one rule fails from more than one input and the differing values travel as creation parameters.
Application validators handle malformed caller input through validity findings. Aggregate and value-object refusals remain the last defense when direct Domain use violates a rule. Command handlers do not catch expected Domain refusals. The host maps them to transport errors through the mapping the API page documents under litespec/backend/api#defer-error-mapping-to-the-global-handler.
Checka reviewer answers
Reviewed forWhich rejected business rules end outside a dedicated refusal with its stable code, or leak transport status? Look at each rejection beside its refusal.
Marked guidance: a reviewer answers this question.
Reference Other Aggregates by ID#
ruleruleguidancelitespec/backend/domain#reference-other-aggregates-by-idAn aggregate MUST store another aggregate's typed identifier rather than an object reference.
Applies toartifact: aggregate
Why
An object reference between aggregates loads one aggregate's data through another, which couples their consistency boundaries and their storage. A typed identifier keeps each aggregate loadable on its own: Application loads each aggregate through its domain repository when a command coordinates several, with consistency: atomic citing the rule in cites.
Checka reviewer answers
Reviewed forWhich aggregates store another aggregate as an object reference instead of a typed identifier? Look at the fields of each aggregate.
Marked guidance: a reviewer answers this question.
Pass Nondeterministic Values into Domain#
rulerulecheckablelitespec/backend/domain#pass-nondeterministic-values-into-domainDomain behavior MUST receive time, randomness, and new identifiers as arguments rather than read them, and read no clock.
Applies toartifact: any
Why
Domain behavior that read a clock or generated identifiers would return different results for the same call, which makes tests nondeterministic. Receiving time, randomness, and new identifiers as arguments keeps every Domain call a pure function of its inputs: a test supplies explicit values and stays deterministic. Application obtains each value through an owned port and passes it in. A method that needs more than one value receives a clock or a generator through its arguments, and version 7 identifiers come from the caller-supplied generator.
Checkchecked by a tool
Checked bydotnet build reports LS1009 for a clock, randomness, or identifier read in Domain.
Marked checkable: the tool named here checks it.
Describe Every Public Domain Contract in Documentation#
ruleruleguidancelitespec/backend/domain#describe-every-public-domain-contract-in-documentationEvery public Domain type and member MUST carry documentation stating the business constraint, result, or refusal it represents.
Applies toartifact: any
Why
A reader meeting a public Domain member needs to know the business rule behind it, not just its signature. Documentation states the business constraint, result, or refusal the member represents: a mutation method names its source states, resulting state, protected invariant, and recorded event. A summary that restates the member name adds nothing.
Checka reviewer answers
Reviewed forWhich public Domain types or members lack documentation of their business constraint, result, or refusal? Look at the documentation on each public member.
Marked guidance: a reviewer answers this question.
Conventions#
Organize a Module by Aggregate and Concept#
conventiondefaultcheckablelitespec/backend/domain#organize-a-module-by-aggregate-and-conceptGive each aggregate a Domain/ folder named with its plural root name, holding its own states, events, refusals, and concepts.
ReplacementA project names its own module layout in its documentation.
Applies toartifact: any
Why
Each aggregate Domain/ folder holds everything one aggregate owns: its states, events, refusals, and concepts. A reader finds the full aggregate in one place instead of hunting shared folders. A single aggregate stays flat only when its plural root name equals the module name, and separate aggregates keep separate lifecycle, event, and refusal folders.
Example
{Product}/
Domain/ the shared kernel: the root, event, identity, and refusal contracts
Posts/ one aggregate whose name matches the module: kept flat in the module folder
Domain/
Post
PostId
PostTitle
PostRepository
States/
PostState
PostDraftState
PostPublishedState
PostArchivedState
Events/
PostCreatedEvent
PostPublishedEvent
Refusals/
PostIdentityRequired
PostAlreadyPublished
Shipping/ one aggregate whose name differs from the module, with a domain union in a concept folder
Shipments/
Domain/
Shipment
ShipmentId
ShipmentRepository
Outcomes/
ShipmentOutcome
ShipmentDeliveredOutcome
ShipmentReturnedOutcome
ShipmentLostOutcome
States/
ShipmentState
ShipmentPendingState
ShipmentDispatchedState
ShipmentDeliveredState
Events/
ShipmentOutcomeRecordedEvent
Customers/ more than one aggregate: one plural folder per aggregate
CustomerAccounts/
Domain/
CustomerAccount
CustomerAccountId
CustomerAccountRepository
States/
CustomerAccountState
CustomerAccountClaimedState
Events/
CustomerAccountRestrictedEvent
Consents/
Domain/
Consent
ConsentId
ConsentRepository
States/
ConsentState
ConsentGrantedState
Events/
ConsentGrantedEventEach aggregate's domain repository stays with the aggregate it loads. The module, aggregate, and use case order follows litespec/backend/architecture#organize-code-by-module-aggregate-and-use-case. Each stack page states the file names and extensions that carry this layout.
Checkchecked by a tool
Checked bylitespec model check reports code-placement-drift (level S) for a domain event outside its aggregate's Events folder.
Marked checkable: the tool named here checks it.
Use These Domain Names#
conventiondefaultguidancelitespec/backend/domain#use-these-domain-namesName each Domain type from the aggregate root it belongs to, following the workspace naming provisions cited below.
ReplacementA project names its own Domain naming rules in its documentation.
Applies toartifact: any
Why
Aggregate-prefixed names keep each type readable at its call site without its namespace. Aggregate-owned names follow litespec/workspace/naming#anchor-aggregate-owned-types-on-the-aggregate-root. Architectural suffixes follow litespec/workspace/naming#use-architectural-suffixes. Refusal names follow litespec/workspace/naming#name-exceptions-by-failed-rule. A project names every state case in one form and keeps it.
Example
A post title reads as PostTitle at its call site, and a published post state as PostPublishedState. The stack page states the declaration form each name takes.
Checka reviewer answers
Reviewed forWhich Domain names break the cited workspace naming provisions, or which state cases break the one kept form? Look at each Domain name.
Marked guidance: a reviewer answers this question.
Reuse the Shared Domain Contracts Across Modules#
conventiondefaultguidancelitespec/backend/domain#reuse-the-shared-domain-contracts-across-modulesDeclare the shared Domain contracts once in the product Domain/ folder and reuse them across every module.
ReplacementA project names its own shared-contract layout in its documentation.
Applies toartifact: any
Why
One declaration keeps the root, event, identity, and refusal mechanics identical in every module; a second declaration drifts from the first with the next fix.
Example
The shared contracts are the aggregate root, the domain event, the typed identity, and the refusal.
Event clearing is delivery mechanics, not a business mutation. Infrastructure calls it only after delivery accepts the pending events.
Checka reviewer answers
Reviewed forWhich shared Domain contracts repeat their declaration across modules? Look at the root, event, identity, and refusal contracts in each module.
Marked guidance: a reviewer answers this question.
Align ID Representations at Every Boundary#
conventiondefaultguidancelitespec/backend/domain#align-id-representations-at-every-boundaryRepresent an identifier the same way in Domain, persistence, transport, and generated clients.
ReplacementA project names its own identifier-mapping rules in its documentation.
Applies toartifact: aggregate, state-record, value-object, domain-event
Why
One identifier takes a different form at each boundary, and each form is correct in its own place. Alignment means every form still reads as the same value, so a reader tracing one identifier follows it across boundaries without translation.
Example
Domain and Application hold the typed identifier; the HTTP API states the route, query, JSON, and contract forms, and Persistence states the stored form. Alignment means a reader recognizes one identifier in every form.
Checka reviewer answers
Reviewed forWhich identifiers read as different values across Domain, persistence, transport, or generated clients? Look at one identifier in each boundary form.
Marked guidance: a reviewer answers this question.