~/blog/domain-model-ddd-series

Domain Model (DDD series part 4)

Part 4 of the DDD series: the domain model as a shared representation of business concepts, relationships and rules, not a diagram of database tables.

Krzysztof Słomka4 min readupdated

series · Domain-Driven Design (DDD) Series4 / 11

  1. 01What Domain-Driven Design Is (DDD series part 1)
  2. 02Benefits of using DDD (DDD series part 2)
  3. 03Who should consider using DDD? (DDD series part 3)
  4. 04Domain Model (DDD series part 4)
  5. 05Bounded Context (DDD series part 5)
  6. 06Value object (DDD series part 6)
  7. 07Entity (DDD series part 7)
  8. 08Services (DDD series part 8)
  9. 09Mastering Transactions: The Power of Aggregates in DDD
  10. 10Domain Events in DDD (DDD series part 10)
  11. 11Alternatives to Domain-Driven Design, and When to Use Them

next in series →

A domain model is the part of Domain-Driven Design that gets drawn wrong most often, because it looks like an entity-relationship diagram and isn't one. An ER diagram describes how data is stored. A domain model describes how the business behaves, and the two agree with each other only by coincidence.

This part covers what actually belongs in a domain model, the test I use to tell a domain model from a database diagram wearing a domain model's name, why the model is allowed to contradict the schema underneath it, and where the model's authority ends: it governs behaviour inside a bounded context, not the whole system at once.

Not a diagram of your tables#

A domain model is a representation of the concepts, relationships and rules that govern behaviour inside a bounded context. "Concepts" means the nouns the business actually uses: Policy, Claim, Shipment. "Rules" means the constraints those nouns obey: a claim cannot be approved against a lapsed policy, a shipment cannot be marked delivered before it was marked dispatched. An ER diagram captures neither of those on its own. It captures columns, foreign keys and the shape a relational database needs to store rows efficiently, which is a different job with a different customer: the database, not the business rule.

The practical test: open the model and ask whether a rule can be read off it. "A Policy can have many Claims" is a cardinality, not a rule. "A Claim can only be filed while its Policy is active" is a rule, and it belongs in the model as code, not as a comment next to a foreign key.

// BAD: the relationship is visible in the schema, the rule is visible nowhere.
// Nothing stops a Claim row being inserted against a lapsed Policy; the
// constraint lives only in whichever controller remembers to check it.
final class Claim
{
    public function __construct(
        public string $id,
        public string $policyId,
        public float $amount,
    ) {}
}
// GOOD: the rule is enforced at the one place a Claim can be created, so it
// cannot be bypassed by a second code path that forgot the check.
final class Policy
{
    public function fileClaim(float $amount): Claim
    {
        if ($this->status !== PolicyStatus::Active) {
            throw new CannotClaimAgainstLapsedPolicy($this->id);
        }
        return new Claim(Uuid::v7()->toString(), $this->id, $amount);
    }
}

Two details are easy to miss here. First, fileClaim lives on Policy, not on Claim, because Policy is the object that owns the invariant: nothing outside it is allowed to construct a Claim directly. Second, the schema underneath both versions can be identical, two tables, one foreign key. The model is a behavioural layer on top of storage, not a replacement for it, and the two are allowed to disagree about what matters.

Why the model and the schema diverge on purpose#

A relational schema is optimised for storage and query performance: normalise to avoid duplicate writes, index what gets filtered on, denormalise where a join gets expensive. A domain model is optimised for expressing rules correctly. Those goals pull in different directions often enough that forcing one shape to serve both jobs produces a model that is either a bad database or a bad rule book.

A common place this shows up: Money. The schema wants a decimal column and maybe a separate currency column for the row. The domain model wants a Money value object that refuses to be constructed with a negative amount in some contexts, that will not add two amounts in different currencies, and that the compiler enforces rather than a validator that runs sometimes. The mapping layer between the two is where that translation happens, and the Doctrine or Eloquent mapping configuration is exactly that translation, not the model itself.

Where the model's authority stops#

A domain model is scoped to one bounded context. Customer inside the billing context and Customer inside the support context are allowed to be different models with different rules, because they answer different questions: one cares about payment methods and invoice history, the other cares about open tickets and SLA tier. Trying to build one Customer model that satisfies both contexts is how systems end up with a 40-field god object that nobody individually needs all of.

That boundary, what a model is allowed to assume and where it stops being authoritative, is the bounded context, the next concept in this series and the one that makes "the model is allowed to be wrong about the rest of the system" a feature rather than a bug.

A question worth asking before you model anything#

Before drawing boxes and arrows, I ask for one rule that cannot be expressed as "this field is required," stated by a domain expert, not inferred from a ticket. If that rule exists and it surprises me, there is a domain model worth building. If the answer is a list of validations, the right diagram is an ER diagram, and that is a perfectly fine thing to draw.

Previous parts#

Who should consider using DDD? (DDD series part 3)

Benefits of using DDD (DDD series part 2)

Unlocking the Power of Domain Driven Design in Software Development (DDD series part 1)

$ related posts