# Domain-Driven Design in .NET: Getting Started

> Domain-Driven Design connects business language and boundaries to the rules in your code. Start with bounded contexts, then implement entities, value objects, and aggregate transitions in plain C#.

Published: 2026-09-22. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/domain-driven-design-dotnet-getting-started

**Domain-Driven Design (DDD)** models business concepts and rules in code using a shared language with domain experts.
Start by defining bounded contexts, then use entities, value objects, and aggregates where the rules justify them.
In .NET, plain C# types can enforce those rules without a dedicated DDD framework.

## What Is Domain-Driven Design?

Domain-Driven Design (DDD) is an approach to software development that focuses on modeling the business domain in code. Instead of thinking in terms of database tables and CRUD operations, you think in terms of business concepts, rules, and processes.

Eric Evans introduced DDD in his 2003 book. The core idea is simple: the structure and language of your code should match the business domain. When a domain expert says "a customer places an order," your code should have a `Customer` that places an `Order`.

DDD is not a framework. It's a set of patterns and practices. You adopt what makes sense for your domain and skip the rest.

## When DDD Makes Sense

DDD shines in complex business domains. If your application has intricate rules, nuanced workflows, and domain experts with deep knowledge, DDD helps you capture that complexity in code.

DDD is overkill for simple CRUD applications. If your app is mostly data in and data out with minimal business logic, you don't need aggregates and value objects. A straightforward approach with [**minimal APIs**](https://milanjovanovic.tech/blog/minimal-apis-dotnet) and EF Core is perfectly fine.

Use these questions to assess the fit:

- Is the business logic complex enough that getting it wrong has real consequences?
- Are there domain experts who can explain the rules?
- Will the domain evolve significantly over time?

If the answer to all three is yes, DDD is worth the investment.

## Strategic vs Tactical DDD

DDD has two sides: [**strategic and tactical**](https://milanjovanovic.tech/blog/strategic-vs-tactical-ddd).

**Strategic DDD** is about the big picture - understanding the domain, identifying [**bounded contexts**](https://milanjovanovic.tech/blog/bounded-context-ddd-explained), and defining how they relate. This is the higher-value activity. Getting the boundaries right matters more than getting the entity design right.

**Tactical DDD** is about the implementation - [**entities**](https://milanjovanovic.tech/blog/entity-vs-value-object-ddd), [**value objects**](https://milanjovanovic.tech/blog/value-objects-in-dotnet-ddd-fundamentals), [**aggregates**](https://milanjovanovic.tech/blog/aggregate-design-ddd), [**domain events**](https://milanjovanovic.tech/blog/how-to-use-domain-events-to-build-loosely-coupled-systems), and repositories. These are the building blocks you use within a bounded context.

Most teams jump straight to tactical patterns. That's a mistake. Start with strategic DDD. Understand the domain first, then implement it.

## Ubiquitous Language

The most important DDD concept is the [**ubiquitous language**](https://milanjovanovic.tech/blog/ubiquitous-language-ddd), a shared vocabulary between developers and domain experts.
If the business calls it a "shipment," use that term in commands, tests, and conversations.

For example, a command carrying the business intent can be a plain C# record:

```csharp
public sealed record PlaceOrder(Guid CustomerId, Guid ProductId, int Quantity);
```

The name tells you what the customer is doing.
A name such as `InsertRecord` only describes a database operation.
Use [**EventStorming**](https://milanjovanovic.tech/blog/eventstorming-guide) to discover the language with domain experts and a [**context map**](https://milanjovanovic.tech/blog/context-mapping-ddd) to record where the meanings change.

## Bounded Contexts in .NET

A [**bounded context**](https://milanjovanovic.tech/blog/bounded-context-ddd-explained) is a boundary within which a particular domain model applies. The same word can mean different things in different contexts. A "Product" in the Catalog context has a name, description, and images. A "Product" in the Inventory context has a SKU and stock count.

In .NET, bounded contexts typically map to modules or projects:

```
src/
  Catalog/
    Domain/
      Product.cs          <- has Name, Description, Price
    Infrastructure/
    Application/
  Inventory/
    Domain/
      Product.cs          <- has SKU, StockQuantity, WarehouseLocation
    Infrastructure/
    Application/
```

Each context has its own model of "Product." They don't share the same class. This is intentional - it prevents one context's requirements from polluting another's.

You might structure a bounded context as a [**modular monolith**](https://milanjovanovic.tech/blog/modular-monolith-architecture-dotnet) module or as a set of [**Clean Architecture**](https://milanjovanovic.tech/blog/clean-architecture-dotnet) layers.

## Building Blocks: A Quick Tour

![The tactical DDD building blocks: a repository loads and saves an aggregate root, which contains entities and value objects and raises domain events](https://milanjovanovic.tech/blogs/articles/domain-driven-design-dotnet-getting-started/tactical-building-blocks.png)

### Entities

An entity has an identity that persists while its attributes change.
This customer keeps the same ID when renamed:

```csharp
public sealed class Customer
{
    public Guid Id { get; }
    public string Name { get; private set; }

    public Customer(Guid id, string name)
    {
        if (id == Guid.Empty) throw new ArgumentException("Customer ID is required.");
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        Id = id;
        Name = name;
    }

    public void Rename(string name)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        Name = name;
    }
}
```

Compare entities by their IDs when you mean business identity.
C# classes use reference equality by default; DDD does not change the language's equality rules.

### Value Objects

A [**value object**](https://milanjovanovic.tech/blog/entity-vs-value-object-ddd) is defined by its attributes.
For example, a reservation period must end after it starts:

```csharp
public sealed record DateRange
{
    public DateOnly Start { get; }
    public DateOnly End { get; }

    public DateRange(DateOnly start, DateOnly end)
    {
        if (end <= start)
            throw new ArgumentException("End must be after start.");

        Start = start;
        End = end;
    }
}
```

Two instances with the same dates compare equal.
Get-only properties prevent a caller from bypassing validation with a record's with-expression.

### Aggregates

An [**aggregate**](https://milanjovanovic.tech/blog/aggregate-design-ddd) groups the state that must remain consistent in one transaction.
The [**aggregate root**](https://milanjovanovic.tech/blog/aggregate-root-ddd) controls changes to that state.

For an order, the root might enforce a maximum number of line items and prevent changes after confirmation.
Expose [**collections through read-only wrappers**](https://milanjovanovic.tech/blog/encapsulating-collections-domain-entities), and keep child mutation methods out of the public API.
Choose boundaries from the rules that must hold together, rather than from the database's foreign keys.

### Domain Events

A domain event records something that happened inside the model.
The reservation below records a confirmation event:

```csharp
public sealed record ReservationConfirmed(Guid ReservationId)
{
    public Guid EventId { get; } = Guid.NewGuid();
    public DateTime OccurredOnUtc { get; } = DateTime.UtcNow;
}
```

Recording an event in memory does not deliver it reliably.
Dispatch it through the application layer, and use an [**outbox**](https://milanjovanovic.tech/blog/outbox-pattern-dual-write-problem) when changes and outgoing messages must survive a crash together.
See [**domain events in .NET**](https://milanjovanovic.tech/blog/how-to-use-domain-events-to-build-loosely-coupled-systems) for the dispatching mechanics.

## Your First DDD Implementation

This aggregate uses the `DateRange` and `ReservationConfirmed` types above.
Its constructor establishes a valid initial state, and its methods enforce the allowed transitions:

```csharp
public enum ReservationStatus { Pending, Confirmed, Cancelled }

public sealed class Reservation
{
    private readonly List<ReservationConfirmed> _events = [];

    public Guid Id { get; } = Guid.NewGuid();
    public Guid RoomId { get; }
    public Guid GuestId { get; }
    public DateRange Period { get; }
    public ReservationStatus Status { get; private set; }
    public string? CancellationReason { get; private set; }
    public IReadOnlyList<ReservationConfirmed> Events => _events.AsReadOnly();

    public Reservation(Guid roomId, Guid guestId, DateRange period)
    {
        if (roomId == Guid.Empty || guestId == Guid.Empty)
            throw new ArgumentException("Room and guest IDs are required.");

        ArgumentNullException.ThrowIfNull(period);
        RoomId = roomId;
        GuestId = guestId;
        Period = period;
        Status = ReservationStatus.Pending;
    }

    public void Confirm()
    {
        if (Status != ReservationStatus.Pending)
            throw new InvalidOperationException("Only pending reservations can be confirmed.");

        Status = ReservationStatus.Confirmed;
        _events.Add(new ReservationConfirmed(Id));
    }

    public void Cancel(string reason)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(reason);
        if (Status == ReservationStatus.Cancelled)
            throw new InvalidOperationException("Reservation is already cancelled.");

        Status = ReservationStatus.Cancelled;
        CancellationReason = reason;
    }

    public void ClearEvents() => _events.Clear();
}
```

This is an in-memory domain example; persistence mapping and message delivery are separate concerns.
Clear recorded events only after the application has captured them for dispatch or durable storage.
Preventing two reservations for the same room and dates also needs database-enforced concurrency control, because one `Reservation` instance cannot see all the others.

For expected validation failures, the [**result pattern**](https://milanjovanovic.tech/blog/functional-error-handling-in-dotnet-with-the-result-pattern) is an alternative to exceptions.
The important rule is that failed operations leave the existing model valid.

## Common Mistakes

1. **Anemic domain model** - Entities with only getters/setters and no behavior. That's a [**transaction script**](https://milanjovanovic.tech/blog/transaction-script-vs-domain-model) in disguise.
2. **Aggregate too large** - Large collections can make loading and concurrency expensive. Check whether every child really participates in the same invariant before splitting the boundary.
3. **Skipping strategic DDD** - Jumping into coding without understanding bounded contexts leads to a tangled domain model.
4. **Sharing entities across contexts** - Each bounded context should have its own model of shared concepts.

## Implementing the Rest of the Model

Once the boundaries are clear, choose [**domain services**](https://milanjovanovic.tech/blog/domain-services-ddd) for rules that span values or entities within the domain.
The [**application-service comparison**](https://milanjovanovic.tech/blog/application-services-vs-domain-services-ddd) explains which orchestration belongs outside that model.
For simpler workflows, compare [**transaction scripts and domain models**](https://milanjovanovic.tech/blog/transaction-script-vs-domain-model) before adding more types.

Use [**business-rule validation**](https://milanjovanovic.tech/blog/domain-invariants-business-rules), [**an always-valid model**](https://milanjovanovic.tech/blog/always-valid-domain-model), and [**domain factories**](https://milanjovanovic.tech/blog/factory-pattern-domain-driven-design) to control how valid objects are created and changed.
[**Strongly typed IDs**](https://milanjovanovic.tech/blog/strongly-typed-ids-csharp) prevent identifiers for different concepts from being mixed accidentally.
The [**rich versus anemic model**](https://milanjovanovic.tech/blog/rich-vs-anemic-domain-model) comparison shows where those rules live.

For persistence, start with [**EF Core value-object mapping**](https://milanjovanovic.tech/blog/value-objects-ef-core) and the practical limits of [**persistence ignorance**](https://milanjovanovic.tech/blog/persistence-ignorance-ef-core-ddd).
Use the [**specification pattern**](https://milanjovanovic.tech/blog/specification-pattern-csharp) when named, reusable query criteria justify an abstraction.

Between contexts, an [**anti-corruption layer**](https://milanjovanovic.tech/blog/anti-corruption-layer-ddd) translates external concepts into local ones.
A [**process manager or saga**](https://milanjovanovic.tech/blog/process-manager-vs-saga-pattern) coordinates workflows across transactions.
[**Event sourcing**](https://milanjovanovic.tech/blog/event-sourcing-dotnet-beginners-guide) is a separate persistence choice with replay and versioning costs; it is not required for DDD.

## Summary

1. DDD models business complexity in code using a shared vocabulary between developers and domain experts.
2. Start with strategic DDD - bounded contexts and ubiquitous language - before jumping into tactical patterns.
3. Use entities for objects with identity, value objects for objects defined by attributes, and aggregates as transactional boundaries.
4. Keep entity invariants in domain objects and use domain services for rules that do not belong to one entity.
5. Domain events record what happened and enable decoupled reactions to state changes.
6. DDD is worth the investment for complex domains. For simple CRUD, keep it simple.

If you want to go deeper, I teach domain modeling, bounded contexts, and the full tactical toolkit in [**Pragmatic Clean Architecture**](https://milanjovanovic.tech/pragmatic-clean-architecture).

Thanks for reading.

And stay awesome!

---

## Frequently asked questions

### What is Domain-Driven Design in simple terms?

DDD is an approach where the structure and language of your code match the business domain. Instead of modeling database tables, you model business concepts, rules, and processes, using the same vocabulary the domain experts use.

### When should you not use DDD?

Skip DDD for simple CRUD applications with minimal business logic. Aggregates, value objects, and domain events add overhead that only pays off when the domain rules are complex and getting them wrong has real consequences.

### What is the difference between strategic and tactical DDD?

Strategic DDD is about understanding the domain and drawing boundaries: bounded contexts, subdomains, and context maps. Tactical DDD is the implementation toolkit inside a boundary: entities, value objects, aggregates, and domain events.

### Do you need a special framework for DDD in .NET?

No. DDD is a set of patterns, not a framework. Plain C# classes, EF Core, and a small set of base types like Entity and AggregateRoot are all you need.

### Is DDD the same as Clean Architecture?

No. Clean Architecture is a way to organize dependencies between layers, while DDD is a way to model the domain. They combine well: the domain model from DDD typically lives in the innermost layer of Clean Architecture.
