Appearance
Tier 2b · Chapter 3: Domain Modeling & DDD-lite
PHP/Laravel Track · ~30 min read + exercise
A note on sources: This chapter draws heavily on Domain-Driven Design in PHP (Buenosvinos & Soronellas) for the domain-modeling concepts — entities, aggregates, domain services — adapted to a pragmatic Laravel context. Where it discusses how these map onto Eloquent, that mapping is Laravel-specific practice, flagged where relevant.
Why this chapter exists
Domain-Driven Design is one of the most influential and most misapplied ideas in software. Applied well, it produces systems whose code reflects the business so clearly that a domain expert could almost read it. Applied dogmatically, it buries a simple CRUD app under aggregates, repositories, domain events, and ceremony it never needed. This chapter is about the pragmatic middle — "DDD-lite" — taking the genuinely valuable modeling ideas from DDD and applying them in proportion to the complexity that actually warrants them, in a Laravel app where Eloquent's Active Record pattern creates a specific tension with DDD's ideals.
The throughline: model the domain richly where the domain is complex, and don't where it isn't — DDD's value is proportional to domain complexity, and the skill is calibrating it. The two failure modes are equal and opposite. The anemic domain model — where your "domain" objects are just bags of data (Eloquent models with public properties and no behavior) and all the logic lives in services manipulating them from outside — throws away the central DDD insight that behavior should live with the data it governs. The over-modeled domain — full DDD tactical patterns on a glorified spreadsheet — pays enormous ceremony for complexity that isn't there. Good judgment is matching the modeling richness to the domain, which is the foundations' trade-off thinking applied to domain design.
What this chapter covers
- Entities and the anemic-model trap — putting behavior with data, and why Eloquent tempts you not to.
- Aggregates and consistency boundaries — grouping objects that must stay consistent together.
- Domain services and events — where logic goes when it doesn't belong to one entity.
- Calibrating DDD to complexity — the pragmatic judgment that keeps it from becoming ceremony.
Entities and the anemic-model trap
An entity, in DDD terms, is a domain object with an identity that persists over time — a Ticket, a Customer, an Order — as opposed to a value object (last chapter) which is defined purely by its values. The crucial DDD teaching about entities is that they should have behavior, not just data: the rules that govern a ticket's lifecycle (it can be assigned only if open, it can be resolved only if assigned) should live on the Ticket, as methods, not scattered across services that reach in and manipulate the ticket's raw fields from outside.
The contrast is the difference between a rich and an anemic model:
php
// Anemic — the Ticket is a data bag; rules live elsewhere, applied from outside.
$ticket->status = 'resolved';
$ticket->resolution = $resolution;
$ticket->resolved_at = now();
$ticket->save();
// Nothing stops resolving an unassigned ticket; the rule lives nowhere, or in a service.
// Rich — the Ticket enforces its own rules; the behavior lives with the data.
$ticket->resolve($resolution); // throws if the ticket isn't in an assignable state
// The lifecycle rule is on the entity, in one place, impossible to bypass.The rich version puts the rule where it belongs — on the entity that owns it — so the rule is in one findable place, can't be bypassed by code that forgets it, and reads as the domain concept it represents. This is the foundations' "push correctness into structure" at the domain level, and it's the heart of what DDD offers.
It's worth seeing this contrast in the deep-module vocabulary from the previous chapter. A rich entity is a deep module in Ousterhout's sense: it exposes a small, intention-revealing interface (resolve(), assign()) and hides what's behind it — the fields, the valid-state rules, the order operations must happen in. An anemic model is the opposite — a shallow object whose entire state is public, so every service that touches it has to know its field layout and re-implement its rules. That's textbook information leakage: the knowledge of what makes a ticket validly resolved is spread across every service instead of living in one place, so changing the rule means finding and fixing all of them. "Behavior with data" and "a deep module that hides its state" are the same instinct under two names — and an entity is the smallest module you'll ever apply it to.
Here's the Laravel-specific tension, and it's real: Eloquent is an Active Record ORM, which pulls toward anemic models. Eloquent models are data-centric by design — they map to table rows, expose columns as properties, and the framework's conventions encourage treating them as data carriers you manipulate from services. Pure DDD prefers entities that are persistence-ignorant (they don't know they're stored in a database at all), which Eloquent's Active Record fights. (Flagged: the resolution of this tension is Laravel-community practice, not from the book set, which uses Doctrine's data-mapper pattern that fits DDD more naturally.) The pragmatic DDD-lite resolutions in Laravel are a spectrum: at the light end, put behavior methods on your Eloquent models (a resolve() method on the Ticket model) so at least the rules live with the data even if the model isn't persistence-ignorant; at the heavier end, separate domain entities from Eloquent persistence models entirely (the domain Ticket is a plain object; an Eloquent model handles storage, mapped at the repository). The light end is right for most Laravel apps; the heavy end earns its ceremony only in genuinely complex domains. Either way, the goal is the same: behavior with the data, not anemic bags manipulated from afar.
Aggregates and consistency boundaries
The most architecturally important DDD concept is the aggregate, and it's the one that most directly connects domain modeling to the data and consistency lessons from the foundations. An aggregate is a cluster of related objects treated as a single unit for the purpose of data changes, with one entity designated the aggregate root — the only member outside code is allowed to reference directly. A Ticket with its TicketNotes might be an aggregate: the Ticket is the root, the notes are inside, and you only ever load, modify, and save the whole thing through the Ticket.
The reason aggregates matter is that they define consistency boundaries — and this is where domain modeling meets Foundations Chapter 3's consistency spectrum. The rule is: everything inside an aggregate is kept strongly consistent and saved together in one transaction; consistency across aggregates is eventual, coordinated by other means (domain events, sagas). This isn't arbitrary — it's how DDD answers the question "what data must change together atomically, and what can lag?", which is exactly the per-piece-of-data consistency decision from the foundations. An aggregate is a declared answer: this cluster changes together, transactionally; between clusters, we accept eventual consistency.
This connects directly to service boundaries too (Foundations Chapter 2). Aggregates are natural seams — because an aggregate is a consistency unit, it's a natural unit to own data, and aggregate boundaries often become service boundaries when a system is decomposed. The Hard Parts book makes this link explicit: getting your aggregate boundaries right in a monolith is what makes a later extraction into services tractable, because the consistency boundaries are already drawn where the service boundaries will want to be. So the practical guidance:
Keep aggregates small. A common mistake is making aggregates too large — pulling in everything related — which creates huge transactional units that are slow to load, prone to contention, and resistant to later decomposition. Prefer small aggregates (often a single entity and its truly-owned children), with references between aggregates by ID rather than by holding the whole object.
Reference other aggregates by identity, not containment. A Ticket references its Customer by CustomerId, not by embedding the whole Customer object. This keeps the aggregate boundary clean, the transactional unit small, and the two aggregates independently changeable — and it's exactly the boundary that lets ticketing and customers become separate services later if needed.
Save an aggregate as a unit, in one transaction. The root and its children commit together (strong consistency within); coordination with other aggregates happens after, eventually (via events). This is the consistency spectrum from Foundations Chapter 3, made into a structural rule of the domain model.
Domain services and events
Not all logic belongs on an entity. Some operations involve multiple aggregates, or don't naturally belong to any single one — and forcing them onto an entity distorts the model. For these, DDD offers two tools.
A domain service holds domain logic that spans entities or doesn't belong to one. If assigning a ticket to the best-fit expert involves evaluating many experts against the ticket's needs, that logic doesn't belong on the Ticket (it's about the relationship between tickets and experts) nor on any one Expert — it belongs in an ExpertAssignmentService in the domain layer. The distinction worth holding: a domain service contains domain logic (business rules about the domain), as opposed to the application services/actions from the last chapter, which orchestrate a use case (fetch this, call that, save). Domain services are part of the model; application services are the plumbing around it. (The line between them blurs in practice, and in a DDD-lite Laravel app you often won't separate them sharply — that's fine; the point is recognizing logic that spans entities and giving it a home in the domain rather than smearing it.)
Domain events are the other tool, and they connect domain modeling to the async and communication patterns from the foundations. A domain event records that something significant happened in the domain — TicketResolved, CustomerRegistered — as a first-class concept. Emitting an event when something happens lets other parts of the system react without the emitting code knowing or caring about them, which is exactly the decoupling and choreography from Foundations Chapter 5. In Laravel this maps cleanly onto the framework's event system: the Ticket aggregate's resolve() raises a TicketResolved event; listeners notify the customer, schedule the satisfaction survey, and record an analytics event — each independently, none coupled to the resolve logic. (Flagged: Laravel's event/listener system is the framework mechanism; the domain-event concept is from the DDD book.) Domain events are also how you coordinate across aggregates while respecting their separate consistency boundaries — the aggregate commits, then emits an event, and other aggregates react eventually. This is the eventual-consistency-across-aggregates rule, implemented.
Calibrating DDD to complexity
The meta-skill of this chapter, and the thing that separates DDD-done-well from DDD-as-ceremony, is calibration: applying these patterns in proportion to the domain complexity that warrants them. DDD's tactical patterns — rich entities, aggregates, domain services, events — are tools for managing complexity, and like any tool they cost something to use (indirection, ceremony, more concepts to understand). When the domain is genuinely complex, that cost buys clarity and correctness that more than pays for itself. When the domain is simple CRUD, the same patterns are pure overhead — the premature-flexibility trap from Foundations Chapter 1, in domain-modeling form.
The honest guidance, which is itself foundations-style trade-off reasoning:
For simple CRUD, skip most of it. An app that's mostly forms saving rows to tables doesn't need aggregates and domain services. Eloquent models with a little behavior, thin services, and the layering from the last chapter are plenty. Forcing DDD tactical patterns here makes the code harder to understand for no benefit — there's no complexity to manage. Recognizing "this is just CRUD" and not reaching for the heavy patterns is a sign of judgment, not laziness.
For complex domains, invest in the modeling. Where the business has intricate rules, important invariants, rich lifecycles, and language that domain experts use precisely — billing, scheduling, fulfillment, anything with real domain logic — the DDD patterns earn their cost. Rich entities keep the rules findable and enforced, aggregates keep consistency correct, events keep the system decoupled. The complexity is real, and the patterns are how you manage it rather than drown in it.
Most apps are mixed. The realistic picture is that one application has both — a complex core subdomain that deserves rich modeling and peripheral CRUD that doesn't. DDD's strategic side (which subdomains are core and worth the investment, which are supporting and can stay simple) is exactly about making this distinction, and the practical version is: spend your modeling effort where the complexity and the business value are, keep the rest simple, and don't let consistency-of-style pressure you into over-modeling the simple parts or under-modeling the complex ones.
That's DDD-lite: take the genuinely valuable ideas — behavior with data, aggregates as consistency boundaries, events for decoupling — and apply them in proportion to the domain's actual complexity, calibrating richness to need rather than applying patterns by rote. It's the foundations' core lesson once more: there's no universally right amount of modeling, only the right amount for this domain.
Practice
Budget about ninety minutes.
Exercise 1: Spot anemic vs. rich (20 min)
In a Laravel app you work on, find a piece of business logic that manipulates a model's raw fields from a service (anemic). Identify the rule being applied and where it lives. Ask: if someone wrote new code touching this model, would they know to apply this rule, or could they bypass it? That bypassability is the cost of the anemic model.
Exercise 2: Make an entity rich (35 min)
Take that logic and move it onto the entity as a behavior method ($ticket->resolve(...) that enforces the rule), so the rule lives with the data and can't be bypassed. Note what changed about where the rule lives and who can violate it. Decide how far to go — a method on the Eloquent model, or a separated domain entity — based on the app's complexity.
Exercise 3: Draw an aggregate boundary (35 min)
Pick a cluster of related objects in your domain and decide the aggregate: what's the root, what's inside (changes transactionally with the root), and what's referenced by ID (a separate aggregate, eventually consistent). Then connect it to the foundations: what must be strongly consistent within this boundary, and what across boundaries can lag? Note whether this aggregate boundary would make a sensible future service boundary.
Deliverable
A short domain-modeling note for one feature: one rule moved from anemic-service-manipulation to a rich entity behavior, one aggregate boundary drawn (root, contents, by-ID references) with its consistency rationale, and a one-sentence calibration judgment — is this part of the domain complex enough to warrant rich modeling, or is it CRUD that should stay simple?
Check yourself
- Explain the anemic-model trap and why Eloquent's Active Record pattern tempts you into it.
- Define an aggregate and aggregate root, and connect aggregate boundaries to the consistency spectrum and to service boundaries.
- Distinguish domain services from application services, and explain what domain events decouple.
- Explain how to calibrate DDD richness to domain complexity, and why over-modeling simple CRUD is the premature-flexibility trap.
What a strong answer demonstrates: you treat an aggregate as a consistency boundary — strong within, eventual across — not just a cluster of objects, and you connect that boundary to both the consistency spectrum and a plausible future service seam. The strongest answers refuse both failure modes with equal conviction: they explain why an anemic model throws away DDD's central insight and why over-modeling a spreadsheet is the premature-flexibility trap, landing on calibration — rich modeling where the domain is genuinely complex, simple CRUD where it isn't — as a judgment, not a default.
Going deeper (optional)
- Domain-Driven Design in PHP (Buenosvinos & Soronellas) — the chapters on entities, aggregates, domain services, domain events, and modules are the direct source for this entire chapter, in far more depth (including the Doctrine-based persistence-ignorant approach).
- Primary source: Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software (Addison-Wesley, 2003) — the seminal text for everything here: rich entities with behavior, aggregates and aggregate roots as consistency boundaries, domain services, and the strategic distinction (core vs. supporting subdomains) that underlies "calibrate richness to complexity."
- Primary source: Vaughn Vernon, Implementing Domain-Driven Design (Addison-Wesley, 2013) — the practical companion to Evans; its aggregate-design rules ("keep aggregates small," "reference other aggregates by identity") are the direct origin of the small-aggregate guidance in this chapter.
- Primary source: Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002) — the canonical descriptions of Active Record and Data Mapper name precisely the ORM tension this chapter navigates: Eloquent is Active Record (data-centric, pulling toward anemic models), while the persistence-ignorant DDD ideal wants a Data Mapper.
- Software Architecture: The Hard Parts (Ford, Richards, Sadalage & Dehghani, O'Reilly, 2021) — for how aggregate and data boundaries become service boundaries when decomposing a system, which the synthesis and capstone chapters return to. (Flagged: the Eloquent/Active-Record adaptation and Laravel event mapping are Laravel-specific practice, not from the DDD book, which assumes Doctrine.)
Key takeaways
- Put behavior with data: rich entities enforce their own rules in one findable place, versus the anemic model where rules scatter across services — a trap Eloquent's Active Record actively tempts you into.
- Aggregates are consistency boundaries: strong consistency and one transaction within, eventual consistency across — the foundations' consistency spectrum made a structural rule, and a natural future service boundary.
- Domain services hold logic spanning entities; domain events decouple reactions (mapping to Laravel's event system) — the foundations' choreography in the domain.
- Calibrate DDD to complexity: rich modeling where the domain is genuinely complex, simple CRUD where it isn't — over-modeling a spreadsheet is the premature-flexibility trap.
Next: Data Consistency in Laravel — Eloquent, transactions, and enforcing domain rules at the data layer, making the foundations' consistency lessons concrete in Laravel.