NDepend Blog

Improve your .NET code quality with NDepend

domain_driven_design_demystified

Domain-Driven Design Demystified

April 4, 2026 9 minutes read

Domain-Driven Design, usually shortened to DDD, is a software design approach that puts the business problem at the center of the code. Instead of starting from the database schema or the framework of the month, you start from the language the business actually uses, model that language explicitly, and let the code mirror it. The payoff is software that stays workable as the business grows, instead of slowly turning into a tangle nobody dares touch.

Eric Evans introduced the practice in his 2003 book Domain-Driven Design: Tackling Complexity in the Heart of Software. His core message was simple, even if applying it is not: use shared models, speak the language of the business, and follow a small set of technical patterns so the model survives contact with real code. Done well, DDD lets you keep adding features to a complex system without painting yourself into a corner.

In this post I will demystify DDD without the academic fog. I will share how I came to take it seriously, walk through the strategic side (ubiquitous language, bounded contexts, context maps), unpack the tactical building blocks (entities, value objects, aggregates, repositories, factories, services, domain events), and finish on how DDD connects to code-level metrics like cyclomatic complexity so you can keep contexts healthy over time.

Index

TL;DR: What Domain-Driven Design Actually Is

If you only read one paragraph: DDD is the discipline of building software whose structure matches the business it serves. You do it on two levels. Strategically, you split the system into bounded contexts, each with its own ubiquitous language agreed with domain experts. Tactically, inside each context, you express the model with a small vocabulary of building blocks (entities, value objects, aggregates, domain services, repositories, factories, domain events). The goal is not orthodoxy. The goal is to keep complex software changeable.

How I Came to Take DDD Seriously

Let me be honest. When I first started hearing the term “DDD,” I would smile and nod politely. It was very mysterious to me, and I didn’t give it much thought until I got a polite but pointed comment on a TDD post I wrote. The commenter insisted that domain-driven design was the way to go.

In that TDD post I argued that test-driven development should really be “test-driven design.” Sure, there’s value in emergent design. But there’s value in other methodologies too. So, being an open-minded, curious person, I had to know more about domain-driven design. Soon after, I read Evans’ book and immediately saw how it tackled design problems I had been working around for years without naming them.

The honest summary of my conversion: emergent design works fine until the domain itself is complicated. Once you have to model insurance policies, billing, regulatory rules, or any non-trivial business, “just refactor when it hurts” stops scaling. DDD gave me a vocabulary for the things I had been doing badly and ad hoc.

The Design Problem DDD Is Actually Solving

In software there are many design problems and many tools to solve them. Patterns help. So do models. Layered architectures, hexagonal architectures, and clean architectures each cover their corner. DDD uses these tools and adds concepts of its own for one specific job: dealing with complexity in long-lived business software.

But not so fast. What are the problems with complexity?

The Trouble With Complexity

We know intuitively what complexity is. It can be relative to each person and environment. A problem may be simple to one person but complex to another.

To make it concrete, picture something with more than three interconnected components and at least five business rules that require formal definition beyond “required field.” Maybe the problem involves computations, data transfer, multiple screens, and an external data source. And speaking of externalities, software is often part of a complex web of interconnected systems. How’s that for complexity? Right, it depends. To one developer this system is complex. Another eats systems like this for lunch.

Domain-driven design is fundamentally about how to deal with complexity in software development. That is its value proposition. Emergent design is useful when the problem is simple. When complexity is real, some forethought is needed. Here is the uncomfortable truth: when there is complexity in the problem, it will exist in the solution no matter what. You can move it around, push it into a config file, hide it behind a clever abstraction, but you cannot eliminate it. The real trouble starts when you keep adding more complexity on top.

What you can do is contain it. DDD contains complexity inside bounded contexts.

Strategic Design: The Half of DDD People Skip

Most introductions to DDD jump straight to entities and repositories. That’s the tactical half. The strategic half is what makes DDD pay off on a real codebase. It is also the half teams skip first, then regret.

Strategic design has three working parts: a ubiquitous language, bounded contexts, and a context map that shows how those contexts talk to each other. Get these right and the tactical patterns mostly fall into place. Get them wrong and no amount of repositories will save you.

Ubiquitous Language: Speak the Business, Write the Business

The ubiquitous language is the shared vocabulary used by everybody on a team, domain experts included, to describe the system. The rule is brutal in its simplicity: the words people use in meetings should be the same words you read in the code. When the underwriter says “exposure,” there should be an Exposure class. The warehouse manager says “pick”? Then there should be a Pick somewhere. And when your code says CustomerEntity while the business says “subscriber,” you have a problem that no naming convention will fix.

Being a service industry, we IT folks should take the time to learn the language of our customers. When talking to the HR department, we use “personnel.” When working with finance, we use “labor resources,” even if that makes us cringe slightly. They’re just words to us, but in context those words carry meaning. Imagine how you would feel if a non-developer used “unit testing” to mean “manual click testing.” You would not take that person seriously. That is exactly how a domain expert feels when we mangle their terminology. This is also why we need to work directly with a domain expert: someone working in or with intimate knowledge of the business area.

Bounded Contexts: Where the Language Holds

A bounded context is the explicit boundary inside which a particular model and its ubiquitous language are consistent. Step outside the boundary and the same word can mean something different.

In an e-commerce system, the word Product shows up everywhere, and means something different almost everywhere:

  • In the Catalog context, a Product is a rich object: descriptions, images, marketing copy, SEO metadata, variants.
  • In the Inventory context, a Product is an SKU with a location, a count, and a reorder threshold.
  • In the Billing context, a Product is essentially a line item with a price and a tax category.
  • In the Shipping context, it is mostly a weight, dimensions, and a hazmat flag.

A new developer’s instinct is to create one Product class shared by all four. That class becomes the godfather of the codebase: every team needs it, every team owns part of it, nobody can change it without breaking somebody. The DDD answer is the opposite. Let each context have its own Product. Connect them through translation layers, not through a shared class.

Bounded contexts do two jobs at the same time:

They contain complexity. A context wraps a coherent piece of business logic. It connects to other contexts through adapters. Those adapters protect against change. When the complexities inside a context evolve, the rest of the system does not have to follow. This is why we use adapters to “impedance-match” between contexts.

They scope terminology. The risk management department may think of workers as “employees.” HR calls them “personnel.” Finance calls them “labor resources.” Whether you love these terms or not, you have to respect that the same real-world thing has different names in different contexts, and trying to merge them into a single uber-Employee object loses information.

Context Maps: Drawing How the Contexts Talk

Once you have more than one bounded context, you need to be honest about the relationships between them. A context map is a simple diagram, often a whiteboard sketch, that shows each context and the kind of relationship it has with its neighbors. Useful relationships include:

  • Partnership: two teams plan together because their contexts depend on each other.
  • Shared Kernel: a small piece of model is intentionally shared. Use sparingly: every shared kernel is a future coordination tax.
  • Customer / Supplier: a clear upstream-downstream relationship with negotiated requirements.
  • Conformist: the downstream team accepts the upstream model as-is because pushing back is not worth it.
  • Anti-Corruption Layer: a translation layer that protects your model from a messy or legacy upstream.
  • Open Host Service: a published protocol other contexts integrate against.
  • Separate Ways: no integration at all. Sometimes the right answer.

The context map is where strategic design lives or dies. A team that cannot draw its context map probably does not have bounded contexts. It has a monolith with optimistic namespaces.

Core, Supporting, and Generic Subdomains

Not every part of your system deserves the same attention. DDD divides the problem space into three kinds of subdomain:

  • The core domain is what makes the business different from its competitors. It is where you should be investing your best engineers, your best modeling, your best test coverage.
  • Supporting subdomains are necessary but not differentiating. Build them well, but not artfully.
  • Generic subdomains are things every business needs: authentication, sending email, invoicing. Buy or adopt. Do not write your own.

Investing modeling effort in a generic subdomain is one of the most common DDD mistakes. If your team is hand-rolling a beautiful aggregate hierarchy for sending password reset emails, something has gone wrong upstream.

Modeling With the Domain Expert

Strategic design only works if you actually talk to the people who understand the business. A model is a tool for communication and planning. It can be a simple box-and-line model or a more complex format like UML. In the end it has to be understood by both the domain expert and the engineer(s). Modeling is an important step in communicating the business problem and iterating over solutions.

Note that this type of modeling isn’t data modeling. It is also not database design. Data modeling and database design are exercises in defining low-level details. Those are generally not done in conjunction with the business. It isn’t object modeling either. Object models are implementation details in the same way as database design.

The modeling in DDD is about creating a shared understanding of the business. It can be somewhat abstract, but it must be a shared vision. The goal is a joint understanding and two-way communication between business and engineering about the business problem. Only then are we truly prepared to get into the technical details.

A practical technique that has become popular for this is EventStorming: get the domain experts and engineers in a room, give them a long roll of paper and orange sticky notes, and ask them to write down every domain event (“OrderPlaced,” “PaymentCaptured,” “ShipmentDispatched”) in time order. Within an hour you can usually see the seams where bounded contexts should live, and the gaps where nobody actually knows how the business works.

Tactical Design: The Building Blocks Inside a Bounded Context

Inside each bounded context, DDD gives you a small vocabulary of patterns. They are not a checklist. They are a kit. Use what fits.

Entities

An entity is a thing in your system with an identity that persists over time, independent of its attributes. A Customer is an entity: change their address, name, even their email, and they are still the same customer. Two customers with identical attributes are still two different customers. Identity, not data, is what defines them.

Think of entities as units of behavior rather than units of data. Put logic in the entity that owns it. Most of the time there is an entity that should receive an operation you are trying to add, or a new entity begging to be extracted. Anemic code piles validation into “service” or “manager” classes that poke entities from the outside. It is usually better to put that logic inside the entity, where encapsulation can do its job.

Value Objects

A value object is the opposite of an entity: defined entirely by its attributes, with no identity of its own, and immutable. Money is the classic example. Two $20 bills are interchangeable. When you “add $20 to $20” you are not mutating a bill, you are creating a new $40 value.

Value objects are wildly underused. A surprising amount of business logic that lives in services should really live in value objects. Think of an EmailAddress that validates itself in the constructor. Or a Money that refuses to be added to a different Currency without an explicit exchange. Or a DateRange that knows whether it overlaps another. Pushing rules into immutable values shrinks the surface area of the rest of the model.

Aggregates and Aggregate Roots

An aggregate is a cluster of entities and value objects treated as a single consistency boundary. One entity in the cluster is the aggregate root: the only one the outside world is allowed to reference. Everything inside the aggregate is reached through the root.

The rule sounds bureaucratic until you have lived without it. Without aggregate roots, you end up with code like:

Now every caller depends on Policy, Period, Coverage, and Rider. Change any one of them and you have ripples. With an aggregate root the same operation becomes:

Policy figures out which period, which coverage, and whether the operation is even legal. Callers depend on one class. Invariants (“a policy cannot have more than three riders,” “a rider cannot be added to a cancelled period”) live in one place and cannot be bypassed.

A useful sizing rule: keep aggregates small. If your aggregate root has thirty children, it is not an aggregate, it is a god object with a more pretentious name.

Domain Services

Sometimes a piece of business logic does not naturally belong to any one entity or value object. A funds transfer touches two accounts. A pricing calculation depends on a catalog, a customer, and a promotion. These belong in a domain service: a stateless object whose name is a verb from the ubiquitous language. TransferFunds, QuoteOrder, UnderwritePolicy.

Be disciplined here. The “service” label is the easiest place in DDD to cheat. Every time you put logic in a service that could have lived on an entity, you are reintroducing anemic design through the back door.

Application Services vs Domain Services

Application services sit on the outside of the domain. They handle the technical orchestration: starting a transaction, calling the domain, publishing events, returning a DTO. Domain services sit inside the domain and express business rules. The application service is the use case (“RenewPolicy”). The domain service is the verb the business actually uses (“Underwrite”).

Domain Events

A domain event is something the business cares about that happened. OrderPlaced, PolicyRenewed, ShipmentDelayed. Events are immutable, named in the past tense, and carry just enough data for a consumer to react.

Events are the bridge between bounded contexts. When the Sales context places an order, it raises OrderPlaced. The Inventory context consumes that event and reserves stock. The Billing context consumes it and issues an invoice. None of them imports a class from another. This is how you keep contexts decoupled at runtime, not just in your architecture diagram.

Factories: Building Objects Without Leaking the Recipe

When we need to create some new object, we should consider the factory pattern. We don’t want every consumer to be concerned with the details of constructing an object of a specific type or sub-type. So what do we do? We delegate the responsibility to a factory. Sure, we can call the constructor directly, but it’s best not to perform too many operations in a constructor. The solution to complex object creation is the factory. By the way, a factory is also useful for containing decisions about which specific sub-type to return.

A factory can be a static method on the base class, or it can be its own object. The consumer must use the factory to build the object. We can enforce this by making the constructor for the class inaccessible to the consumer. The only way to get a new Foo is to call Foo.Build(…params). Then you would work with the Foo object to do some Foo things:

This code calls the factory method to build a Foo. Then it uses the object to report hours and set the manager. Finally it saves and gets the id generated from the save. Later we can use the id to rehydrate a Foo from the saved data.

Rehydrate Using a Repository

A repository is an abstraction of a persistence mechanism. A few common persistence mechanisms are a database, a file, and memory. The repository represents anything that can save and retrieve the data for the object. All of this happens inside the object or the factory. In this way, a domain class is dependent on the repository, not on the database.

A domain class is something that represents a real-world concept. It can also be called a domain object. These domain classes are what your application works with. So how do we rehydrate a domain object using a repository?

Luckily, all that can happen in the factory so it is all nicely contained. Here is a quick look at how you might accomplish this:

You can see that Foo.Get uses a ServiceLocator to get the repository, but that is an implementation detail. You can accomplish the same goal using dependency injection, which is the choice most teams make today.

You have to remember that domain-driven design came around more than two decades ago. Since then our tools have evolved. Even so, the important concepts are timeless. A good takeaway is containing all the code that is primarily responsible for making a Foo. These are object-oriented programming principles that get cast aside too frequently. When these patterns are missing, a codebase becomes much more difficult to maintain.

One repository per aggregate root is the convention. Resist the urge to add repositories for child entities. If you reach for one, that is usually a sign the child should be its own aggregate, or the parent’s API is too thin.

Layered, Hexagonal, Clean: Where DDD Sits in Your Architecture

DDD does not prescribe a specific architecture, but it tends to live happily inside a layered or hexagonal one:

  • Domain layer: entities, value objects, aggregates, domain services, domain events. No framework, no SQL, no HTTP.
  • Application layer: use cases that orchestrate the domain. Thin.
  • Infrastructure layer: repository implementations, message buses, external APIs.
  • Interface layer: HTTP controllers, CLI, UI.

The key rule is the dependency direction: infrastructure depends on the domain, never the other way around. Hexagonal architecture (ports and adapters) is the same idea drawn differently. So is Clean Architecture. They are all answers to the same question: how do you keep the domain model from getting contaminated by infrastructure concerns?

CQRS (Command Query Responsibility Segregation) and Event Sourcing are common companions to DDD, but they are not required. CQRS splits write models (commands that change state) from read models (queries optimized for display). Event Sourcing stores the sequence of domain events as the source of truth and rebuilds state by replaying them. Both add power and both add cost. Reach for them when the model demands it, not because a conference talk did.

DDD and Complexity Metrics: Keeping Contexts Healthy

A bounded context is not a “set it and forget it” structure. Even a well-modeled context decays under sustained change. Methods grow. Aggregates accumulate special cases. A class that started with a single responsibility ends up doing six. Strategic design draws the boundary; tactical metrics tell you whether the inside is still healthy.

What Cyclomatic Complexity Actually Measures

The most useful metric here is cyclomatic complexity, introduced by Thomas McCabe in 1976. It counts the number of independent execution paths through a method, which is roughly one plus the number of decision points (if, while, case, ternary, and so on). It is a rough estimate of how hard a method is to understand and how many tests you need to cover it.

The static thresholds are well known. A cyclomatic complexity above 15 usually means a method is hard to follow. Above 30, it is extremely complex and should be split. Plenty of teams stop there: they set a threshold, fail builds that exceed it, and call it a day.

Static Thresholds Miss the Real Risk

That stop-at-the-threshold approach misses the point. The real risk of a complex method is not its current complexity; it is what happens when complexity meets change. A method with cyclomatic complexity of 20 that hasn’t been touched in two years is fine. A method at 12 that has been edited every sprint, by a different developer each time, is a defect waiting to happen.

This is where you go beyond raw cyclomatic complexity. Two ideas help:

  • Complexity hot spots: cross-reference cyclomatic complexity with churn (how often a file is changed). The methods that are both complex and frequently modified are where bugs accumulate.
  • CRAP score (Change Risk Analyzer and Predictor): a combined metric that weights complexity by the cube of its uncovered percentage. Low coverage plus high complexity blows up fast.

Reading Complexity as a DDD Signal

Map all of this back to DDD. Complexity hot spots inside a bounded context are usually a signal that the model has drifted. An aggregate is doing too much. A domain service has become a process manager in disguise. A value object should have been split. Cyclomatic complexity is the smoke; an outgrown model is the fire.

Two practical heuristics:

  • If a single aggregate root has methods consistently above CC of 15, look for a missing concept. Often there is a value object or a child entity waiting to be born.
  • If a domain service is growing branches every release, ask whether the branches are really one process or several. Two simple use cases are almost always better than one clever one.

Complexity Across Context Boundaries

You should also look at complexity across context boundaries. A class that has high coupling to many other contexts (high afferent coupling, in NDepend terms) is a candidate for an anti-corruption layer. A context that constantly grows new types when an upstream context changes is conformist when it should not be.

In short: strategic design draws the lines, tactical patterns shape the inside, and metrics like cyclomatic complexity tell you whether the lines you drew six months ago are still where they should be.

Common DDD Anti-Patterns

A few traps that show up over and over:

  • Anemic domain model: entities are just bags of getters and setters, all behavior lives in “services.” This is OO inside-out and gives you the costs of DDD with none of the benefits.
  • One enterprise model: a single “Customer” or “Product” class shared by every context. The shared class becomes the most-edited, most-broken, most-feared file in the repository.
  • Aggregates the size of small countries: an Order that owns OrderLines, Customer, Shipments, Payments, Refunds, ReturnRequests. Every change is a transaction war.
  • Repositories that leak the database: a repository whose method signatures expose Expression<Func<T, bool>> or LINQ trees is no longer abstracting persistence, it is renaming it.
  • DDD applied to CRUD: if your domain is “save this form to the database, sometimes,” you do not need bounded contexts. You need a form and a table.
  • Worship of the cargo: building factories, repositories, and aggregates because the book said so, without a model that actually requires them. The patterns serve the model, not the other way around.

When DDD Pays Off, and When It Does Not

DDD is not a universal good. It is a tradeoff with overhead. Use it when you have:

  • A non-trivial, evolving domain (finance, healthcare, logistics, insurance, marketplaces, anything regulated).
  • Multiple teams working on parts of a system that overlap conceptually.
  • A long-lived codebase where the cost of misalignment with the business compounds.
  • Access to actual domain experts who can collaborate with engineering.

Skip it, or borrow only the parts you need, when you have:

  • A small CRUD application with a stable schema.
  • A prototype whose main risk is “will anyone use this,” not “will this scale to more rules.”
  • No access to domain experts. Without them, the “ubiquitous language” is just a developer dialect.
  • A short-lived project that will be thrown away within months.

DDD is not a silver bullet. It does not generate code, and it will not magically fix a legacy monolith. It is a discipline. Like all disciplines, it costs something up front and pays out over time. On the wrong project that payoff never comes.

In the End, Domain-Driven Design Is About Organization

All the terminology and technical practices add up to one thing: organization. Evans’ book carries the subtitle “Tackling Complexity in the Heart of Software” for good reason. Complex software requires special care to advance on the good road ahead. Without it the project can get bogged down in the mud. The concepts and practices of DDD are a well-thought-out way to manage that complexity. There are others, to be sure, but this one has enough merit that you should pay attention to it. If you haven’t read the book yet, I highly recommend grabbing a copy. And once your contexts are in place, watch the cyclomatic complexity inside them. It is the cheapest early warning you will ever get that the model is starting to drift.

For more on the technical side, you can also explore the broader family of software architecture patterns that surround DDD.

Frequently Asked Questions About Domain-Driven Design

What is Domain-Driven Design in one sentence?

Domain-Driven Design is a software design approach that aligns the structure and language of the code with the business domain it serves, splitting the system into bounded contexts and modeling each one with a small set of building blocks (entities, value objects, aggregates, domain services, repositories, factories, domain events).

Who invented Domain-Driven Design?

Eric Evans introduced DDD in his 2003 book “Domain-Driven Design: Tackling Complexity in the Heart of Software.” Vaughn Vernon later expanded it in “Implementing Domain-Driven Design” (2013), which is the most common practical reference today.

What is the difference between strategic and tactical DDD?

Strategic DDD is about how you carve up the problem: ubiquitous language, bounded contexts, context maps, core vs supporting vs generic subdomains. Tactical DDD is the building blocks inside a context: entities, value objects, aggregates, repositories, factories, services, domain events. Most teams skip the strategic half and then wonder why the tactical patterns are not paying off.

What is a bounded context, in plain English?

A bounded context is the boundary inside which a word means one specific thing. “Product” in the catalog is a rich marketing object. “Product” in shipping is a weight and a box size. A bounded context lets each part of the business use its own vocabulary, and uses translation at the boundary instead of forcing a single shared model.

What is the difference between an entity and a value object?

An entity has identity that persists over time, independent of its attributes (a Customer is still the same Customer even if they change their name). A value object has no identity and is defined by its attributes (two $20 amounts are interchangeable). Value objects are immutable; entities usually are not.

What is an aggregate root?

An aggregate is a cluster of related entities and value objects that must stay consistent with one another. The aggregate root is the one entity in the cluster the rest of the system is allowed to reference. All access to the inside of the aggregate goes through the root, which enforces the invariants.

Is DDD object-oriented only? Can I do DDD in a functional language?

DDD started in an OO context but the core ideas (ubiquitous language, bounded contexts, modeling with domain experts, immutable values) translate well to functional languages. In F#, Scala, Haskell, or Elixir, value objects fall out of the type system for free, and immutability is the default rather than a discipline. Several modern DDD books explicitly use functional examples.

How is DDD related to microservices?

A bounded context is a strong candidate for a microservice boundary, because both are about isolating a model that can evolve independently. But the two are not the same. A bounded context can live inside a monolith. Splitting along the wrong boundary is one of the fastest ways to make a microservices project fail.

How does DDD relate to Clean Architecture or Hexagonal Architecture?

Clean Architecture and Hexagonal Architecture are about dependency direction: keep the domain at the center, push frameworks and infrastructure to the edges. DDD is about what lives in that center: the model. They compose naturally. Most modern DDD codebases are organized as a hexagonal or clean architecture with a DDD domain layer.

How does cyclomatic complexity relate to DDD?

Strategic design draws the boundaries between contexts. Cyclomatic complexity and related metrics (CRAP score, churn-weighted hot spots) tell you whether the inside of a context is still healthy. A method that grows complex over many edits is a signal that the model has drifted and a concept is missing. DDD shapes the system; metrics keep it honest.

Is DDD overkill for small projects?

Usually, yes. If your project is essentially CRUD on a stable schema, the overhead of strategic design will not pay back. Borrow the parts that make sense (the ubiquitous language is almost always worth it) and skip the ceremony.

This article is brought to you by the team behind NDepend — a proven .NET static analysis tool for improving code maintainability, security, and overall quality. Whether you’re modernizing a legacy .NET application or starting fresh in C#, get started with your free full-featured trial today!

Comments:

  1. I loved that you started with the domain modeling. I found Bounded Contexts to be the most valuable contribution of DDD. I’ve been using Event Storming lately to do modeling with domain experts. I found it enables to come up with a Rough Design Up Front in just a few days of workshop. It’s a great addition to evolutionary design. I’m currently writing about this on my blog https://philippe.bourgau.net/misadventures-with-big-design-up-front/

    Thanks again for your post.

  2. Thanks! What’s really cool about Bounded Contexts is that it creates a good foundation for microservices design.

Comments are closed.