Domain-Driven Design Principles

Domain-driven design is a way to align the software’s model with the business’s language and boundaries. It is not “use entities and repositories” and not microservices. If the domain is simple CRUD, a boring layered app is enough. Use DDD when rules are the product (pricing, underwriting, trading, clinical workflows).

Ubiquitous language

The same words in conversation, tests, and types. If the business says “policy binds” and the code says ActivateContract(), you will translate forever and get it wrong. Rename toward the domain. When two groups use the same English word differently, you have two languages — that is a bounded context, not a thesaurus problem.

Bounded contexts

A model is valid inside a boundary. “Customer” in billing is not “Customer” in support. Draw the map: names, teams, data owners, integration (shared DB is an implicit, usually bad, mapping). Context mapping (customer/supplier, conformist, anti-corruption layer, published language) is how they talk without merging models.

Tactical building blocks (inside one context)

  • Entity — identity over time (Order 1234).
  • Value object — equality by value, immutable (Money, Email). See records.
  • Aggregate — a cluster with one root; invariants enforced on that root; persist atomically. Small aggregates. Do not make “the whole company” one aggregate.
  • Domain event — something that happened in the language of the domain, not “row updated.”
  • Repository — collection-like access to aggregates you persist. Not a wrapper that exposes IQueryable to controllers.
  • Domain service — a rule that does not naturally sit on one entity. Keep them rare.

Strategic: where the value is

Core domain — the differentiator; your best people. Supporting — necessary, not how you win. Generic — buy or clone (auth, email). Spending your core team on a custom CMS is how DDD projects die.

Example

Order.AddLine refuses a cancelled order. The API maps that exception to 409. The test says “order” and “line,” the same words the business uses.

public sealed class Order
{
    public enum Status { Placed, Cancelled }
    private readonly List<OrderLine> lines = new();
    public Status State { get; private set; } = Status.Placed;
    public IReadOnlyList<OrderLine> Lines => lines;

    public void AddLine(string sku, int qty)
    {
        if (State == Status.Cancelled)
            throw new InvalidOperationException("A cancelled order cannot take a line.");
        lines.Add(new OrderLine(sku, qty));
    }
}

public sealed record OrderLine(string Sku, int Qty);

What breaks it

Public setters, and the rule lives in the controller. A batch job that touches Lines does not know about the rule. Put the check on the aggregate.

public sealed class AnemicOrder
{
    public bool Cancelled { get; set; }
    public List<OrderLine> Lines { get; } = new();
}

static void AddFromController(AnemicOrder order, string sku, int qty)
{
    if (!order.Cancelled)
        order.Lines.Add(new OrderLine(sku, qty));
}

Pitfalls

  • Folder names “Domain/Application/Infrastructure” with an anemic UserDto and all rules in the controller.
  • One “canonical data model” for the whole enterprise.
  • Aggregates that span transactions “because the invariant is big.”

Getting there from a mess: Migrating legacy systems to DDD.