The Domain Layer
Part 18 built the walls. This part is about what goes inside the innermost one: the model — the types that carry your business rules.
Everything here lives in the domain package (or the orders-domain project, if you took the four-project layout of 18.5). That means everything here is pure: no Async, no await, no use, no repository, no logging. A domain function takes values and returns values. If it cannot decide with what it was handed, it says so by returning an error.
Part 20 covers the layer that drives this one — use cases, event handlers, jobs, and the repository ports that load and store what you build here. Part 21 zooms out to other contexts.
The one idea: model with types until most of your rules cannot be broken, then give the rules that remain exactly one home.
Those two halves are in that order deliberately. A rule enforced by a type is checked everywhere, by the compiler, forever. A rule enforced by a function is checked wherever someone remembers to call it. Dovetail has a strong type system; the whole point of this chapter is to spend it.
19.1 Start With the Types
Section titled “19.1 Start With the Types”Dovetail separates a type’s declaration from its behavior: a record says what the shape is, and a module of the same name says what it does. Lean into that. Put the entire model’s vocabulary in one file, and the behavior in a file per concept:
orders-dovetail/src/├── types.dove every record, enum, and newtype in the model├── Order.dove module Order — the aggregate's behavior├── Shipment.dove module Shipment — a second aggregate└── policy.dove domain services and policiesThis is the first recommendation in the chapter because it is the one that changes how you work, not just how your files are arranged.
It does assume the functional style of 19.5, which is what the rest of this chapter is written in. The layout works because a record separates shape from behavior, so the shapes can be collected without dragging the functions along. A class does not separate them, and is not meant to — see 19.5 for what an object-oriented model organizes by instead.
Why one types file
Section titled “Why one types file”Because the types file is the model. Everything else in the domain layer is consequence. When the shapes are collected in one place, you can read the entire model in one sitting — states, parts, errors, events — and that turns it into an artifact you can actually use: something to review, to walk through with a domain expert, to hand a new joiner, and to diff when the business changes. A model change should be a visible edit in one file, not a shift scattered across twelve.
Because it is where the modelling happens. The habit this layout encourages is to write the types first and the behavior second — to spend your thinking on what states exist and what each one carries, before writing a single function. That is the work. 19.2 is entirely about doing it well, and it is much easier to do when you are looking at the whole vocabulary at once rather than at one record in one file.
Because mutual references are free. All files in a package are typechecked together, so Order may mention OrderLine, OrderStatus, and OrderPlaced in any order, with no forward declarations and no dependency ordering to maintain. There is nothing pushing you to split.
Because the behavior files stay about behavior. Order.dove reads as a list of the things an order can do, uninterrupted by field declarations.
19.2 Make Illegal States Unrepresentable
Section titled “19.2 Make Illegal States Unrepresentable”This is the section the rest of the chapter rests on.
Most “business rules” are not really rules. They are statements about which combinations of data make sense — and a statement about which combinations make sense is a type. Every one you can express as a type is a rule that cannot be violated, cannot be forgotten at a call site, and needs no test.
The model most people write first
Section titled “The model most people write first”public enum OrderStatus = Draft Placed Cancelled
public record Order = id: OrderId customerId: CustomerId status: OrderStatus lines: List<OrderLine> placedAt: Option<Instant> total: Option<Money> cancelledAt: Option<Instant> cancellationReason: Option<CancellationReason>This looks reasonable, and it is what a database table would suggest. Count what it permits: three statuses times four independent optional fields is 48 representable combinations, of which perhaps three make sense. The rest are all constructible, and the compiler is happy with every one of them:
- a
Draftwith aplacedAt - a
Placedorder with nototal - an order that is
Cancelledbut has aplacedAtand nocancelledAt - an order with both a cancellation reason and a
Draftstatus
Worse than the nonsense states is what they do to every function you write afterwards. total is an Option<Money> in all three states, so every caller must handle None — including callers that only ever see placed orders, where None is impossible. You end up with .require sprinkled through the model, or a defaulted zero that quietly becomes a wrong invoice.
The status enum here is not modelling anything. It is a label sitting next to the data, and keeping them consistent is now your job, forever, in every function.
The model to write instead
Section titled “The model to write instead”Put the data inside the state that gives it meaning:
public record Placement = placedAt: Instant total: Money
public record Cancellation = cancelledAt: Instant reason: CancellationReason
public enum OrderStatus = Draft Placed(Placement) Cancelled(Cancellation)
public record Order private = id: OrderId customerId: CustomerId status: OrderStatus lines: List<OrderLine>Now there are exactly three representable states, and each carries precisely what that state means:
- A
Drafthas no placement. Not an empty one, not aNone— there is no field to be wrong about. - A
Placedorder always has aplacedAtand atotal. Both are plain values, notOption, because in this state their absence is not a possibility. - A
Cancelledorder always has a reason.
The nonsense states are gone — not caught, gone. There is no code that rejects them because there is no way to write them down. Try to read a placement off a draft and the compiler stops you before you finish the thought:
match order.status with case OrderStatus.Draft(placement) => ...error: variant 'OrderStatus.Draft' has no fields; use 'OrderStatus.Draft' without parenthesesAnd reading the value requires proving you are in the state that has it:
public function placedAt(self): Option<Instant> = match self.status with case OrderStatus.Placed(placement) => Some(placement.placedAt) case _ => NoneThe Option appears exactly once, at the boundary where it is honest — asking a possibly-unplaced order when it was placed — instead of infecting the four fields underneath.
Naming the payload, or inlining it
Section titled “Naming the payload, or inlining it”A variant can carry its fields directly, with no separate record:
public enum OrderStatus = Draft Placed { placedAt: Instant, total: Money } Cancelled { cancelledAt: Instant, reason: CancellationReason }built with OrderStatus.Placed { placedAt = now; total = total } and read by naming the fields in the pattern:
public function placedAt(self): Option<Instant> = match self.status with case OrderStatus.Placed { placedAt = at; total = _ } => Some(at) case _ => NoneBoth forms model exactly the same three states — this is a question of whether the payload deserves a name, not of how much the type can prove.
Giving the payload a name preserves the brace syntax. With the named declaration Placed(Placement), the same construction and field pattern still work:
let status = OrderStatus.Placed { placedAt = now; total = total }
match status with case OrderStatus.Placed { placedAt = at; total = _ } => Some(at) case _ => NoneThe construction means OrderStatus.Placed(Placement { placedAt = now; total = total }). The pattern means OrderStatus.Placed(Placement { placedAt = at, total = _ }). This lets you extract an inline payload into a named record without rewriting callers that construct or inspect its fields.
When a caller needs the concept as a whole, the positional form binds the actual record:
match status with case OrderStatus.Placed(placement) => Some(placement) case _ => NoneBoth forms remain available. Braces inspect the record’s fields; parentheses bind or pass the Placement value. The shorthand applies to exactly one record payload, including a generic record whose type is known. It does not unwrap newtypes, classes, or multiple positional payloads. Patterns may omit fields, so case OrderStatus.Placed { placedAt = at } is sufficient when only the timestamp matters; there is no need for a rest marker.
Inline when the fields only ever exist as part of that state. It is two fewer types in types.dove, and the enum reads as the whole story of the state machine.
A named record when the payload is a concept in its own right — something you pass to a function, return, store, or want to say in a signature. Placement is one: 19.7 builds it as a value before putting the order into the state that holds it, and a function can take a Placement without taking an Order. Once you find yourself writing the same field list in two places, it has already earned the name.
Apply that test per variant, not per enum — one state’s payload earning a name says nothing about its neighbours’. OrderStatus in 19.12 ends up mixed for exactly this reason: AwaitingApproval(Money) carries a bare total that means nothing outside the state, while Approved(Approval) names a payload the approval rules pass around. The rest of this chapter uses named payloads because Placement and Cancellation both earn one.
Rules of thumb
Section titled “Rules of thumb”A field that only means something in one state belongs inside that state. This is the whole technique, and placedAt is the archetype: it exists once an order is placed, and asking for it before then is not a missing value, it is a meaningless question.
Option means “optional in every state”, not “filled in later”. If a field becomes populated at a transition, it belongs to the state the transition produces. A middle name is genuinely optional; a shipment date is a state.
Two booleans that cannot both be true are an enum. isPlaced and isCancelled gives you four combinations for three states, and one of them is a bug you will eventually ship.
A collection with a minimum size is not a plain list. If a placed order must have at least one line, the honest type says so; List<OrderLine> cannot. So the rule ends up in place as a check that can refuse (19.7) — which is fine, but notice it is the type system telling you where a rule leaked out into a function.
Errors are types too
Section titled “Errors are types too”The same discipline applies to refusals. An error enum whose variants are bare names throws away everything the code knew at the moment it refused:
public enum OrderError = NoLines NotDraft(OrderStatus) TooManyLines(Int32) MixedCurrency(Currency, Currency) CreditRefused ApprovalRequired(Money)NotDraft(OrderStatus) carries what the order actually was, so the caller can say “this order was cancelled on the 3rd” instead of “not a draft”. MixedCurrency(Currency, Currency) names both offenders. ApprovalRequired(Money) reports the total that tripped the threshold, so the message writes itself and the application layer can route the approval without going back to look.
The rule: an error should carry the evidence for itself. If reporting it well requires re-deriving what happened, the variant is missing a field.
The compiler as reviewer
Section titled “The compiler as reviewer”The payoff arrives on the day the business adds a state. Add Refunded(Refund) to OrderStatus and every exhaustive match in the codebase stops compiling — each one a place where somebody has to decide what a refunded order means. That is a review checklist generated by the compiler, complete and free.
This is why a bare case _ => catch-all is worth being suspicious of in the domain layer. It buys you a little brevity today and costs you exactly the compile errors you would have wanted tomorrow. Match the states explicitly, and let the additions find you.
How far to take it
Section titled “How far to take it”You can go further. Typestate gives each state its own type — DraftOrder, PlacedOrder — so that place only exists on a draft and calling it twice is not a runtime error but a nonexistent function.
That is genuinely stronger, and for most models it is too much. You get a type per state, conversions at every boundary, and a repository that has to return a sum type anyway, since a row’s state is not known until it is read. The enum-with-payloads is the sweet spot: it eliminates the illegal combinations, which is where the bugs actually live, while keeping one type you can load, store, and pass around.
Reach for typestate when a wrong transition is catastrophic rather than merely wrong — a payment capture, a compliance hold, a released deployment — and accept the ceremony for those.
19.3 Value Objects
Section titled “19.3 Value Objects”A value object is defined by its value, not by identity. Two amounts of 12.50 USD are the same amount; there is no “which one.” Value objects have no lifecycle, and they never change.
Where they live
Section titled “Where they live”Default to the domain package. Most value objects are nobody else’s business — an OrderLine, a DiscountBand, a PickingPriority. They go in types.dove with everything else.
Promote one to primitives when a second layer needs to speak it — in practice, when it appears on the wire. That is what primitives is for (18.2): it exists so contract and domain can share a vocabulary without either depending on the other. A type only the model uses creates no such pressure, so putting it there buys nothing and widens a surface other code compiles against.
OrderId, Sku, Money, and Quantity are in primitives below because the contract carries them. Had they been internal to the model, they would live in the domain package and nothing else would change.
That is the same rule at every boundary: put a type at the innermost place that needs it, and promote only when a second consumer appears. Domain-only stays in domain; shared with the contract moves to primitives; shared with another context moves to the shared kernel (18.8). Promotion is cheap — move the file, change the package line — which is precisely why starting inward costs nothing.
Make invalid values unconstructable
Section titled “Make invalid values unconstructable”19.2 removed illegal combinations. The same idea one level down removes illegal values: a private newtype and a module function that can refuse construction.
package acme.orders.primitives
public newtype OrderId private = String
module OrderId = public function parse(raw: String): Result<OrderId, String> = if raw.isEmpty then Error("order id must not be empty") else Ok(OrderId(raw))
public function text(self): String = self.value
public newtype Sku private = String
module Sku = public function parse(raw: String): Result<Sku, String> = if raw.length != 8 then Error("sku must be exactly 8 characters") else Ok(Sku(raw))
public function text(self): String = self.valueprivate after the newtype name is what makes this real. Outside module Sku in the defining package, even elsewhere in that same package, Sku("nope") is a compile error — cannot construct private newtype ‘Sku’ outside its associated module. Only that module can read .value or unwrap a constructor pattern; callers use text(). Trait implementations and extensions must use these module functions too. The only public construction path here is parse, so once you hold a Sku it is valid, and no code downstream ever checks again.
Push behavior onto the value
Section titled “Push behavior onto the value”A value object with no methods is a tooltip. Behavior that belongs to the value goes on the value:
@derive(Equatable)public enum Currency = Usd Eur
public enum MoneyError = CurrencyMismatch
public record Money = minorUnits: Int64 currency: Currency
module Money = public function of(minorUnits: Int64, currency: Currency): Money = Money { minorUnits = minorUnits; currency = currency }
public function add(self, other: Money): Result<Money, MoneyError> = if self.currency != other.currency then Error(MoneyError.CurrencyMismatch) else Ok(Money { minorUnits = self.minorUnits + other.minorUnits currency = self.currency })
public function times(self, quantity: Quantity): Money = Money { minorUnits = self.minorUnits * quantity.count().toInt64() currency = self.currency }add returns a Result because adding USD to EUR is not an arithmetic operation, it is a bug — and the type says so. When money is an Int64, every + in the codebase is a place where the currencies could have differed and nobody checked.
minorUnits is the other decision worth making once: money as a floating-point number is a rounding bug waiting for a large enough invoice.
When to introduce one
Section titled “When to introduce one”Default to yes. A bare String or Int32 in a domain signature is a smell — not an error, but worth a second look every time, because it is the shape a rule takes before anyone has noticed it is a rule. Quantity not Int32, because quantities are positive. Sku not String, because SKUs have a shape. Money not Int64, because 500 of what?
The immediate payoff is that argument-order mistakes stop compiling. Given function priceFor(sku: Sku, quantity: Quantity): Money you cannot swap the arguments, which you very much can when they are String and Int32.
The delayed payoff is the bigger one, and it is why this is a smell rather than a preference. The rule usually arrives after the field does. Nobody says “SKUs are eight characters” on day one; it turns up in the third sprint, in a bug report. If Sku is a String, that rule has nowhere to live, so it lands in whichever function noticed first — and then in a second one, written by someone who did not know about the first, and the two drift. If Sku is a type, there is exactly one place the rule can go, and the day it goes there every value in the system is already validated.
By then the primitive is also too late to tighten. String is in two hundred signatures and nothing distinguishes the ones that meant a SKU, so the change you want is not a change you can make. The type costs six lines on the day you have no rules to put in it, which is precisely the day it is cheap.
The exceptions are narrow. Inside a value object’s own representation — Money.minorUnits is an Int64, and the wrapper is the whole point. And genuinely free text with no unit and no rule, such as a customer’s note. Everything else earns a type.
Conventions: parse when construction can fail, make when it cannot, never new or create.
19.4 Entities
Section titled “19.4 Entities”An entity is defined by identity over time. An order that has had ten lines added and one removed is still that order. Two orders with identical contents are still two different orders.
- The identity is a value object —
OrderId, notString— so it cannot be confused with aCustomerId. - The ID comes from outside. Generating it inside the entity means randomness or a clock, and both are IO. An adapter supplies it, so the domain stays pure and deterministic. The same goes for timestamps:
placetakesnowas a parameter (19.8). - Every change goes through a function that can refuse. No setters, and no assembling a new version by hand from outside the module.
- An entity is not automatically an aggregate root.
OrderLinehas identity within its order but no independent existence; only roots get repositories (19.6).
Make the transition boundary enforceable
Section titled “Make the transition boundary enforceable”The recommended declaration in 19.2 is public record Order private = .... The type and its fields remain visible, so a caller can read order.status, inspect order.lines, and match the record. Construction and with updates belong exclusively to module Order in the defining package. The type can stay in types.dove and its module in Order.dove; file placement does not change that ownership.
This makes the entity rule enforceable: callers must use Order.draft, order.addLine(...), and order.place(...). Writing order with { status = OrderStatus.Placed(...) } from application code is a compile error. Trait implementations and extensions must use those functions as well.
OrderStatus remains publicly constructible in this model. Building a status value does not let a caller install it in a private Order. If the status needs its own construction rules, declare enum OrderStatus private = ... and put its factories in module OrderStatus; even module Order must then call those factories. Enum patterns remain available either way. Private records and enums expose inspection; private newtypes also hide their wrapped value.
The same boundary applies to named record payload shorthand. If Placement has a private constructor, OrderStatus.Placed { placedAt = now; total = total } must be inside module Placement, because it constructs a Placement. OrderStatus.Placed(placement) can wrap an existing value without constructing another record. Either pattern form remains available for inspection. If OrderStatus also has private construction, its permission is checked independently: being inside one type’s module does not grant construction access to the other.
19.5 Two Styles: Values or Objects
Section titled “19.5 Two Styles: Values or Objects”Record fields cannot be reassigned. Referenced arrays or classes can still contain mutable state; record immutability is shallow. Classes can carry mutable state. So this is not a per-type decision — it is a choice about what the whole domain layer is written in:
- Functional. Aggregates are records. A rule is a function from a value to a new value.
- Object-oriented. Aggregates are classes. A rule is a method that changes the object in place.
Pick one for the entire layer. A model where half the aggregates are values and half are objects is the worst of both, because every reader has to check which kind they are holding.
This chapter recommends the functional style, and is written in it.
The functional model
Section titled “The functional model”module Order = public function draft(id: OrderId, customerId: CustomerId): Order = Order { id = id customerId = customerId status = OrderStatus.Draft lines = List<OrderLine>.Nil }
public function addLine(self, line: OrderLine): Result<Order, OrderError> = match self.status with case OrderStatus.Draft => if self.lines.length >= maxLines then Error(OrderError.TooManyLines(maxLines)) else Ok(self with { lines = self.lines.prepend(line) }) case other => Error(OrderError.NotDraft(other))A rule that refuses returns Error; a rule that succeeds returns the next Order. The with expression keeps that from being a wall of field copies.
Note the shape of that match: because the state machine is an enum (19.2), the guard is the state check, and the failure case gets the actual status to report. In a model with a bare status field this would be if self.status != OrderStatus.Draft, and the error would have nothing to say.
Why this is the better default in Dovetail:
- It matches everything else about the language. No exceptions, rules returning
Result, a layer with no IO, modules that already separate shape from behavior.Result<Order, OrderError>is the same shape as every other fallible operation and composes withandThenandorReturnlike they all do. - Immutable contents make sharing safe. An
Ordermade entirely of immutable values can be shared without another function changing it. An array or mutable class stored in a record can still be changed through an alias. - The previous value is still there — for comparing before and after, auditing what changed, retrying from a known state, or replaying history. Event sourcing stops being an architecture and becomes the natural reading of the model.
- The tests are stronger. You can assert both that the operation produced what it should and that its input was left alone (19.11).
The cost, named honestly: an operation that both advances the aggregate and reports a fact must return both, as a tuple (19.7).
The object-oriented model
Section titled “The object-oriented model”public class Order private ( public id: OrderId, public customerId: CustomerId, mutable status: OrderStatus, mutable lines: List<OrderLine>) = public function draft(id: OrderId, customerId: CustomerId): Order = Order(id, customerId, OrderStatus.Draft, List<OrderLine>.Nil)
public function addLine(self, line: OrderLine): Result<Unit, OrderError> = match self.status with case OrderStatus.Draft => self.lines = self.lines.prepend(line) Ok(()) case other => Error(OrderError.NotDraft(other))Two details are worth pointing out: private on the constructor means only Order.draft can build one, and class members are private unless marked public, so status and lines are not readable from outside at all.
Everything in 19.2 applies unchanged here — the state machine is still an enum carrying its data. The style choice is about mutation, not about how well you model.
It changes the file layout
Section titled “It changes the file layout”19.1’s types.dove does not apply to this style, and should not. A class declares its fields and its methods in one body: data and behavior travel together, which is the entire point of reaching for a class. So an object-oriented model organizes one file per aggregate — Order.dove holds the Order class whole — with the value objects, enums, and errors it coordinates in a shared types.dove alongside it, since those are still records and enums.
orders-dovetail/src/├── Order.dove class Order — fields and behavior together├── Shipment.dove class Shipment├── types.dove value objects, state enums, errors, events└── policy.dove domain servicesBe honest about what that costs. 19.1’s real argument was not tidiness, it was that one readable file is the model — something to review, to walk through with a domain expert, to diff when the business changes. Split across aggregate files, the model stops being a single artifact and you get it back only by reading several files at once. The state enums and value objects still collect in types.dove, so a good deal survives; the aggregate shapes do not.
Do not try to recover the split with an extension or a module hanging off the class. Even where the language allows it, it is two patterns fighting: a class that has handed its behavior to somewhere else is a record wearing a class’s syntax, and you would be better off with the functional style.
Choosing
Section titled “Choosing”| Functional (record) | Object-oriented (class) | |
|---|---|---|
| Mutation | none — produce the next value | in place |
| Aliasing bugs | impossible | possible; a reference handed out can change |
| Updating | self with { status = ... } |
self.status = ... |
| State and an event | returns a tuple | event only; state already changed |
| Event sourcing, audit, replay | natural | awkward |
| File layout | types.dove + a file per concept (19.1) |
a file per aggregate; shape and behavior together |
Fits Dovetail’s Result-based, IO-free layer |
closely | fine |
| Reads like the blue book | takes adjustment | yes |
The recommendation: the functional style, because it agrees with the rest of the language. Choose the object-oriented style if your team thinks natively in objects, or if you are porting an existing Java or C# model and want the shapes to line up. It is a coherent choice, not a lesser one. What you should not do is mix them.
19.6 Aggregates
Section titled “19.6 Aggregates”An aggregate is a consistency boundary: a cluster of entities and value objects that must be correct together, with one entity — the root — as the only way in. Order is a root; OrderLine is inside its boundary and has no independent existence.
This is the most consequential modelling decision you make, because it decides what has to be correct together — and so what a unit of work looks like (20.1).
The three rules
Section titled “The three rules”1. Protect true invariants inside the boundary. The test is temporal: if a rule must hold at every instant, everything it mentions belongs in one aggregate. If it may lag by a second without harm, it does not.
“An order’s total must equal the sum of its lines” must never be observably false, so lines are inside Order. “A customer’s lifetime spend must reflect their orders” can lag, so that is a handler (20.3), not an aggregate. And a screen that shows things together is not evidence of an invariant — that is a read model (20.2), and it can be assembled from anywhere.
2. Reference other aggregates by identity. An Order holds a CustomerId, never a Customer. Hold the object and three things follow: loading an order loads a customer which loads their orders; one aggregate’s rules start reaching into another’s state; and the boundary stops being visible in the code.
3. No aggregate’s invariants may depend on another’s. Each one has to be correct on its own, checkable from what it holds. If keeping A valid requires reading or changing B in the same breath, you have not found two aggregates — you have found one, drawn in the wrong place.
19.7 Domain Events
Section titled “19.7 Domain Events”A domain event is a fact that has already happened, named in the past tense: OrderPlaced, PaymentDeclined. Not a command, not a request — a record of something that is now true.
public record OrderPlaced = orderId: OrderId customerId: CustomerId total: Money placedAt: InstantReturn the event from the function that caused it
Section titled “Return the event from the function that caused it”The temptation is an ambient event bus the aggregate can publish to. Resist it: publishing is IO, and this layer has none. The function that caused the fact returns it:
public function place( self, now: Instant, creditApproved: Bool ): Result<(Order, OrderPlaced), OrderError> = match self.status with case OrderStatus.Draft => if self.lines.isEmpty then Error(OrderError.NoLines) else if !creditApproved then Error(OrderError.CreditRefused) else let total: Money = self.total() let placement: Placement = Placement { placedAt = now; total = total } let placed: Order = self with { status = OrderStatus.Placed(placement) } let event: OrderPlaced = OrderPlaced { orderId = self.id customerId = self.customerId total = total placedAt = now } Ok((placed, event)) case other => Error(OrderError.NotDraft(other))The signature tells the whole story: placing either fails for one of these reasons, or it succeeds, and here is both the order that resulted and the fact that is now true. The caller decides what to do with the fact (20.5); the domain does not care whether it is published, logged, or dropped.
Two things in that body are worth calling out. The Placement is built once and stored in the state, so a placed order can never be missing its total — the invariant from 19.2 is maintained by construction rather than by a later check. And now is a parameter, not a clock reading, for the reason in 19.8.
This pair — the new state and the event — is the shape of every state-changing operation in the functional style. If an operation genuinely produces several events, return Result<(Order, List<OrderEvent>), OrderError>; the tuple already generalizes.
The alternative — carrying pending events on the aggregate and draining them in the application layer — handles multi-event operations more gracefully, at the cost of a field that is not really part of the aggregate’s state and that someone must remember to drain. Prefer returning events.
Domain events stop at the boundary
Section titled “Domain events stop at the boundary”A domain event is phrased in domain types and never leaves the context. The application layer translates the ones that matter into integration events, phrased in contract types (18.9, 20.5, 21.5).
Conflating the two makes a model impossible to refactor: rename a field in your aggregate and you have broken a stranger’s deployment. Keeping them separate costs one mapping function and buys the freedom to change your model whenever you understand it better — which is the entire point of having one.
Events carry value objects and IDs, never entities.
19.8 Domain Services
Section titled “19.8 Domain Services”Some logic is genuinely about the domain but belongs to no single entity — because it spans two aggregates, because it is a policy about value types, or because the calculation is a concept in its own right. That is a domain service: a module of stateless functions in the domain package.
A service is not a consolation prize. The common advice is to suspect any service that reads only one aggregate, on the grounds that it is really a method with its receiver passed as an argument. Sometimes it is. Often it is not, and pricing is the archetype: a fee schedule has its own rules, changes on its own schedule, and may be swapped per tier, per contract, or per date. Pricing.feeFor(order) is not Order.fee() waiting to be discovered. An order is not the home of every rule that happens to read an order, and treating it as one is how an aggregate ends up owning half the model.
The test is ownership, not argument shape. Ask whether the rule is part of what the type is, or a policy applied to it. “An order cannot be placed with no lines” is the order’s own business — it defines a valid order. “This customer tier pays 1.5%” is not; it is a policy that takes an order as input and would still make sense if the order type were replaced.
The genuine failure is at the other extreme. A domain layer that is all services and no methods is the anemic model (19.10) wearing a hat. If Order has no functions that can refuse, the rules that define an order have gone to live somewhere else.
Pass the fact, not the port
Section titled “Pass the fact, not the port”Some rules need a fact the aggregate does not own. “No two customers may share an email.” “A customer may not have more than three open orders.” The aggregate cannot check either, and the obvious move is to hand it a repository so it can go and look.
Do not. A domain function takes the answer to the question, never the means of asking it:
// Good: the fact is a parameter. public function place(self, now: Instant, creditApproved: Bool): Result<(Order, OrderPlaced), OrderError>
// Bad: the domain now performs IO, and cannot be pure. public function place(self, credit: CreditService): Async<Result<OrderPlaced, OrderError>, ...>The application service does the asking; the domain does the deciding. Three things follow:
- The domain stays pure and total — no
Asyncin the layer, which is what makes the four-project layout of 18.5 possible. - The test stays a one-liner.
order.place(noon(), false)needs no fake credit service. - The cost becomes visible. A lookup hidden inside a rule is a network call nobody budgeted for; a parameter forces the use case to fetch it deliberately, where a reader can see what it costs.
The clock is the case you will hit first. Instant.now() is IO, so place takes now: Instant. That reads like pedantry until the first time you test an expiry rule at an arbitrary date, or replay a day of events and get the right answers because time was an input rather than an ambient fact.
On uniqueness specifically: check-then-act races no matter which layer checks. Two requests can both find the email free. The guarantee is a unique constraint in the database; the domain-side check exists to produce a good error message in the common case. The domain layer is not where you solve concurrency, and pretending otherwise produces code that looks safe and is not.
19.9 Factories and Reconstitution
Section titled “19.9 Factories and Reconstitution”A factory is for construction complex enough to be its own concept, or where an invariant spans the whole assembly. In Dovetail that is a static function — Order.draft, Money.of — returning Result when it can refuse. There is no separate factory type unless the construction genuinely needs one.
Reconstitution is a different operation
Section titled “Reconstitution is a different operation”Creating an order and rebuilding one from a database row are not the same operation, though they end at the same type:
| Creation | Reconstitution | |
|---|---|---|
| Runs the creation rules | yes | no — they ran once, when it was created |
| Emits a creation event | yes | no — nothing happened |
| Assigns identity | yes | no — the identity is in the row |
| Valid starting states | Draft only |
any state the order has reached |
Order.draft cannot rebuild a placed order — it hard-codes OrderStatus.Draft, and it should.
Modelling the states as an enum makes this notably easier: the adapter reads a status column plus its associated columns and builds the one OrderStatus variant they describe. If those columns disagree — a placed row with a null timestamp — the adapter has found corrupt data and must fail, which is exactly right and exactly where you want that check. In the Option-soup model of 19.2 that row would have loaded happily and failed somewhere else, much later.
Reconstitution needs a door of its own, since Order.draft is not it. Three options:
- A reconstitution constructor,
publicand unmistakably named —Order.fromStorage(...). Simple; the cost is a public door application code could also walk through, so naming carries the weight. - A snapshot record the aggregate exports and imports. The persistence-facing shape is explicit and the adapter’s mapping is simpler; the cost is a second type to keep in step.
- Put the adapter’s mapping inside
module Order. This grants construction access, but drags persistence vocabulary into the model. Moving the mapping to another function in the domain package does not grant private construction access.
Prefer (1), and (2) once the internal and stored shapes diverge. Whichever you choose, the function that actually reconstructs an Order lives in module Order. It must check the stored shape and invariants without repeating command preconditions or emitting a new creation event. A snapshot is data, not construction permission: decode the snapshot, then pass it to that module function. A generated decoder cannot bypass private construction either.
19.10 The Anemic Domain Model
Section titled “19.10 The Anemic Domain Model”The failure mode worth naming, because it is where most codebases end up without a fight:
- Entities are records of public fields with no behavior.
- All behavior lives in services that take those records as arguments.
- The domain layer is a namespace for data structures, and the rules are in the application layer — or in three places at once.
It happens because it is what ORMs teach, what DTO-mapping habits encourage, and what CRUD screens make look reasonable. It is not stupid; it is the path of least resistance.
How to spot it in review:
- Application services full of
ifstatements about the business. - Entities with setters and no function that can refuse.
- The same validation in the HTTP handler, the service, and the database.
- A record whose fields are mostly
Option— the tell from 19.2, and usually the earliest one. Optional fields are states that were never modelled, and the rules keeping them consistent had to go somewhere else. - A model written in
StringandInt32— the tell from 19.3. Rules attach to types; a layer with no types of its own has nowhere to put them. - A rule you cannot point at.
Why it costs: the rules are still in your program, but duplicated, with no copy authoritative. Every change means finding all of them, and the compiler cannot help, because a missing rule is not a type error.
The fix is directional, not a rewrite. Each time you touch a rule, move it onto the type that owns the data it reads, and delete the other copies. Where the rule is really about which combinations are legal, fix the type instead and the rule disappears. A codebase converges surprisingly fast under that habit, and it is safe at every step — which a big-bang re-model is not.
19.11 Testing the Domain
Section titled “19.11 Testing the Domain”Domain tests are the ones to have thousands of: they test the thing that matters and cost nothing to run — no database, no runtime, no Async, no fakes.
Write them in the module, not in test/. Domain tests belong at the bottom of Order.dove, under the functions they pin. 15.2 draws the line by what a test is for: test/ is for exercising a package’s public API from the outside, src/ is for tests that are part of the code. A rule and the test that holds it in place are one thought, and they should be one edit — when a rule changes, its test is on the screen; when a rule is deleted, so is its test, rather than being found six months later in a file nobody opened.
The practical difference is the top of the file. In test/ the example below would open with package test and eleven import lines, every one of them a thing to update when the model moves. In Order.dove there are none: the aggregate, its states, and its errors are already in scope, and Money and Sku are already imported for the production code above. Private helpers are reachable too, which test/ cannot do.
// the tail of Order.dove, after module Order
private function anOrder(): Order = Order.draft(OrderId.parse("o-1").require, CustomerId.parse("c-1").require)
private function aLine(): OrderLine = OrderLine { sku = Sku.parse("ABCD1234").require quantity = Quantity.parse(2).require unitPrice = Money.of(1250i64, Currency.Usd) }
private function noon(): Instant = Instant.ofEpochSecond(1700000000i64)
test "an order with no lines cannot be placed" = assert anOrder().place(noon(), true).isError
test "a placed order carries when it was placed and what it came to" = let drafted: Order = anOrder().addLine(aLine()).require match drafted.place(noon(), true) with case Ok((placed, event)) => match placed.status with case OrderStatus.Placed { total; placedAt } => assert total.minorUnits == 2500i64 assert placedAt == noon() case _ => panic "expected a placed order" assert event.total.minorUnits == 2500i64 case Error(_) => panic "expected the order to place"
test "the draft is unchanged by placing it" = let drafted: Order = anOrder().addLine(aLine()).require drafted.place(noon(), true) assert drafted.placedAt().isNone
test "placing twice reports what the order actually was" = let drafted: Order = anOrder().addLine(aLine()).require let (placed, _e) = drafted.place(noon(), true).require match placed.place(noon(), true) with case Error(OrderError.NotDraft(OrderStatus.Placed(_))) => assert true case _ => panic "expected NotDraft carrying the real status"Things worth copying:
- A couple of small builders (
anOrder,aLine,noon) keep the tests about the rule rather than the setup. They are file-private functions, so they never leak into the package’s surface. Tests after an inline module are still file-level declarations: being inOrder.dovedoes not grant private construction access.anOrdercorrectly callsOrder.draft. - Notice what you are not testing. There is no test that a draft has no
placedAt, or that a placed order has a total — those are not true because of a check, they are true because of a type, and a test would only be re-stating the type. The better your types, the fewer tests you need, and the ones left are about behavior rather than about data hygiene. - The third test cannot be written in a mutable model.
placeis called, its result discarded, and the draft is still a draft. - Match the specific error, including its payload. The fourth test asserts not just “this refused” but that the refusal carried the real status — which is the thing 19.2 added, so it is the thing worth pinning.
- A controlled clock is free because
nowis a parameter (19.8).
Test rules, not plumbing: Order.draft needs no test, and neither does an accessor.
What does belong in test/ is the layer above: a use case exercised through its public surface, with the repository faked (20.8). That is a test of the package from outside, which is exactly what the directory is for.
19.12 A Change, End to End
Section titled “19.12 A Change, End to End”The reason for all of this is what happens when the business changes its mind. Suppose orders above 1,000.00 now need approval.
The rule concerns data the aggregate already has, so it goes in place — and the error carries the total that tripped it, so the caller need not re-derive it:
let total: Money = self.total() if total.isGreaterThan(Money.of(100000i64, total.currency)) then Error(OrderError.ApprovalRequired(total)) else ...One new error variant in types.dove, four lines in Order.dove, one new test. The application service, HTTP handler, repository, and database are untouched — and every caller that matches exhaustively on OrderError now gets a compile error telling them there is a new outcome to handle.
Now a harder change, and a more instructive one: “an approved order records who approved it.” That is not a new check, it is a new state, so it belongs in the type:
public record Approval = approvedBy: UserId approvedAt: Instant
public enum OrderStatus = Draft AwaitingApproval(Money) Approved(Approval) Placed(Placement) Cancelled(Cancellation)Adding that variant breaks every exhaustive match in the domain layer, and each break is a real question: can you add a line to an order awaiting approval? Can you cancel one? The compiler has just produced the design review, and you cannot ship until you have answered all of it. Compare that with adding an approvedBy: Option<UserId> field, which compiles immediately and answers nothing.
And when a rule genuinely needs a fact the aggregate does not have — “the approver must not be the buyer” — it becomes a parameter, and the use case must supply it:
public function approve(self, now: Instant, approver: UserId): Result<(Order, OrderApproved), OrderError>so the application layer changes too. That is the design telling you the truth: a new input is a new cost, and it shows up at the layer that pays for it.
Summary
Section titled “Summary”- Start with the types. One
types.doveyou can read in one sitting is the model; behavior goes in a file per concept. Design the shapes before the functions. - Make illegal states unrepresentable. Put data inside the state that gives it meaning, so a
Drafthas noplacedAtand aPlacedorder cannot lack a total. ParallelOptionfields next to a status enum is the anti-pattern to recognize. Optionmeans optional in every state, not filled in later. Errors carry their own evidence. Acase _ =>in the domain layer forfeits the compile errors that would have found you.- Value objects by default — a bare
StringorInt32in the model is a smell, because the rule about it usually arrives later and needs somewhere to live. Private newtype construction through its module so holding one proves it is valid, living at the innermost place that needs it. - Entities have identity; IDs and clocks are supplied from outside. Private record construction and
withupdates make the associated module own their transitions, while callers can still inspect them. - Pick one style for the whole layer. The functional style is recommended — it agrees with Dovetail’s
Result-based, IO-free layer. - Aggregates are consistency boundaries, drawn by invariants. They reference each other by ID, and none of them depends on another’s invariants.
- Domain events are facts, returned from the function that caused them, stopping at the context boundary.
- Pass the fact, not the port — including the clock.
- Every
Resultin a domain signature is a rule a type could not carry. That is often fine, and it is always worth noticing.
Part 20 puts this model to work: use cases, transactions, the repository ports that load and save aggregates, and the three kinds of trigger that drive them.
