Mert Özen Further With Every Line
Software Architecture Domain-Driven Design — #14

The Evolution of DDD and Common Mistakes: From the Blue Book to Dynamic Consistency Boundaries

Mert Özen Nov 7, 2026 13 min 5 views
The Evolution of DDD and Common Mistakes: From the Blue Book to Dynamic Consistency Boundaries

DDD's most common mistake is skipping the strategic design left at the end of the Blue Book and taking only tactical words like entity, repository, and base class. A history from Evans's 2003 book to the DDD Reference, Vernon's aggregate rules, EventStorming, Team Topologies and Khononov, and the Dynamic Consistency Boundary that questions the aggregate; with an in-memory DCB example and a reading route after the series.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

Imagine opening the repository of a team that said "we use DDD (Domain-Driven Design) in our project" in a job interview. There's a project called Domain, and this is inside it:

namespace Bookstore.Domain.Common
{
    public interface IAggregateRoot;

    public abstract class Entity<TId>
    {
        public TId Id { get; set; } = default!;
        public DateTime CreatedAt { get; set; }
        public DateTime? UpdatedAt { get; set; }
    }

    public interface IRepository<T> where T : Entity<int>, IAggregateRoot
    {
        Task<T?> GetByIdAsync(int id);
        Task<List<T>> GetAllAsync();
        void Add(T entity);
        void Update(T entity);
        void Delete(T entity);
    }
}

namespace Bookstore.Domain.Entities
{
    using Bookstore.Domain.Common;

    public class Customer : Entity<int>, IAggregateRoot
    {
        public string Email { get; set; } = "";
        public List<Order> Orders { get; set; } = [];
    }

    public class Book : Entity<int>, IAggregateRoot
    {
        public string Title { get; set; } = "";
        public decimal Price { get; set; }
        public int Stock { get; set; }
    }

    public class Order : Entity<int>, IAggregateRoot
    {
        public Customer Customer { get; set; } = null!;
        public string Status { get; set; } = "New";
        public decimal Total { get; set; }
        public List<OrderItem> Items { get; set; } = [];
    }

    public class OrderItem : Entity<int>, IAggregateRoot
    {
        public Order Order { get; set; } = null!;
        public Book Book { get; set; } = null!;
        public int Quantity { get; set; }
    }
}

What of DDD is in this code, and what isn't? How many aggregates are there? What does it tell you that OrderItem has its own repository? And most importantly, can you learn Bookstore's rules by reading this code?

The Words Are There, the Model Isn't

Instead of guessing, I counted with a small reflection script:

using System.Reflection;
using Bookstore.Domain.Common;

// A simple audit: aggregate root count, navigations between aggregates, public setters
Type[] entities = [.. typeof(IAggregateRoot).Assembly.GetTypes()
    .Where(t => t is { IsClass: true, IsAbstract: false } && t.Namespace == "Bookstore.Domain.Entities")];
Type[] roots = [.. entities.Where(t => typeof(IAggregateRoot).IsAssignableFrom(t))];
Console.WriteLine($"Aggregate roots: {roots.Length} of {entities.Length} entities");

foreach (Type type in entities)
{
    foreach (PropertyInfo property in type.GetProperties())
    {
        Type target = property.PropertyType.IsGenericType
            ? property.PropertyType.GetGenericArguments()[0]
            : property.PropertyType;
        if (roots.Contains(target))
            Console.WriteLine($"Navigation to another aggregate: {type.Name}.{property.Name} -> {target.Name}");
    }
}

int setters = entities.SelectMany(t => t.GetProperties()).Count(p => p.SetMethod?.IsPublic == true);
Console.WriteLine($"Public setters: {setters}");
Console.WriteLine($"Domain methods: {entities.SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)).Count(m => !m.IsSpecialName)}");
Aggregate roots: 4 of 4 entities
Navigation to another aggregate: Customer.Orders -> Order
Navigation to another aggregate: Order.Customer -> Customer
Navigation to another aggregate: Order.Items -> OrderItem
Navigation to another aggregate: OrderItem.Order -> Order
Navigation to another aggregate: OrderItem.Book -> Book
Public setters: 24
Domain methods: 0

All four of the four entities are aggregate roots. OrderItem is an aggregate of its own but is tied to Order by a navigation, and Order to Customer. Loading an order brings the customer, and the customer brings all of their orders; there's no such thing as a consistency boundary. There are 24 public setters and not a single domain method. The code has DDD's words (Entity, IAggregateRoot, IRepository) but not its model: the rules are somewhere else, most likely in services. This is a short summary of the most common misunderstanding in more than twenty years of DDD history, and this post is about where it came from.

2003: The Blue Book and the Strategy at the End

Eric Evans's Domain-Driven Design: Tackling Complexity in the Heart of Software came out in August 2003; its imprint says 2004, so Evans himself calls it "the 2004 book", while readers call it the "Blue Book" after its cover. The book has four parts: putting the domain model to work (ubiquitous language, unity of model and code), the building blocks of a model (entity, value object, service, aggregate, factory, repository), refactoring toward deeper insight, and strategic design (bounded context, context map, distillation, core domain).

The problem is the order. Strategic design sits in the last quarter of the book, and most readers stopped at the building blocks. The result is the code above: DDD reduced to a class catalog, an "entity base class + generic repository" template. Evans said so himself in his 2009 QCon London talk on what he had learned since the book: the building blocks matter but had been overemphasized, and the real gain came from focusing on the core domain. That's why we put the strategic and tactical split at the very start, in post 01 of this series.

Evans's Own Corrections: The DDD Reference

In 2015 Evans published the book's definitions and pattern summaries for free under a Creative Commons license as the DDD Reference. It contains three patterns that aren't in the book, all marked with an asterisk: Domain Events, Partnership, and Big Ball of Mud. We used domain events in post 12 of the series, and Partnership and Big Ball of Mud in the context map in post 05. Adding Big Ball of Mud is a particularly honest admission: not every part of a large system will be well designed, and that deserves a place on the map.

The Reference's acknowledgements also read like a short history. Evans credits Greg Young and Udi Dahan with turning CQRS (Command Query Responsibility Segregation) and Event Sourcing into mainstream architecture options for DDD systems; he notes that the first DDD book after his was written by Jimmy Nilsson, and that Vaughn Vernon's book was the most ambitious one since his own. CQRS and Event Sourcing are the subject of a separate upcoming series.

Vernon and the Four Rules of Aggregates

Vaughn Vernon's Effective Aggregate Design essays, published in three parts in 2011, brought concrete rules to the most misunderstood building block of the Blue Book: model true invariants inside consistency boundaries, design small aggregates, reference other aggregates by identity, and use eventual consistency outside the boundary. The opening code does the opposite of all four. Vernon published Implementing Domain-Driven Design (the "Red Book", after its cover) in 2013 and a short introduction, Domain-Driven Design Distilled, in 2016. In post 09 of the series we drew the Order aggregate according to these four rules.

Discovering the Model Together: EventStorming and DDD Crew

The Blue Book said "work with the domain expert" but didn't show much of how. In November 2013 Alberto Brandolini introduced EventStorming in a blog post: a wide wall, orange stickies, and a workshop that starts with past-tense domain events. Its first name, "Event-based modelling", comes from Vaughn Vernon's IDDD (Implementing Domain-Driven Design) training tour, where Brandolini was a guest trainer; Brandolini's book Introducing EventStorming is published on Leanpub. Other techniques appeared in the same period: Matt Wynne's Example Mapping (2015) for discussing rules through examples, and Stefan Hofer and Henning Schwentner's Domain Storytelling (book in 2021) for deriving a model from stories told by the actors. The EventStorming wall in post 06 of the series is part of this tradition.

The next step was to turn the output of these workshops into repeatable templates. The DDD Crew community on GitHub collected the Bounded Context Canvas (from Nick Tune's work), Context Mapping pattern cards, the Aggregate Design Canvas, and the DDD Starter Modelling Process as open source. In post 04 we defined Ordering with the Bounded Context Canvas, and in post 09 the aggregate with the questions of the Aggregate Design Canvas.

Microservices, Team Topologies, and Khononov

In 2014, James Lewis and Martin Fowler's "Microservices" article pointed to DDD's bounded context for service boundaries; Sam Newman's Building Microservices (2015) turned it into a design method. DDD suddenly became the language of microservice design, but it brought a misunderstanding along: "if we do DDD, we should move to microservices." A bounded context is a model boundary; it can just as well live as a namespace or module in the same process. Bookstore today is a monolith with a single deployment unit, and every example in the series ran inside that monolith.

Matthew Skelton and Manuel Pais's Team Topologies (2019) built the relationship from the team side: among the "fracture planes" used when drawing the responsibility boundaries of stream-aligned teams, the business domain's bounded contexts come first. We discussed its connection to Conway's Law in the Introduction to Architecture series post "Conway's Law: How Team Structure Shapes Architecture"; Bookstore's four teams 12–18 months from now were drawn with this idea. Vlad Khononov's Learning Domain-Driven Design (2021), in turn, made strategic design most readable for newcomers: measuring core, supporting, and generic subdomains by competitive advantage and complexity, and choosing a different implementation strategy for each type. Post 03 of the series relies largely on that framework. Khononov's 2024 book Balancing Coupling in Software Design asks the same question again through coupling.

Questioning the Aggregate: Dynamic Consistency Boundary

The newest debate of the twenty years is an objection to the aggregate itself. In a series that began in April 2023 with the post "I am here to kill the aggregate", Sara Pellegrini argued that the definition of the aggregate is vague and that a boundary drawn wrongly is very expensive to fix later. Her proposal is the Dynamic Consistency Boundary (DCB): defining the consistency boundary not as a fixed aggregate at design time but, for each decision, as a query that selects the events that decision reads. The idea later became a specification at dcb.events.

An example from Bookstore: signed editions. Each edition has two copies, and a customer can get at most two signed copies across all editions. The rule spans two "things". If you want to model it with aggregates, you either put everything into one giant aggregate (all reservations collide on the same row) or make Edition and Customer separate aggregates and split a single reservation into two transactions and a compensation flow. With DCB, a single event is written and it carries two tags. I built the two capabilities the specification requires (reading by type and tag, conditional append) in memory, in a 35-line file:

namespace Bookstore.Dcb;

public sealed record SequencedEvent(long Position, object Event, IReadOnlySet<string> Tags);

// Query Item from the specification: one of the types AND all of the tags
public sealed record QueryItem(Type[] Types, string[] Tags)
{
    public bool Matches(SequencedEvent e) =>
        (Types.Length == 0 || Types.Contains(e.Event.GetType())) && Tags.All(e.Tags.Contains);
}

// Query: matches if any of the items match
public sealed record Query(params QueryItem[] Items)
{
    public bool Matches(SequencedEvent e) => Items.Any(item => item.Matches(e));
}

public sealed record AppendCondition(Query FailIfEventsMatch, long After);

public sealed class AppendConditionFailedException(string message) : Exception(message);

public sealed class InMemoryEventStore
{
    private readonly List<SequencedEvent> _events = [];
    private readonly Lock _gate = new();

    public (SequencedEvent[] Events, long Head) Read(Query query)
    {
        lock (_gate)
            return ([.. _events.Where(query.Matches)], _events.Count);
    }

    public void Append(object domainEvent, string[] tags, AppendCondition condition)
    {
        lock (_gate)
        {
            // If an event matching the same query was appended after the read, the decision is stale
            if (_events.Skip((int)condition.After).Any(condition.FailIfEventsMatch.Matches))
                throw new AppendConditionFailedException("A matching event was appended after the decision was read.");
            _events.Add(new SequencedEvent(_events.Count + 1, domainEvent, tags.ToHashSet()));
        }
    }
}
// Two rules, two "things": copies per edition and the signed-copy limit per customer
const int CopiesPerEdition = 2;
const int MaxSignedCopiesPerCustomer = 2;

Decision Decide(string edition, string customer)
{
    Query query = new(
        new QueryItem([typeof(SignedCopyReserved)], [$"edition:{edition}"]),
        new QueryItem([typeof(SignedCopyReserved)], [$"customer:{customer}"]));
    (SequencedEvent[] events, long head) = store.Read(query);

    int sold = events.Count(e => e.Tags.Contains($"edition:{edition}"));
    int owned = events.Count(e => e.Tags.Contains($"customer:{customer}"));
    string? rejection = sold >= CopiesPerEdition ? "sold out"
        : owned >= MaxSignedCopiesPerCustomer ? "customer limit reached"
        : null;
    return new Decision(edition, customer, rejection, new AppendCondition(query, head));
}

void Commit(Decision decision)
{
    if (decision.Rejection is not null)
    {
        Console.WriteLine($"{decision.Customer,-6} {decision.Edition,-12} rejected: {decision.Rejection}");
        return;
    }
    store.Append(new SignedCopyReserved(decision.Edition, decision.Customer),
        [$"edition:{decision.Edition}", $"customer:{decision.Customer}"], decision.Condition);
    Console.WriteLine($"{decision.Customer,-6} {decision.Edition,-12} reserved");
}
Commit(Decide("dune-signed", "ada"));
Commit(Decide("emma-signed", "ada"));
Commit(Decide("dune-signed", "ada"));

// Alan and Grace decide on the last Dune copy at the same time; Linus on another edition
Decision alan = Decide("dune-signed", "alan");
Decision grace = Decide("dune-signed", "grace");
Decision linus = Decide("emma-signed", "linus");
Commit(alan);
try
{
    Commit(grace);
}
catch (AppendConditionFailedException ex)
{
    Console.WriteLine($"grace  dune-signed  conflict: {ex.Message}");
    Commit(Decide("dune-signed", "grace"));
}
Commit(linus);
ada    dune-signed  reserved
ada    emma-signed  reserved
ada    dune-signed  rejected: customer limit reached
alan   dune-signed  reserved
grace  dune-signed  conflict: A matching event was appended after the decision was read.
grace  dune-signed  rejected: sold out
linus  emma-signed  reserved

Ada got one copy from each of two editions; her third attempt hit the customer limit. Alan and Grace decided on the last Dune copy at the same time; since Alan's event matched Grace's query, Grace's append condition failed, and when she read again she saw the copies were gone. Linus had decided at the same moments too, but his query covered the Emma edition and himself; Alan's event didn't touch it, so there was no conflict. The consistency boundary is as big as the decision's own query.

The price is clear too. DCB needs an event store that supports tags and conditional appends; it doesn't fit directly into the model of this series, which we built on relational tables with Entity Framework Core (EF Core). There are .NET libraries implementing the specification, but they are young; on the Java side, Axon Framework 5 brought support. Every decision requires designing its own query, and the question "does this query cover the right events?" spreads across every command the question the aggregate boundary used to answer. Event stores and event-sourced models are the subject of the upcoming CQRS and Event Sourcing series; DCB will need a closer look there. The lesson here isn't that the aggregate is dead, but that it is a tool, not a goal: the goal is protecting invariants.

Common Pitfalls: Common DDD Mistakes

Tactical DDD only

The story of the opening code. Without strategic design, tactical patterns are ceremonies added to CRUD (Create, Read, Update, Delete) code. In a project whose bounded contexts, ubiquitous language, and core domain aren't clear, an IAggregateRoot interface says nothing.

A bounded context per entity, or an aggregate per entity

A bounded context is the boundary within which a model and its language apply; it's not a table, an entity, or a controller. Likewise, making every entity an aggregate root empties the aggregate concept completely. The measure is the invariant: if the answer to "must these two things always be consistent together?" is no, they are separate aggregates; if yes, the same one.

Skipping the ubiquitous language

If the code says OrderDetail, the meeting says "order line", and the support team says "product", the model is already split in three. That's why the first commit in post 13 only changed names. For Turkish teams, where the code is in English and the domain language is Turkish, the glossary's translation table is the language itself (post 02).

Thinking DDD requires microservices

Equating a bounded context with a microservice ties two separate decisions together: the boundary of the model and the boundary of deployment. The first is always needed; the second when scale and team structure require it. The upcoming Modular Monolith series will cover keeping bounded contexts as separate modules in the same process, and the Microservices series when turning them into separate deployments is worth it.

The generic repository and base class cult

A generic repository opened for every entity, with Update and Delete in IRepository<T>, punches through the aggregate boundary: deleting an OrderItem independently of its Order becomes possible. A repository is defined per aggregate and in the aggregate's language (post 10). An Entity<TId> base class isn't a gain on its own either; as audit fields, soft delete, and tenant id are added to it, it turns into an infrastructure class that binds the whole domain.

DDD for every project

Building a rich model and events in a supporting or generic subdomain with few rules and low volatility is a cost. The subtitle of Evans's book is a warning: the complexity is in the heart of the software, that is, in the core domain. Invest there and keep the rest simple.

After the Series: Where to Go Next

In fourteen posts we saw first the problem (ubiquitous language, subdomains, bounded contexts, context maps, EventStorming), then the building blocks of the solution (entity, value object, aggregate, persistence, services, domain events), and finally an end-to-end transformation. Four paths branch out from here. The upcoming CQRS and Event Sourcing series will cover separating read and write models, building aggregates from events, and DCB; the Messaging and Event-Driven Architecture series integration events, the outbox, and sagas; the Modular Monolith series turning bounded contexts into separate modules in the same process; and the Microservices series when and how a separate service is worth it. My suggested reading order for books: first Khononov's Learning Domain-Driven Design, then Evans's Blue Book (starting with the strategic design part), then Vernon's Effective Aggregate Design essays and the free DDD Reference.

A Small Experiment

★☆☆ Run the audit script from the opening on your own project's domain assembly. How many aggregate roots, navigations between aggregates, and domain methods do you get?

★★☆ Add a rule to the DCB example: "In the first 24 hours of an edition, only loyalty program members can get a signed copy." Which events and which tags do you need? How does the decision query change?

★★★ Model the same signed-edition rule with Edition and Customer aggregates, the domain events from post 12, and eventual consistency. In the scenario where Alan and Grace ask for the last copy at the same time, which compensation flow is needed? Compare it line by line with the DCB solution.

With this post the Domain-Driven Design series ends. Bookstore's Ordering context now has a model that speaks its own language, protects its invariants, and states what happened as events. The next step is carrying those events reliably between contexts; that journey starts in the upcoming Messaging and Event-Driven Architecture series.