DEV Community

Cover image for Domain-Driven Design: Modeling Software Around the Business
Rhuturaj Takle
Rhuturaj Takle

Posted on

Domain-Driven Design: Modeling Software Around the Business

Domain-Driven Design: Modeling Software Around the Business

A practical guide to Domain-Driven Design (DDD) — the approach to software design that centers business rules and domain models as the primary design concern — covering ubiquitous language, bounded contexts, entities and value objects, aggregates, domain events, repositories, and how DDD's tactical and strategic patterns show up concretely in C# and connect to the microservices architecture covered elsewhere in this series.


Table of Contents

  1. Introduction
  2. The Core Problem DDD Addresses
  3. Ubiquitous Language
  4. Bounded Contexts: Strategic Design
  5. Context Mapping
  6. Entities and Value Objects
  7. Aggregates and Aggregate Roots
  8. Domain Events
  9. Repositories
  10. Domain Services and Application Services
  11. Anemic vs. Rich Domain Models
  12. DDD and Persistence: Making EF Core Cooperate
  13. DDD and Microservices
  14. When DDD Is (and Isn't) Worth the Investment
  15. Common Pitfalls
  16. Quick Reference Table
  17. Conclusion

Introduction

Domain-Driven Design is an approach to software design, introduced by Eric Evans in his 2003 book of the same name, that treats the business domain — the actual real-world problem the software exists to solve — as the primary design concern, with technical architecture serving that model rather than the reverse. DDD splits into two complementary halves: strategic design (how to divide a large, complex domain into manageable, well-bounded pieces) and tactical design (specific patterns — entities, value objects, aggregates — for modeling within one of those pieces). This guide covers both, with a specific focus on how DDD's concepts show up concretely in C# code and connect directly to the bounded-context and service-boundary discussion already introduced in this series' Microservices guide.

// Not DDD: an anemic model, all logic lives elsewhere
public class Order
{
    public int Id { get; set; }
    public decimal Total { get; set; }
    public string Status { get; set; }
}

// DDD: a rich model that enforces its own business rules
public class Order
{
    public OrderId Id { get; }
    private readonly List<OrderLine> _lines = new();
    public OrderStatus Status { get; private set; }

    public void AddLine(ProductId productId, int quantity, Money unitPrice)
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("Cannot modify an order that has already been submitted");
        _lines.Add(new OrderLine(productId, quantity, unitPrice));
    }
}
Enter fullscreen mode Exit fullscreen mode

The difference between these two Order classes is the difference between a data container and an actual domain model — DDD is fundamentally about deliberately building the second kind, and this guide covers why that distinction matters and how to build toward it.


1. The Core Problem DDD Addresses

Software that drifts away from the business it's meant to model

As a codebase grows, a common and genuinely damaging pattern emerges: the code's structure and vocabulary slowly diverge from how the actual business people who understand the domain talk about it — a "Customer" in the code has fields and behaviors that don't map cleanly onto what a customer actually is to the business, business rules get scattered across services, controllers, and validation attributes rather than living in one clear, discoverable place, and every new developer has to reverse-engineer business intent from implementation detail rather than reading it directly from a model that expresses it.

DDD's central claim

The software's core model should be a direct, deliberately maintained reflection of the business domain — not a database schema translated into classes, not a generic CRUD structure applied uniformly regardless of actual business complexity, but a model built through close, ongoing collaboration between developers and domain experts, expressed in code using the same language and concepts those domain experts actually use.

Where DDD fits relative to the rest of this series

This series' Microservices guide already introduced bounded contexts as the standard technique for finding service boundaries — that's DDD's strategic design half. This guide goes deeper into strategic design (Sections 3–4) and covers the tactical design half in full (Sections 5–10): the specific patterns for modeling within a bounded context once you've found it, which is what actually determines whether the resulting code has the clarity and business-rule enforcement DDD promises, or just a differently-drawn set of service boundaries with the same underlying anemic-model problems as before.


2. Ubiquitous Language

The foundational discipline everything else in DDD builds on

Ubiquitous language is a shared vocabulary — developed jointly by developers and domain experts — used consistently in conversation, documentation, and code, with no translation layer between how the business talks about a concept and how the code names it.

// ❌ Code vocabulary that doesn't match how the business actually talks
public class UserAccountBalanceAdjustmentProcessor { }

// ✅ Code vocabulary lifted directly from how domain experts actually describe this
public class RefundService { }
Enter fullscreen mode Exit fullscreen mode

If domain experts consistently talk about "refunding an order," but the codebase instead has a UserAccountBalanceAdjustmentProcessor, every conversation between a developer and a domain expert requires mental translation in both directions — a translation that degrades over time as the code's vocabulary and the business's vocabulary drift further apart independently.

Why this is harder, and more valuable, than it sounds

Domain expert: "When a customer cancels within the grace period, we void the order, not refund it."
Developer, before ubiquitous language discipline: "Okay, I'll add a Cancel method that sets Status = Refunded"
Developer, after ubiquitous language discipline: "So voiding is a genuinely different concept from refunding —
                                                     let me add a separate Void method, since the business
                                                     clearly treats these as distinct operations with distinct meaning"
Enter fullscreen mode Exit fullscreen mode

This example illustrates the real value: ubiquitous language discipline doesn't just rename things — it surfaces genuine business distinctions (voiding vs. refunding are different concepts with potentially different downstream consequences, like different accounting treatment) that a less careful, "these seem similar enough" translation would have silently collapsed into one, losing real business meaning in the process.

Ubiquitous language is scoped to a bounded context

Within the Sales bounded context:  "Customer" means a person actively placing orders, with a shopping cart and order history
Within the Support bounded context: "Customer" means someone with a ticket history and support tier, possibly not currently ordering anything
Enter fullscreen mode Exit fullscreen mode

Critically, ubiquitous language isn't meant to be one single, universal vocabulary across an entire organization's software — it's explicitly scoped per bounded context (Section 3), which is precisely why the same word ("Customer") can and should mean something meaningfully different in two different contexts, without that being a modeling inconsistency to "fix."


3. Bounded Contexts: Strategic Design

Building directly on the Microservices guide's introduction

As introduced in this series' Microservices guide, a bounded context is a boundary within which a specific model and its ubiquitous language apply consistently and unambiguously — this section goes deeper into how to actually identify these boundaries, since "draw boundaries around business capabilities" is easy to state and genuinely difficult to do well in practice.

Signals that you've found a genuine bounded context boundary

Signal: the SAME business term means genuinely different things to different groups of people
  "Product" to Catalog: name, description, images, marketing copy
  "Product" to Inventory: SKU, warehouse location, stock count, reorder threshold
  "Product" to Pricing: base price, discount rules, tax category

→ These are three separate bounded contexts, even though they all "concern products"
Enter fullscreen mode Exit fullscreen mode

The clearest, most reliable signal for a bounded context boundary is exactly this: when domain experts from different parts of the organization use the same word but clearly mean different things by it, attached to different data and different rules — forcing all three into one shared "Product" model (the natural instinct in a less DDD-informed design) creates a model that's simultaneously bloated (carrying every field every group needs) and unsatisfying to every single group (none of whom actually wanted the other groups' fields mixed into their view of "Product").

Bounded contexts don't have to become separate microservices

This is worth stating explicitly, since it's a common point of confusion: a bounded context is a modeling boundary, valid and valuable within a single monolithic application just as much as within a distributed microservices architecture. As covered in this series' Microservices guide's "monolith first" guidance, a well-structured modular monolith organizes its internal modules along exactly these same bounded-context lines — DDD's strategic design applies identically regardless of whether those boundaries later become separate deployable services or remain internal module boundaries within one deployable application.

Core, supporting, and generic subdomains

Core domain:        the thing that makes THIS business actually competitively distinctive
                     (for an e-commerce company: sophisticated, personalized pricing/recommendations)
Supporting domain:   necessary, but not where competitive differentiation actually lives
                     (order fulfillment logistics — important, but broadly similar across many businesses)
Generic subdomain:   a genuinely solved problem, not worth custom-building
                     (authentication — buy/use an existing identity provider, per this series' OAuth2/OIDC guide)
Enter fullscreen mode Exit fullscreen mode

Not every bounded context deserves equal design and engineering investment — DDD explicitly distinguishes the core domain (where deep, careful modeling effort pays off because it's genuinely where the business's competitive value lives) from supporting and generic subdomains (where a simpler, more off-the-shelf approach is often the right call, freeing the team's most careful design attention for the core domain that actually warrants it).


4. Context Mapping

How separately-modeled bounded contexts actually relate to each other

Once a domain is divided into multiple bounded contexts, each with its own model and ubiquitous language, context mapping describes the relationships and integration patterns between them — this connects directly to the service-to-service communication patterns covered in this series' Microservices, REST, gRPC, and Event-Driven Architecture guides, giving DDD's own vocabulary for the same underlying integration concern.

Shared Kernel

Two bounded contexts jointly own and evolve a small, shared piece of model
  — used deliberately and sparingly, since it reintroduces coupling between contexts
Enter fullscreen mode Exit fullscreen mode

A small, explicitly agreed-upon piece of model (a shared Money value object, say) that two bounded contexts both depend on and jointly maintain — genuinely useful for a small number of truly universal, stable concepts, but a shared kernel used too broadly recreates exactly the cross-context coupling bounded contexts exist to avoid.

Customer/Supplier and Conformist relationships

Customer/Supplier: the upstream context (Inventory) genuinely considers the downstream context's
                     (Order Service's) needs when evolving its own model/API

Conformist:          the downstream context simply accepts and adapts to whatever the upstream
                       context's model looks like, with no influence over it (e.g., integrating
                       with a third-party payment provider's API exactly as they define it)
Enter fullscreen mode Exit fullscreen mode

These describe the power dynamic between two related contexts — whether the downstream consumer has genuine influence over how the upstream provider's model evolves, or must simply conform to it as given, which is exactly the situation for any integration with an external, third-party system a team doesn't control.

Anti-Corruption Layer: protecting your model from someone else's

public class LegacyInventoryAdapter : IInventoryService
{
    private readonly LegacySoapClient _legacyClient;

    public async Task<StockLevel> GetStockAsync(ProductId productId)
    {
        var legacyResponse = await _legacyClient.GetInventoryDataAsync(productId.Value.ToString());
        // Translate the legacy system's awkward, poorly-modeled response into OUR clean domain model
        return new StockLevel(legacyResponse.QTY_AVAIL, legacyResponse.WAREHOUSE_CD);
    }
}
Enter fullscreen mode Exit fullscreen mode

An Anti-Corruption Layer is a deliberate translation boundary — a thin adapter layer that converts an external or legacy system's model (often poorly structured, inconsistent, or simply expressed in a different vocabulary) into your own bounded context's clean model, so the external system's messiness never leaks into and corrupts your own carefully-maintained domain model. This is a genuinely valuable, widely-applicable pattern any time a well-modeled context needs to integrate with a legacy system or third-party API that doesn't share (and has no reason to share) your own modeling discipline.


5. Entities and Value Objects

Entities: defined by identity, not by their current attribute values

public class Order
{
    public OrderId Id { get; } // identity — this is what makes two Order instances "the same order"

    // even if every other property changes over time (status, line items, total),
    // this remains THE SAME Order as long as its Id is unchanged
}
Enter fullscreen mode Exit fullscreen mode

An entity is a domain object whose identity persists across changes to its other attributes — two Order objects with the same Id represent the same real-world order even if one has more line items or a different status than the other; equality for an entity is based on identity, not on comparing every field.

Value objects: defined entirely by their attribute values, with no independent identity

public record Money(decimal Amount, string Currency)
{
    public static Money operator +(Money a, Money b)
    {
        if (a.Currency != b.Currency)
            throw new DomainException("Cannot add amounts in different currencies");
        return new Money(a.Amount + b.Amount, a.Currency);
    }
}

public record Address(string Street, string City, string PostalCode, string Country);
Enter fullscreen mode Exit fullscreen mode

A value object has no identity of its own — two Money instances representing $50.00 USD are simply, interchangeably equal, and C# record types (with their built-in value-based equality, covered in this series' C# Features guide) are a natural, idiomatic fit for implementing value objects in modern .NET, requiring essentially no boilerplate to get correct equality semantics.

Why the distinction matters: it drives real design decisions

// Money as a value object: immutable, freely shared, compared by value
var total = orderLines.Sum(line => line.LineTotal); // a NEW Money instance, not a shared mutable one

// Order as an entity: identity matters, so it's tracked, not simply compared by value
if (order1.Id == order2.Id) { /* same order */ }
Enter fullscreen mode Exit fullscreen mode

This isn't a pedantic categorization exercise — it directly determines how a type should behave: value objects should be immutable (no with mutation aside, they're replaced wholesale rather than modified in place) and can be freely shared and compared by value without any risk, since there's no identity to accidentally conflate; entities need careful identity tracking and generally shouldn't be freely duplicated or compared by their current field values, since two entities with identical current field values but different identities are still two different real-world things.

Making illegal states unrepresentable via value objects

// ❌ A primitive string, allowing any garbage value
public class Order { public string Email { get; set; } }

// ✅ A value object that enforces validity at construction, making an invalid Email impossible to construct
public record EmailAddress
{
    public string Value { get; }
    public EmailAddress(string value)
    {
        if (!IsValidEmailFormat(value))
            throw new DomainException($"'{value}' is not a valid email address");
        Value = value;
    }
}
Enter fullscreen mode Exit fullscreen mode

This is one of value objects' most practically valuable applications — wrapping a primitive (a string, a decimal) in a value object that validates its invariant at construction time means an invalid EmailAddress or negative Money amount simply cannot exist anywhere in the system once constructed, eliminating an entire category of defensive if (string.IsNullOrEmpty(email)) checks scattered redundantly throughout the codebase.


6. Aggregates and Aggregate Roots

The problem aggregates solve: consistency boundaries

An Order has many OrderLines. Some business rules span the WHOLE order
  (e.g., "an order's total must never exceed the customer's credit limit"),
  not just a single line item in isolation.
Enter fullscreen mode Exit fullscreen mode

An aggregate is a cluster of related entities and value objects treated as a single, consistent unit for the purpose of data changes — the aggregate root is the single entity through which all changes to the aggregate must flow, acting as the sole entry point and consistency guardian for everything within its boundary.

public class Order // the aggregate root
{
    public OrderId Id { get; }
    private readonly List<OrderLine> _lines = new();
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
    public Money Total => _lines.Aggregate(Money.Zero, (sum, line) => sum + line.LineTotal);

    public void AddLine(ProductId productId, int quantity, Money unitPrice)
    {
        var newLine = new OrderLine(productId, quantity, unitPrice);
        var newTotal = Total + newLine.LineTotal;

        if (newTotal.Amount > _creditLimit.Amount)
            throw new DomainException("Adding this line would exceed the customer's credit limit");

        _lines.Add(newLine);
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice that OrderLine objects are never modified or added directly from outside Order — every change flows through Order's own methods, which is exactly what lets Order enforce a rule (the credit limit check) that spans the entire aggregate, not just a single line item in isolation. This is the single most important practical rule in tactical DDD: external code never reaches inside an aggregate to modify its internals directly — it only calls methods on the aggregate root.

Aggregates as transaction boundaries

_dbContext.Orders.Update(order); // one aggregate, saved as one atomic unit
await _dbContext.SaveChangesAsync();
Enter fullscreen mode Exit fullscreen mode

A single aggregate is generally the natural unit of a single database transaction (per the ACID guarantees covered in this series' SQL Server and PostgreSQL guides) — everything within one aggregate should be consistent immediately, within one transaction; consistency across multiple aggregates (or, in a microservices architecture, across multiple services entirely) is handled through the eventual-consistency and saga patterns covered in this series' Event-Driven Architecture guide, never by trying to force a single transaction to span multiple aggregates.

Keeping aggregates small

❌ One giant "Customer" aggregate containing every order the customer has ever placed
✅ Customer as its own small aggregate; Order as a SEPARATE aggregate referencing the customer only by ID
Enter fullscreen mode Exit fullscreen mode

A large, sprawling aggregate (loading a customer's entire multi-year order history every time you just need to update their shipping address) causes real, practical problems: poor performance (loading far more data than a given operation actually needs), and unnecessary contention (two genuinely unrelated operations — updating a shipping address, placing an order — competing for a lock on the same enormous aggregate). The standard, widely-endorsed guidance is keeping aggregates small, referencing other aggregates only by their identity (an OrderId, a CustomerId) rather than holding a direct object reference or embedding the other aggregate's full data.


7. Domain Events

Direct continuity with this series' Event-Driven Architecture guide

As covered in depth in this series' Event-Driven Architecture guide, a domain event represents a meaningful fact that already happened within the business domain — DDD is where this concept originates architecturally, and it's worth revisiting here specifically through the lens of where in a rich domain model these events actually get raised.

public class Order
{
    private readonly List<IDomainEvent> _domainEvents = new();
    public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    public void Submit()
    {
        if (!_lines.Any())
            throw new DomainException("Cannot submit an order with no line items");

        Status = OrderStatus.Submitted;
        _domainEvents.Add(new OrderSubmittedEvent(Id, CustomerId, Total));
    }

    public void ClearDomainEvents() => _domainEvents.Clear();
}
Enter fullscreen mode Exit fullscreen mode

A domain event is raised from within the aggregate itself, at the exact point in the aggregate root's own method where the business-meaningful thing actually happened — this is a deliberate, important distinction from raising an event externally, after the fact, from an application service: raising it inside Submit() guarantees the event is raised if and only if the order was genuinely, validly submitted (having passed the aggregate's own business rule checks), rather than relying on external code to remember to raise it correctly and consistently every time.

Dispatching domain events after the transaction commits

public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
    var domainEvents = ChangeTracker.Entries<AggregateRoot>()
        .SelectMany(e => e.Entity.DomainEvents)
        .ToList();

    var result = await base.SaveChangesAsync(cancellationToken);

    foreach (var domainEvent in domainEvents)
        await _mediator.Publish(domainEvent, cancellationToken); // or push to the outbox, per below

    return result;
}
Enter fullscreen mode Exit fullscreen mode

This directly connects to the transactional outbox pattern covered in this series' Event-Driven Architecture guide — domain events collected from every aggregate touched during a unit of work are dispatched (ideally via the outbox pattern, so the event's durable publication is atomic with the aggregate's own database changes) only after SaveChangesAsync has genuinely persisted the aggregate's new state, ensuring an event is never published for a change that didn't actually, successfully commit.


8. Repositories

An abstraction over aggregate persistence, not a generic data-access layer

public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(OrderId id);
    Task AddAsync(Order order);
    // Deliberately NO generic Query() method exposing arbitrary LINQ over the aggregate's internals
}
Enter fullscreen mode Exit fullscreen mode

A DDD repository is specifically an abstraction for retrieving and persisting whole aggregates — not a generic, query-anything data access layer. This is a meaningful distinction from how "repository" is often used more loosely elsewhere: a DDD repository's interface is deliberately narrow, offering exactly the operations the domain actually needs (get an order by ID, add a new order), rather than exposing arbitrary querying capability that would let calling code bypass the aggregate root's own business-rule-enforcing methods.

Why repositories exist even with EF Core already providing DbSet<T>

As covered in this series' EF Core guide, DbSet<T> already provides query and persistence capability directly — a DDD repository sitting on top of it is deliberately a narrower, domain-shaped interface, hiding EF Core's full querying surface (and the temptation to write ad-hoc LINQ queries that reach into and modify an aggregate's internals directly, bypassing its business rules) behind a small, intention-revealing API that only exposes what the domain genuinely needs.

Repositories for read-only/reporting queries: often bypassed deliberately

// A dedicated, repository-bypassing read model for reporting — perfectly acceptable in DDD
public async Task<List<OrderSummaryDto>> GetOrderSummariesAsync(int customerId) =>
    await _dbContext.Orders
        .Where(o => o.CustomerId == customerId)
        .Select(o => new OrderSummaryDto(o.Id, o.Total, o.Status))
        .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

This connects directly to the CQRS discussion in this series' Event-Driven Architecture guide — repositories and the aggregate's business-rule enforcement matter specifically for the write side (commands that change state and must respect the aggregate's invariants); read-only reporting/query needs are commonly served by querying directly, bypassing the repository and the aggregate model entirely, since there's no business rule to protect when you're only reading data, not changing it.


9. Domain Services and Application Services

Domain services: business logic that doesn't naturally belong to one entity

public class PricingService // a DOMAIN service — genuine business logic, just not owned by one entity
{
    public Money CalculateDiscountedPrice(Product product, Customer customer, DateTime orderDate)
    {
        // logic spanning Product AND Customer AND the current promotion calendar —
        // doesn't naturally belong to any single one of these entities
    }
}
Enter fullscreen mode Exit fullscreen mode

Some business logic genuinely doesn't fit naturally as a method on any single entity or value object — calculating a discounted price might depend on the product, the specific customer's loyalty tier, and the current promotional calendar simultaneously. A domain service holds this kind of logic explicitly, as a distinct, still-part-of-the-domain-layer concept, rather than awkwardly forcing it onto one entity that doesn't really own the whole rule, or (worse) leaking it out into application/infrastructure code where it becomes disconnected from the rest of the domain model.

Application services: orchestrating the domain, not containing domain logic themselves

public class PlaceOrderApplicationService
{
    private readonly IOrderRepository _orders;
    private readonly ICustomerRepository _customers;

    public async Task<OrderId> ExecuteAsync(PlaceOrderCommand command)
    {
        var customer = await _customers.GetByIdAsync(command.CustomerId);
        var order = Order.Create(customer.Id, customer.CreditLimit); // domain logic lives IN Order

        foreach (var item in command.Items)
            order.AddLine(item.ProductId, item.Quantity, item.UnitPrice); // still domain logic, in Order

        await _orders.AddAsync(order);
        return order.Id;
    }
}
Enter fullscreen mode Exit fullscreen mode

An application service (often the direct implementation behind an ASP.NET Core minimal API endpoint or controller action, per this series' Minimal APIs guide) coordinates a use case — loading the right aggregates via repositories, calling their domain methods in the right sequence, and persisting the result — but deliberately contains no business logic of its own. The credit-limit check still lives in Order.AddLine, not in PlaceOrderApplicationService — the application service is a thin coordinator, not where business rules actually live; this distinction is what keeps business logic centralized in the domain model rather than gradually leaking out into every application-layer entry point that happens to touch it.


10. Anemic vs. Rich Domain Models

The anti-pattern DDD specifically pushes back against

// Anemic: a pure data bag, with all logic living elsewhere in a "service" class
public class Order
{
    public int Id { get; set; }
    public List<OrderLine> Lines { get; set; }
    public decimal Total { get; set; }
    public string Status { get; set; }
}

public class OrderService
{
    public void AddLine(Order order, int productId, int quantity, decimal price)
    {
        // ALL the business logic lives here, disconnected from the Order class itself
        if (order.Status != "Draft") throw new Exception("Cannot modify");
        order.Lines.Add(new OrderLine { ProductId = productId, Quantity = quantity });
        order.Total = order.Lines.Sum(l => l.Quantity * l.Price);
    }
}
Enter fullscreen mode Exit fullscreen mode

Martin Fowler named this the anemic domain model anti-pattern — entities reduced to plain data containers (public getters/setters, no behavior), with every piece of actual business logic living externally in "service" classes that operate on the data rather than the data genuinely encapsulating and enforcing its own rules. This is a common, natural default in a codebase built primarily around an ORM's straightforward entity-mapping conventions (per this series' EF Core guide), but it's specifically what DDD's tactical patterns exist to avoid.

Why anemic models are a genuine, not just aesthetic, problem

With an anemic model: NOTHING stops a developer from writing
  order.Status = "Submitted"; order.Lines.Add(new OrderLine { Quantity = -5 });
  directly, bypassing every business rule the "service" class was supposed to enforce
Enter fullscreen mode Exit fullscreen mode

The core, practical problem: with public setters and public collections, business rules living only in a separate service class are advisory, not enforced — any code anywhere in the codebase can mutate the entity directly, silently bypassing every rule, since nothing about the entity's own structure prevents it. A rich domain model (Section 6's Order example, with a private _lines list and validating methods) makes invalid states genuinely difficult or impossible to create, because the only way to change the aggregate is through its own rule-enforcing methods.

The rich model isn't about adding complexity for its own sake

// Rich, but genuinely simple where the domain IS simple
public record ProductCategory(string Name); // no complex behavior needed — a value object is enough
Enter fullscreen mode Exit fullscreen mode

It's worth being clear that "rich domain model" doesn't mean every single class needs elaborate behavior — a genuinely simple concept (a product category with just a name) is appropriately modeled simply; DDD's tactical patterns are about matching the model's richness to the domain's actual complexity, concentrating real design effort specifically on the aggregates and entities where genuine business rules and invariants exist (often, per Section 3, within the core subdomain specifically), not applying elaborate ceremony uniformly to every class regardless of whether it actually has any behavior worth encapsulating.


11. DDD and Persistence: Making EF Core Cooperate

The tension: EF Core wants public setters and parameterless constructors; DDD wants encapsulation

public class Order
{
    private Order() { } // EF Core needs a parameterless constructor for materialization

    public Order(CustomerId customerId) // the public, DOMAIN-facing constructor, enforcing invariants
    {
        Id = OrderId.New();
        CustomerId = customerId;
        Status = OrderStatus.Draft;
    }

    public OrderId Id { get; private set; } // private setter — EF Core can still set it via backing field access
    private readonly List<OrderLine> _lines = new();
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
}
Enter fullscreen mode Exit fullscreen mode

EF Core is genuinely capable of mapping to private setters, backing fields, and even a private, parameterless constructor (used only for materializing entities from the database, never called by application code directly) — this is well-supported, documented EF Core capability (covered more generally in this series' EF Core guide), and it's what makes reconciling EF Core with a properly encapsulated, DDD-style rich domain model practical rather than requiring an awkward compromise on either side.

Mapping value objects with EF Core's owned entity types

modelBuilder.Entity<Order>().OwnsOne(o => o.ShippingAddress, address =>
{
    address.Property(a => a.Street).HasColumnName("ShippingStreet");
    address.Property(a => a.City).HasColumnName("ShippingCity");
});
Enter fullscreen mode Exit fullscreen mode

EF Core's OwnsOne/OwnsMany configuration (owned entity types) map a value object's properties directly onto columns of the owning entity's table, without needing a separate table or a foreign key — a clean, well-supported way to persist value objects (Section 5) without compromising their status as genuinely identity-less, immutable domain concepts just to satisfy the ORM's mapping requirements.

Backing fields for collections

modelBuilder.Entity<Order>()
    .Metadata.FindNavigation(nameof(Order.Lines))!
    .SetPropertyAccessMode(PropertyAccessMode.Field); // tells EF Core to use the private _lines field directly
Enter fullscreen mode Exit fullscreen mode

For a collection exposed only as a read-only IReadOnlyCollection<T> (Section 6), configuring EF Core to access the underlying private backing field directly (rather than requiring a public settable collection property) preserves the aggregate's encapsulation — external code still can't call .Add() on order.Lines directly, only EF Core's materialization/persistence machinery can reach the actual mutable list, via the field, not the public property.


12. DDD and Microservices

Bounded contexts as the natural precursor to service boundaries

As established in this series' Microservices guide, bounded contexts are the standard technique for finding good microservice boundaries — this guide's deeper treatment of strategic design (Sections 3–4) is directly, practically applicable to that same boundary-finding exercise, not a separate concern.

Aggregates as a natural fit for per-service data ownership

The aggregate (Section 6) as a consistency and transaction boundary maps naturally onto the database-per-service rule covered in this series' Microservices guide — a single aggregate's invariants are enforced within one service's own database transaction, while consistency across aggregates (whether within one service or, as is more often the case in microservices, across services entirely) flows through domain events and sagas, exactly the pattern this series' Event-Driven Architecture guide covers.

Domain events as the natural bridge to integration events

As touched on in Section 7 and covered fully in this series' Event-Driven Architecture guide, a domain event raised from within an aggregate can (deliberately, not automatically) become an integration event — published externally for other services to consume — but it's worth restating the distinction that guide draws: not every internal domain event needs to leak out as a cross-service integration event; that's a deliberate design decision about what a service's public contract with the rest of the system should actually expose.

DDD doesn't require microservices, and microservices don't require DDD

It's worth being explicit, since the two are so often discussed together: a well-modeled, DDD-informed modular monolith is a completely legitimate, often preferable starting point (per this series' Microservices guide's "monolith first" guidance), and a microservices architecture can technically be built without any DDD tactical modeling at all (with correspondingly anemic models within each service) — the two ideas are complementary and mutually reinforcing when combined, but neither strictly requires the other.


13. When DDD Is (and Isn't) Worth the Investment

DDD's tactical patterns carry real cost

Rich domain models, careful aggregate boundary design, explicit value objects for every meaningful primitive, domain events raised from within aggregates — every one of these is more upfront design and implementation effort than the straightforward, ORM-convention-following anemic model most tooling naturally guides you toward by default.

Where that investment clearly pays off

  • Genuine business complexity — many interacting business rules, invariants that must hold across multiple pieces of data, a domain that domain experts themselves find genuinely complicated to explain. This is precisely DDD's "core subdomain" territory from Section 3.
  • A domain that will be actively maintained and evolved for years, where the long-term cost of business logic scattered and duplicated across services/controllers compounds significantly over that lifetime.
  • A team with real, ongoing access to domain experts, since ubiquitous language and a genuinely accurate model both depend on that ongoing collaboration — DDD without domain expert involvement tends to produce a developer's guess at the domain, not an actually accurate one.

Where DDD's tactical machinery is likely overkill

  • Simple CRUD applications with few genuine business rules beyond basic data validation — an anemic model plus straightforward validation is entirely appropriate here, and DDD's ceremony would add cost without corresponding benefit.
  • Supporting or generic subdomains (per Section 3) even within an otherwise DDD-modeled system — applying full tactical rigor uniformly, including to parts of the domain that aren't actually where the business's complexity or competitive value lives, is a common, avoidable over-investment.
  • A short-lived or genuinely throwaway system, where the long-term maintainability benefits DDD is optimized for simply won't be realized within the system's actual lifespan.

A pragmatic middle ground: DDD-informed without full ceremony

Many teams get substantial value from DDD's core ideas — ubiquitous language, bounded contexts, and at least basic invariant-enforcing entities — without adopting every tactical pattern (domain events for every state change, a strict repository-only persistence discipline) with full rigor everywhere; treating DDD as a spectrum of techniques to apply where the domain's actual complexity justifies them, rather than an all-or-nothing methodology, is a reasonable, common, and defensible position.


14. Common Pitfalls

Pitfall Why it hurts Better approach
Anemic domain models with logic scattered in service classes Business rules become advisory, not enforced; easy to bypass Encapsulate invariants within the entity/aggregate itself, via methods, not public setters
One universal model shared across genuinely different bounded contexts Bloated, unsatisfying to every group forced to share it Let the same term mean different things in different bounded contexts, deliberately
Aggregates that are too large (e.g., a Customer aggregate holding every order ever placed) Poor performance, unnecessary lock contention on unrelated operations Keep aggregates small; reference other aggregates by ID only
External code reaching directly into an aggregate's internal collections Bypasses the aggregate root's business-rule enforcement entirely Expose collections as read-only; all mutation flows through root methods
Raising domain events from application services instead of the aggregate itself Risk of an event being raised for a change that didn't actually pass business rule validation Raise domain events from within the aggregate's own methods, at the moment the fact becomes true
Applying full DDD tactical rigor uniformly across a whole system, including trivial CRUD Ceremony and cost with no corresponding complexity to justify it Reserve full tactical rigor for the core subdomain; keep supporting/generic subdomains simpler
Building a domain model in isolation from domain experts Produces a developer's guess at the domain, not an accurate model of it Treat ubiquitous language and model development as an ongoing, collaborative process
Repositories exposing generic, arbitrary querying over an aggregate's internals Undermines the whole purpose of the repository as a narrow, business-rule-protecting boundary Keep repository interfaces narrow and intention-revealing; use separate read models for reporting

Quick Reference Table

Concept Purpose
Ubiquitous language A shared vocabulary between developers and domain experts, reflected directly in code
Bounded context A boundary within which a model and its language apply consistently
Context mapping Describes how separately-modeled bounded contexts relate and integrate
Anti-Corruption Layer Translates an external/legacy model into your own, preventing its messiness from leaking in
Entity Defined by persistent identity, not current attribute values
Value object Defined entirely by its values, immutable, no independent identity
Aggregate / Aggregate root A consistency and transaction boundary; the sole entry point for changes within it
Domain event A business-meaningful fact, raised from within the aggregate at the moment it becomes true
Repository A narrow persistence abstraction scoped to whole aggregates, not generic querying
Domain service Business logic that doesn't naturally belong to a single entity
Application service Orchestrates use cases by coordinating aggregates/repositories; contains no domain logic itself
Anemic domain model The anti-pattern DDD's tactical patterns specifically push back against

Conclusion

Domain-Driven Design's central insight — that software should be a deliberate, carefully maintained reflection of the business domain it serves, built through genuine collaboration with the people who understand that domain, rather than a generic data-access structure dressed up in domain-sounding names — is what everything else in this guide serves. Strategic design (ubiquitous language, bounded contexts, context mapping) finds the right boundaries; tactical design (entities, value objects, aggregates, domain events) builds genuinely rich, self-enforcing models within those boundaries, in direct contrast to the anemic, easily-bypassed models a straightforward ORM-first approach naturally tends toward.

This guide's connections back to the Microservices, Event-Driven Architecture, and EF Core guides elsewhere in this series aren't incidental — DDD's bounded contexts are precisely the strategic-design foundation the Microservices guide already leaned on, its domain events are the concrete origin point for the integration events covered in the Event-Driven Architecture guide, and its tactical patterns are specifically what turn EF Core from "convenient but anemic-model-encouraging" into a persistence layer that can faithfully support a genuinely rich, business-rule-enforcing domain model. Applied where a domain's real complexity justifies it — and skipped where it doesn't — DDD is less a rigid methodology to adopt wholesale than a disciplined way of asking, at every design decision, whether the code actually says what the business means.


Found this useful? Feel free to star the repo, open an issue with corrections, or share the moment ubiquitous language surfaced a business distinction your code had been silently collapsing into one.

Top comments (0)