Anemic model
Summary. An anemic domain model has the classes of a domain model and none of its behaviour: objects that only hold data, with the business rules written elsewhere, in handlers and services that read and assign their fields. It looks like object-oriented design and works like procedures over records, with the cost of both. The harm is that rules get duplicated, skipped by new entry points, and lost to the reader. This page shows how to recognise it, a before and after on the appointment example, what the kit's rules do and do not catch, and when a thin model is the right choice.
What it looks like
Section titled “What it looks like”// BEFORE: a data holder, and the rules in the handlerexport class Appointment { id!: string; practitionerId!: string; clientId!: string; startsAt!: Date; endsAt!: Date; status!: 'booked' | 'cancelled'; cancelledAt?: Date; cancellationReason?: string;}
export class CancelAppointmentHandler { async execute(command: CancelAppointment): Promise<void> { const appointment = await this.appointments.findById(command.appointmentId); if (!appointment) { throw new Error('Appointment not found'); }
if (appointment.status === 'cancelled') { throw new Error('Already cancelled'); } if (appointment.startsAt.getTime() - Date.now() < 24 * 60 * 60 * 1000) { throw new Error('Too late to cancel'); }
appointment.status = 'cancelled'; appointment.cancelledAt = new Date(); appointment.cancellationReason = command.reason;
await this.appointments.save(appointment); await this.events.publish({ type: 'appointment-cancelled', id: appointment.id }); }}Everything a domain expert would call a rule is in the handler. The consequences:
- The rescheduling handler needs the "not cancelled" check too, and will have its own copy, or forget it.
- A reminder job, an import script or a test can set
status = 'cancelled'without any check and withoutcancelledAt: the type allows a cancelled appointment with no cancellation date. - The 24-hour rule reads the system clock, so its test needs to fake time globally.
- The errors are generic: a caller cannot tell "already cancelled" from "too late" except by message text.
- Nothing in the code says "cancel". The business verb exists only as a handler's file name.
The same use case with the behaviour in the model
Section titled “The same use case with the behaviour in the model”// AFTER: the aggregate decides, the handler orchestratesimport { AggregateRoot, AuthorizedCommand, ConceptError, Repository, type Clock, type CommandHandler, type DomainEvent,} from '@ddd-ts-kit/kernel';
// Written in their own files of the context (the whole aggregate is on the Aggregates page):declare class TimeSlot { readonly startsAt: Date; startsWithin(durationMs: number, at: Date): boolean;}declare class CancellationReason { static of(text: string): CancellationReason;}declare class AppointmentCancelled implements DomainEvent { constructor(aggregateId: string, occurredAt: Date, reason: CancellationReason); readonly name: string; readonly occurredAt: Date; readonly aggregateId: string;}declare class AppointmentError extends ConceptError { static notFound(appointmentId: string): AppointmentError; static alreadyCancelled(appointmentId: string): AppointmentError; static cancellationWindowPassed(appointmentId: string, startsAt: Date): AppointmentError;}declare class CancelAppointment extends AuthorizedCommand { readonly appointmentId: string; readonly reason: string;}
const FREE_CANCELLATION_MS = 24 * 60 * 60 * 1000;
interface AppointmentState { readonly slot: TimeSlot; readonly status: 'booked' | 'cancelled';}
export class Appointment extends AggregateRoot<string> { private constructor( id: string, private state: AppointmentState, ) { super(id); }
static rehydrate(id: string, state: AppointmentState): Appointment { return new Appointment(id, state); }
cancel(reason: CancellationReason, at: Date): void { this.assertNotCancelled(); this.assertCancellationStillFree(at); this.state = { ...this.state, status: 'cancelled' }; this.record(new AppointmentCancelled(this.id, at, reason)); }
private assertNotCancelled(): void { if (this.state.status === 'cancelled') { throw AppointmentError.alreadyCancelled(this.id); } }
private assertCancellationStillFree(at: Date): void { if (this.state.slot.startsWithin(FREE_CANCELLATION_MS, at)) { throw AppointmentError.cancellationWindowPassed(this.id, this.state.slot.startsAt); } }}
export abstract class AppointmentRepository extends Repository<Appointment> {}
export class CancelAppointmentHandler implements CommandHandler<CancelAppointment> { constructor( private readonly appointments: AppointmentRepository, private readonly clock: Clock, ) {}
async execute(command: CancelAppointment): Promise<void> { const appointment = await this.appointments.findById(command.appointmentId); if (appointment === undefined) { throw AppointmentError.notFound(command.appointmentId); }
appointment.cancel(CancellationReason.of(command.reason), this.clock.now());
await this.appointments.save(appointment); }}The handler loads, calls one intention, saves. It has no if on business state. The rules are
in one place, apply to every caller, are named, raise specific errors with stable codes, and
are tested without a database, a clock or a bus:
given(anAppointment().cancelled()) .when((appointment) => appointment.reschedule(anotherSlot, at)) .thenError('APPOINTMENT_ALREADY_CANCELLED');Side by side, the two designs of the same rules:
How to recognise it
Section titled “How to recognise it”A design review of a context looks for the smells below. The first two are the anemic model itself; the others usually travel with it.
| Smell | How to spot it |
|---|---|
| anemic aggregate | public setters, or methods that only assign; rules in handlers |
| fat handler | branches on domain state, loops over entities, computes amounts |
| primitive obsession | string or number for emails, money, periods, statuses, identifiers of other aggregates |
| invalid states representable | optional fields that are mandatory together, a status plus nullable dates |
| generic errors | throw new Error(...) for a business failure, or one error class for many cases |
| cross-aggregate transaction | a handler saving two aggregates |
| leaking persistence | ORM entities or rows returned through a repository interface |
| DTO reuse | a presentation schema imported by application or domain, or a response returning a domain object |
Quick questions for a review:
- Read the aggregate's public methods aloud. Are they business verbs (
cancel,reschedule), orsetStatus,update,changeX? A method that only assigns its argument is a setter with a longer name. - Open a handler. Does it contain
iforswitchon the aggregate's state? Each one is a rule living in the wrong layer. - Search for the same condition in two handlers.
- Count domain specs against handler specs. A context whose rules are all tested through handlers with fakes, and whose aggregates have almost no spec, keeps its rules in handlers.
- Could a test build the aggregate in a state the business calls impossible?
What the kit's rules catch, and what they do not
Section titled “What the kit's rules catch, and what they do not”| Rule | Catches |
|---|---|
domain/aggregate-encapsulation |
a public constructor, a public mutable property, a setter on an aggregate or entity |
domain/value-object-immutability |
a value object that can change |
domain/value-object-class |
a value of the model written as a function that builds a plain object, money(amount, currency) => ({ amount, currency }), or a value object class with a public constructor or no factory (under "stack.values": "classes") |
domain/no-generic-error |
throw new Error(...) in domain/ or application/ |
language/no-technical-name, language/glossary-term |
AppointmentManager, names outside the glossary |
purity/no-system-clock |
Date.now() or new Date() in a handler or an aggregate |
clean/no-flag-argument |
a boolean parameter standing for two intentions |
application/handler-shape |
a handler class with more than execute |
test/spec-required, test/builder-required |
an aggregate without a spec or a builder |
The rule table is in ARCHITECTURE.md, section 3. Together they make the "before" code above fail in several places. They do not make a model rich:
- A method
changeStatus(status)passes every rule and is still a setter. - A handler full of
if (appointment.isCancelled)passes every rule: the checker does not count business branches in handlers. - A spec file that exists and holds only
it.todopassestest/spec-required, which checks the file and not its content (CLI README, Limits).
The rules remove the mechanical forms of anemia. The judgement, "is this decision in the right
place?", belongs to a reviewer: the architecture-reviewer agent of the plugin
is written for it, and so is a human review focused on the domain layer
(Team workflow).
From anemic to rich, one move at a time
Section titled “From anemic to rich, one move at a time”-
Pick one rule in one handler. Write the domain spec first: given a state, when the intention, then the event or the error.
-
Add the intention method on the aggregate and move the condition into it. Name the refusal with
generate error <context> <Concept> <refusal>, which adds a factory to the error class of the concept:Terminal window npx ddd-ts-kit generate error appointments Appointment alreadyCancelled --code APPOINTMENT_ALREADY_CANCELLEDIt adds
AppointmentError.alreadyCancelled()todomain/errors/appointment.error.ts, and its case to the spec. Without--code, the code is the name of the refusal alone,ALREADY_CANCELLED. -
Replace the handler's branches by the single call. Its spec gets shorter: happy path, each error code, nothing saved or published on failure.
-
Remove the setter or public property the handler was using. The compiler lists the other callers; each is a rule that was also living outside.
-
Replace the primitives the rule manipulated by a value object (
TimeSlotinstead of two dates) when the same comparison appears twice.
Do it per use case, in small commits, not as a rewrite. One move, on the 24-hour rule:
// Instead of: the rule in the handler, on the system clock, with a generic errorif (appointment.startsAt.getTime() - Date.now() < 24 * 60 * 60 * 1000) { throw new Error('Too late to cancel');}appointment.status = 'cancelled';
// Write: one intention; the aggregate checks the rule on the instant it is givenappointment.cancel(reason, this.clock.now()); // throws AppointmentError.cancellationWindowPassedWhen a thin model is fine
Section titled “When a thin model is fine”Fowler calls the anemic model an anti-pattern for a domain model. He does not say that every part of every system needs a domain model, and neither does this kit.
- Supporting and generic contexts often have little to decide. A
calendarscontext whereSetWorkingHoursreplaces a list of valid intervals is mostly validated data. Its aggregate will have a few methods that check a value object and assign. That is a simple model that matches a simple subdomain, not a defect (Domain and subdomains). - Read sides are thin by design: a query handler returns the shape a port declares, with no aggregate at all (CQRS).
- Pure CRUD over reference data can stay CRUD. Khononov describes simpler patterns (transaction script, active record) as the right fit for subdomains whose logic is simple.
The test is not "how many methods does the aggregate have" but "are there business rules, and if so, where are they?". A thin aggregate in a context with no rules is honest. A thin aggregate next to a 200-line handler is the anti-pattern.
What stays required in every context of this kit, thin or not: the canonical tree, the encapsulation rules, named errors, tests. A thin model costs little under those rules because the generator writes the structure.
Limits and when not to use it
Section titled “Limits and when not to use it”- A rich model is not a goal in itself. Moving trivial assignments behind ceremonial methods adds reading without adding protection. Enrich where a rule exists.
- Do not push orchestration into aggregates in the name of richness. Loading other aggregates, calling ports and publishing are the handler's job; an aggregate that receives a repository has swapped one problem for another.
- Do not push access rules in either. "Only the client who booked may cancel" is a policy of the application layer (Invariants, permissions and validation).
- Some logic fits no single aggregate. That is what a domain service is for (Domain building blocks); it becomes a problem only when services hold most of the rules.
Sources
Section titled “Sources”- Martin Fowler, "AnemicDomainModel", 2003, martinfowler.com/bliki/AnemicDomainModel.html.
- Eric Evans, Domain-Driven Design, Addison-Wesley, 2003, chapter 4 "Isolating the Domain" (the smart UI and layered alternatives) and chapter 5.
- Vaughn Vernon, Implementing Domain-Driven Design, Addison-Wesley, 2013, chapter 1 (anemia and memory loss) and chapter 10.
- Vlad Khononov, Learning Domain-Driven Design, O'Reilly, 2021, chapter 5 "Implementing Simple Business Logic" and chapter 6 "Tackling Complex Business Logic".
- Robert C. Martin, Clean Code, Prentice Hall, 2008, chapter 6 "Objects and Data Structures".
- Repository:
plugin/agents/architecture-reviewer.md(what a review judges beyond the checks).