← All tutorials

DDD & CQRS Tutorial with the Order Service

Learn how Domain-Driven Design and CQRS work inside the Order microservice

DDD and CQRS lesson banner: Domain at the center, with separate command and query paths.
Writing an order and reading an order are separate paths. Business rules stay in Domain. Labels on this diagram are in Persian.

What is DDD?

Domain-Driven Design is an approach to building software where real business logic sits at the center of the design — not the database, not the framework, not the UI. The "domain" is the problem space the software exists for. In an online shop that's orders, products, customers, stock levels and rules like "an order must have at least one line" or "a cancelled order cannot be edited".

The idea was introduced by Eric Evans in the book "Domain-Driven Design" (2003). A common problem in large projects is that code slowly turns into a pile of Controllers, Services and Repositories with business logic scattered and duplicated, making it hard for the team to understand. DDD says: understand the domain first, then build the code around it.

Key DDD principles

  • Ubiquitous Language: developers and business people share the same vocabulary. If a meeting says "the order was cancelled", the code has Cancel() — not UpdateStatus(3).
  • Layered Architecture: Domain (pure rules), Application (scenario coordination), Infrastructure (database and external APIs) and Api (HTTP entry) are kept separate.
  • Entity and Value Object: an Entity is identified by Id (e.g. an order line). A Value Object is identified by its value (e.g. money or an address).
  • Aggregate and Aggregate Root: a group of related objects that form one unit. Changes go only through the "root" — like an order and its line items.
  • Domain Event: when something important happens in the business (e.g. "order placed") we record it as an event so other parts can react without tight coupling.
Simple example: without DDD you might write in a controller: "if quantity is zero throw, compute the total, save to the database". With DDD those rules live inside OrderAggregate and the controller just says "create an order" — the code reads closer to the business language and is easier to test.

In this tutorial you'll see these concepts step by step through a real Order service in .NET 8: four layers, aggregates, CQRS, MediatR and communication with User and Product microservices. If you're new, start with "What does the Order service do?"

1. What does the Order service do?

The Order service is responsible for managing orders. Its main jobs are:

  • Create a new order (with multiple line items)
  • View a single order or all orders of a user
  • Cancel an order

But here's the key point: Order doesn't work alone. Before creating an order it must ask:

  • Does this user exist and is active? (asks the User service)
  • Is this product available with enough stock? (asks the Product service)
In a microservices architecture, each service owns its own database, and to fetch data from another service it talks to it over HTTP.

2. The four project layers

The Order project is split into four projects (layers). Each layer has a single responsibility:

The heart

🟢 Order.Domain — Domain

Business rules live here. It has no dependency on a database or framework.

Contains: OrderAggregate, OrderLine, OrderStatus, Events, base classes (Entity, AggregateRoot)

Coordinator

🔵 Order.Application — Application

Coordinates the scenarios: "when an order comes in, what should happen and in what order?"

Contains: Commands, Queries, Handlers, Validators, DTOs, Interfaces

Outside world

🟣 Order.Infrastructure — Infrastructure

The real implementation of the database and communication with other services.

Contains: OrderDbContext, Repository, Configurations, HttpClients

Entry door

🟡 Order.Api — HTTP interface

Receives the HTTP request and hands it to Application.

Contains: OrdersController, Request DTOs, Program.cs

The golden rule of dependencies:
Api → depends on Application and Infrastructure
Application → depends only on Domain
Infrastructure → depends on Application and Domain
Domain → depends on nothing (independent and pure)

All arrows point toward Domain. The heart of the project must not know what a database or an API is.

3. DDD concepts with real examples

3.1 Entity

An Entity is something identified by an Id, not by its content. Even if all of its attributes change, as long as the Id is the same it is still the same entity.

Example: an "order line" (OrderLine). Two order lines, even if both are "2 laptops", are still two different entities because they have separate Ids.

// Base class: every entity has an Id
public abstract class Entity
{
    public Guid Id { get; protected set; }
    protected Entity() { }
}
// OrderLine is an Entity
public sealed class OrderLine : Entity
{
    public Guid ProductId { get; private set; }
    public string ProductName { get; private set; }
    public int Quantity { get; private set; }
    public decimal UnitPrice { get; private set; }
    public decimal LineTotal => UnitPrice * Quantity;  // computed

    // can only be created from within this assembly (internal)
    internal static OrderLine Create(Guid productId, string name, int qty, decimal price)
    {
        if (qty <= 0) throw new ArgumentException("Quantity must be > 0");
        if (price < 0) throw new ArgumentException("Price cannot be negative");
        return new OrderLine { Id = Guid.NewGuid(), /* ... */ };
    }
}
Notice all setters are private. That means values cannot be changed from outside — only through controlled methods. This is encapsulation.

3.2 Value Object

A Value Object is the opposite of an Entity: it is identified by its content, not an Id. Two Value Objects with the same values are completely equal.

The classic example is money. "$10" is always equal to "$10". In the Product service we have this:

public sealed class Money : ValueObject
{
    public decimal Amount { get; }
    public string Currency { get; }
    // Two Money instances with the same Amount and Currency are equal
}
In the Order service, for simplicity, price is kept as a plain decimal. But in Product the Money concept is implemented as a Value Object so you can see the difference.
TraitEntityValue Object
Identified byIdContent
Mutable?Yes (controlled)No (immutable)
ExampleOrderLine, OrderAggregateMoney, Email

3.3 Aggregate & Aggregate Root

An Aggregate is a group of related objects that together form one unit. In our service:

The order Aggregate = one OrderAggregate + several OrderLines inside it
📦 OrderAggregate (Root)
📄 OrderLine 1
📄 OrderLine 2

The Aggregate Root is the "boss" of this group. The important rule:

All changes must go through the root only. No one from the outside is allowed to manipulate an OrderLine directly. They must ask the OrderAggregate.

See how it's implemented:

public sealed class OrderAggregate : AggregateRoot
{
    // List of lines: it's private! Not visible from outside
    private readonly List<OrderLine> _lines = [];

    public Guid UserId { get; private set; }
    public OrderStatus Status { get; private set; }
    public decimal TotalAmount { get; private set; }

    // Outside only sees a read-only copy
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();

    private OrderAggregate() { }  // private constructor

    // The only way to create an order: this factory method
    public static OrderAggregate Create(Guid userId, IEnumerable<...> items)
    {
        var lineItems = items.ToList();
        if (lineItems.Count == 0)
            throw new ArgumentException("Order must contain at least one line item.");

        var order = new OrderAggregate
        {
            Id = Guid.NewGuid(),
            UserId = userId,
            Status = OrderStatus.Confirmed,
            CreatedAt = DateTime.UtcNow
        };

        // lines are created only from inside the root
        foreach (var item in lineItems)
            order._lines.Add(OrderLine.Create(item.ProductId, item.ProductName,
                                              item.Quantity, item.UnitPrice));

        order.TotalAmount = order._lines.Sum(l => l.LineTotal);  // compute total
        order.RaiseDomainEvent(new OrderPlacedEvent(order.Id, userId, order.TotalAmount));
        return order;
    }

    public void Cancel()
    {
        if (Status == OrderStatus.Cancelled)
            throw new InvalidOperationException("Order is already cancelled.");
        Status = OrderStatus.Cancelled;
        UpdatedAt = DateTime.UtcNow;
        RaiseDomainEvent(new OrderCancelledEvent(Id));
    }
}
Why so strict? Because if anyone could change the lines directly, TotalAmount might not match the sum of the lines, creating corrupt data. The root guarantees the order always stays in a valid state.

The AggregateRoot base class

public abstract class AggregateRoot : Entity
{
    private readonly List<IDomainEvent> _domainEvents = [];
    public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
    protected void RaiseDomainEvent(IDomainEvent e) => _domainEvents.Add(e);
    public void ClearDomainEvents() => _domainEvents.Clear();
}

The difference between AggregateRoot and Entity: besides an Id, it can record Domain Events. That's why OrderAggregate inherits from AggregateRoot while OrderLine only inherits from Entity.

3.4 Domain Event

A Domain Event means "an important business thing happened". For example "an order was placed" or "an order was cancelled". These events are facts from the past, so their names are always in past tense.

public interface IDomainEvent
{
    DateTime OccurredOn { get; }  // when it happened
}

public sealed class OrderPlacedEvent : IDomainEvent
{
    public Guid OrderId { get; }
    public Guid UserId { get; }
    public decimal TotalAmount { get; }
    public DateTime OccurredOn { get; }
    // ...
}

When an order is created, this line runs inside Create:

order.RaiseDomainEvent(new OrderPlacedEvent(order.Id, userId, order.TotalAmount));
For now this event is only recorded (in the _domainEvents list). In real projects you can publish these events after persistence so that, for example, a confirmation email is sent or the warehouse service is notified. This creates loose coupling between parts of the system.

4. CQRS with real examples

CQRS means separating writes from reads:

CommandQuery
JobChanges state (write)Only reads
ExampleCreate order, cancel orderView order
OutputUsually just an Id or nothingData (DTO)

For the implementation we use the MediatR library. The controller does not call the Handler directly; it gives a message (Command/Query) to MediatR, and MediatR finds the right Handler itself.

4.1 Command

Each Command has three parts in one file: the command itself, the validation rules, and the handler.

// 1) The command itself: just data (a simple record)
public sealed record CreateOrderCommand(
    Guid UserId,
    IReadOnlyList<CreateOrderLineRequest> Lines) : IRequest<Guid>;
//                                                  ^^^^^^^^^^^^^ output: order Id

// 2) Validation rules
public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(x => x.UserId).NotEmpty();
        RuleFor(x => x.Lines).NotEmpty();
        RuleForEach(x => x.Lines).ChildRules(line =>
        {
            line.RuleFor(l => l.ProductId).NotEmpty();
            line.RuleFor(l => l.Quantity).GreaterThan(0);
        });
    }
}

4.2 Handler

The Handler is where the scenario logic runs. This is the most important part of the Order service:

public sealed class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IUserServiceClient _userServiceClient;
    private readonly IProductServiceClient _productServiceClient;
    // (dependency injection via the constructor)

    public async Task<Guid> Handle(CreateOrderCommand request, CancellationToken ct)
    {
        // 1) Does the user exist? (call the User service)
        var user = await _userServiceClient.GetUserAsync(request.UserId, ct)
            ?? throw new KeyNotFoundException($"User '{request.UserId}' not found.");

        // 2) Is the user active?
        if (!user.IsActive)
            throw new InvalidOperationException("Cannot create order for inactive user.");

        // 3) For each item, fetch the product from the Product service and check stock
        var orderItems = new List<(Guid, string, int, decimal)>();
        foreach (var line in request.Lines)
        {
            var product = await _productServiceClient.GetProductAsync(line.ProductId, ct)
                ?? throw new KeyNotFoundException($"Product '{line.ProductId}' not found.");

            if (product.StockQuantity < line.Quantity)
                throw new InvalidOperationException(
                    $"Insufficient stock for '{product.Name}'.");

            orderItems.Add((product.Id, product.Name, line.Quantity, product.Price));
        }

        // 4) Now that everything is valid, create the order (logic inside the aggregate)
        var order = OrderAggregate.Create(request.UserId, orderItems);

        // 5) Persist to the database
        await _orderRepository.AddAsync(order, ct);
        return order.Id;
    }
}
Note the separation of responsibilities: the Handler is the "coordinator" (whom to call, in what order). But business decisions like "what is the order total" or "an order must have at least one line" live inside OrderAggregate. The Handler does not duplicate domain logic.

4.3 Query

A Query only reads and returns a DTO (not the aggregate itself):

public sealed record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDto?>;

public sealed class GetOrderByIdQueryHandler
    : IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
    private readonly IOrderRepository _repository;

    public async Task<OrderDto?> Handle(GetOrderByIdQuery request, CancellationToken ct)
    {
        var order = await _repository.GetByIdAsync(request.OrderId, ct);
        return order?.ToDto();   // convert the aggregate to a DTO
    }
}
Why a DTO? Because we don't want to send the domain aggregate directly to the outside. A DTO is a "simple, safe view" for sending to the client and decouples the outside from the internal domain structure.
public sealed record OrderDto(
    Guid Id, Guid UserId, OrderStatus Status, decimal TotalAmount,
    DateTime CreatedAt, DateTime? UpdatedAt, IReadOnlyList<OrderLineDto> Lines);

4.4 FluentValidation & ValidationBehavior

Question: we wrote the Validator, but who runs it? The answer: ValidationBehavior.

This is a "pipeline behavior" in MediatR. Every Command/Query passes through this pipeline before reaching the Handler:

public sealed class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public async Task<TResponse> Handle(TRequest request,
        RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        if (!_validators.Any()) return await next();  // no validators? skip

        var context = new ValidationContext<TRequest>(request);
        var results = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, ct)));
        var failures = results.SelectMany(r => r.Errors).ToList();

        if (failures.Count != 0)
            throw new ValidationException(failures);  // errors? don't reach the Handler

        return await next();  // all good? go to the next Handler
    }
}
Request arrives from the controller
↓
🔍 ValidationBehavior — runs the validators
↓ (if valid)
⚙️ Handler — the main logic runs

This behavior is registered in DI so it's active for every request:

services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
services.AddValidatorsFromAssembly(assembly);
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
Benefit: Handlers never contain validation code. They stay clean and focus only on logic. Validation happens automatically and in one place.

5. The full flow of creating an order

Now let's put it all together. When the client sends a POST /api/orders:

1. OrdersController — receives the HTTP request, converts it to a CreateOrderCommand and calls _mediator.Send(...)
↓
2. ValidationBehavior — checks that UserId is not empty, there's at least one line, and quantities are positive
↓
3. CreateOrderCommandHandler — fetches the user from the User service and products from the Product service and validates them
↓
4. OrderAggregate.Create() — the order is built, the total is computed and the OrderPlacedEvent is recorded
↓
5. OrderRepository.AddAsync() — the order (and its lines) are saved to the database
↓
6. Controller — returns a 201 Created response with the order Id

And the controller maps errors to the correct HTTP status codes like this:

[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateOrderRequest request, CancellationToken ct)
{
    try
    {
        var id = await _mediator.Send(new CreateOrderCommand(
            request.UserId,
            request.Lines.Select(l => new CreateOrderLineRequest(l.ProductId, l.Quantity)).ToList()), ct);
        return CreatedAtAction(nameof(GetById), new { id }, new { id });
    }
    catch (KeyNotFoundException ex) { return NotFound(new { title = ex.Message }); }    // 404
    catch (InvalidOperationException ex) { return Conflict(new { title = ex.Message }); } // 409
}

6. Persistence

In OrderDbContext we have only one DbSet — for the root:

public sealed class OrderDbContext : DbContext
{
    public DbSet<OrderAggregate> Orders => Set<OrderAggregate>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrderDbContext).Assembly);
        base.OnModelCreating(modelBuilder);
    }
}

6.1 How does EF Core find the Configuration class?

You might ask: we wrote the OrderAggregateConfiguration class, but nowhere did we new it or register it in DI. So how does EF Core know about it? The answer is this single line:

modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrderDbContext).Assembly);

This line tells EF Core:

"Scan this whole assembly (the Order.Infrastructure project), find every class that implements IEntityTypeConfiguration<T>, create an instance of it and call its Configure method."

The contract of a Configuration class

For EF Core to discover a class automatically, only three conditions are required:

  • The class is public
  • It implements the IEntityTypeConfiguration<T> interface
  • It lives in the same assembly that ApplyConfigurationsFromAssembly was called on
//          any name                                contract ↓        for which entity ↓
public sealed class OrderAggregateConfiguration : IEntityTypeConfiguration<OrderAggregate>
{
    public void Configure(EntityTypeBuilder<OrderAggregate> builder)
    {
        // EF Core calls this method itself
    }
}

What does EF Core do behind the scenes?

Simplified, something like this runs:

// pseudo-code inside EF Core
foreach (var type in assembly.GetTypes())
{
    if (type implements IEntityTypeConfiguration<SomeEntity>)
    {
        var instance = Activator.CreateInstance(type);   // create an instance
        instance.Configure(modelBuilder.Entity<SomeEntity>());  // run Configure
    }
}

In our project two classes are found and executed:

Configuration classFor which entityTable
OrderAggregateConfigurationOrderAggregateOrders
OrderLineConfigurationOrderLineOrderLines

The whole flow from the start

The app starts and OrderDbContext is used for the first time
↓
EF Core calls the OnModelCreating method
↓
ApplyConfigurationsFromAssembly scans all IEntityTypeConfiguration classes
↓
For each one, the Configure method runs (mapping tables, keys, relationships)
↓
The EF Core model is built and cached — ready for Migration and Query
Benefit: you don't need to register each configuration manually. Whenever you create a new class with IEntityTypeConfiguration, it's discovered automatically. That's also why OrderLineConfiguration is applied even though it has no DbSet.
What if this line were missing? EF Core would use only the default conventions: table names would be wrong, the _lines relationship wouldn't map correctly, and DomainEvents and Lines might be mistakenly treated as database columns.
Important question: why didn't we add a DbSet for OrderLine?

Because OrderLine is not an independent aggregate — it's part of the order aggregate. In DDD only roots get a DbSet. This prevents direct manipulation of the lines and preserves the aggregate boundary.

But OrderLine still has its own table (OrderLines). EF Core knows it through the relationship:

public void Configure(EntityTypeBuilder<OrderAggregate> builder)
{
    builder.ToTable("Orders");
    builder.HasKey(o => o.Id);
    builder.Property(o => o.TotalAmount).HasPrecision(18, 2);

    // one-to-many relationship with lines; use the private _lines field
    builder.HasMany<OrderLine>("_lines")
        .WithOne()
        .HasForeignKey("OrderId")
        .OnDelete(DeleteBehavior.Cascade);      // order deleted? its lines go too
    builder.Navigation("_lines").UsePropertyAccessMode(PropertyAccessMode.Field);

    builder.Ignore(o => o.Lines);          // read-only property, not mapped
    builder.Ignore(o => o.DomainEvents);   // events are not stored in the database
}

So how is it saved without a DbSet? When you add the order, EF Core follows the entire connected graph:

await _context.Orders.AddAsync(order, ct);   // we only added the root
await _context.SaveChangesAsync(ct);          // EF inserts the inner lines itself

The resulting generated SQL:

INSERT INTO Orders (Id, UserId, Status, TotalAmount, CreatedAt) VALUES (...);
INSERT INTO OrderLines (Id, OrderId, ProductId, ProductName, Quantity, UnitPrice) VALUES (...);
INSERT INTO OrderLines (...) VALUES (...);

And when reading, we load the lines too with Include:

return await _context.Orders
    .Include("_lines")          // load the lines too
    .FirstOrDefaultAsync(o => o.Id == id, ct);

7. Communicating with other services

To fetch User and Product data, the Order service talks to them over HTTP. But the Application layer must not know what HTTP is — so we define an interface:

// In the Application layer: just the contract
public interface IUserServiceClient
{
    Task<UserInfo?> GetUserAsync(Guid userId, CancellationToken ct = default);
}
public sealed record UserInfo(Guid Id, string Email, string FullName, bool IsActive);

And the real HTTP implementation lives in the Infrastructure layer:

// In the Infrastructure layer: the real implementation
public sealed class UserServiceClient : IUserServiceClient
{
    private readonly HttpClient _httpClient;

    public async Task<UserInfo?> GetUserAsync(Guid userId, CancellationToken ct = default)
    {
        var response = await _httpClient.GetAsync($"/api/users/{userId}", ct);
        if (response.StatusCode == HttpStatusCode.NotFound) return null;
        response.EnsureSuccessStatusCode();
        var user = await response.Content.ReadFromJsonAsync<UserApiResponse>(ct);
        return user is null ? null : new UserInfo(user.Id, user.Email, user.FullName, user.IsActive);
    }
}

And they're wired together in DI:

services.AddHttpClient<IUserServiceClient, UserServiceClient>(client =>
{
    client.BaseAddress = new Uri(userServiceUrl);
});
This is the "dependency inversion" pattern: the Application layer only knows IUserServiceClient. Whether HTTP, gRPC, or even a Mock for testing is behind it doesn't matter. This keeps the code testable and changeable.

8. Running & testing

Because Order depends on User and Product, it's best to run all three.

Option 1: with Docker Compose (easier)

docker compose up --build

Option 2: manually

dotnet run --project src/User/User.Api
dotnet run --project src/Product/Product.Api
dotnet run --project src/Order/Order.Api

Then open the Order service Swagger and create an order:

POST /api/orders
{
  "userId": "GUID of an active user",
  "lines": [
    { "productId": "GUID of an in-stock product", "quantity": 2 }
  ]
}
MethodRouteJob
POST/api/ordersCreate an order
GET/api/orders/{id}View a single order
GET/api/orders/user/{userId}A user's orders
PUT/api/orders/{id}/cancelCancel an order

Final summary

  • Domain = the heart and the rules (OrderAggregate, OrderLine, Events) — independent of everything
  • Application = coordinating scenarios (Command/Query/Handler) with MediatR and FluentValidation
  • Infrastructure = the database and HTTP communication with other services
  • Api = the HTTP entry door
  • Aggregate Root guarantees the order always stays valid
  • CQRS separates writes from reads
  • Only the root gets a DbSet; lines are saved through the relationship

This file was made to learn DDD and CQRS with the Order project — good luck! 🚀