Domain-Driven Implementation Patterns
Why tactical patterns matter when domain complexity outgrows simple CRUD architectures

I’ve lost count of the times I’ve seen a project start as a neat set of services and entities, only to slowly morph into a tangled web of anemic models, misplaced business rules, and database schemas that dictate the shape of the code. The pain usually appears around the second or third major feature release: data consistency bugs creep in, edge cases multiply, and every change feels risky. Domain-Driven Design (DDD) promises relief, but the high-level concepts can feel abstract until you get to the implementation layer. That is where tactical patterns come in: they are the concrete building blocks that translate domain knowledge into robust, maintainable code.
In this post, I’ll walk through the implementation patterns I rely on when the domain gets messy. We’ll connect strategic DDD ideas to practical code, mostly in C# and a bit of TypeScript to illustrate patterns in different ecosystems. I’ll share tradeoffs I’ve learned the hard way, a small case study from an IoT platform, and resources that have helped me teach these ideas to teams. If you’ve ever wondered whether an Aggregate should enforce invariants, how to handle long-running processes without sprawl, or where to put validation, this is for you.
Where tactical DDD fits today
DDD’s tactical patterns show up most often in systems with rich business rules, high consistency requirements, and evolving complexity. You’ll see them used in fintech payment processing, logistics and inventory systems, healthcare workflows, and B2B SaaS platforms with domain-specific configuration. They’re less common in simple data-exchange services or purely read-heavy APIs, where a thin service layer over a database might be enough.
Teams adopting these patterns typically include backend engineers, domain experts, and architects collaborating closely. On the methodology side, tactical DDD contrasts with purely data-centric approaches like Active Record or transaction scripts. It also contrasts with event-driven architectures that lean heavily on events without clear domain boundaries. Tactical DDD complements event-driven designs by giving you well-defined aggregates and boundaries that emit meaningful domain events.
Real-world usage tends to be pragmatic: teams often adopt a subset of patterns where they bring the most value, like aggregates for consistency and repositories for persistence abstraction. In modern stacks, you’ll find these patterns in languages like C#, Java, TypeScript, and Go, often paired with ORM frameworks such as Entity Framework, Hibernate, TypeORM, or GORM. Tooling includes static analysis, unit testing frameworks, and, increasingly, code generation and contract testing tools for services. If you want to see the strategic side that underpins these tactics, Martin Fowler’s overview is a reliable starting point: Domain-Driven Design.
Core concepts and practical implementation
DDD tactical patterns focus on modeling the domain in code with clear boundaries and responsibilities. The key building blocks include entities, value objects, aggregates, domain events, repositories, and services. Each has a distinct role, and misplacing responsibilities is a common source of bugs and maintenance headaches.
Entities and value objects
Entities are defined by identity, while value objects are defined by their attributes. An entity’s identity persists across state changes; value objects are immutable and can be replaced wholesale. Getting this right makes equality, comparison, and validation clearer.
public sealed class ProductId
{
public Guid Value { get; }
public ProductId(Guid value)
{
if (value == Guid.Empty)
throw new ArgumentException("Product id cannot be empty.", nameof(value));
Value = value;
}
public static ProductId New() => new ProductId(Guid.NewGuid());
public override string ToString() => Value.ToString();
public override bool Equals(object? obj) => obj is ProductId other && Value.Equals(other.Value);
public override int GetHashCode() => Value.GetHashCode();
}
public sealed class Money
{
public decimal Amount { get; }
public string Currency { get; } // ISO code like USD, EUR
public Money(decimal amount, string currency)
{
if (amount < 0) throw new ArgumentException("Amount must be non-negative.", nameof(amount));
if (string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
throw new ArgumentException("Currency must be a 3-letter ISO code.", nameof(currency));
Amount = amount;
Currency = currency;
}
public Money Add(Money other)
{
if (other.Currency != Currency)
throw new InvalidOperationException("Cannot add amounts in different currencies.");
return new Money(Amount + other.Amount, Currency);
}
public override bool Equals(object? obj)
{
return obj is Money other && Amount == other.Amount && Currency == other.Currency;
}
public override int GetHashCode()
{
return HashCode.Combine(Amount, Currency);
}
}
In this example, ProductId encapsulates identity and validation. Money demonstrates immutability and domain behavior. Avoid anemic models by keeping behavior close to the data that represents the domain concept. Value objects eliminate nulls and primitive obsession, making code easier to reason about.
Aggregates and invariants
An aggregate is a cluster of domain objects treated as a single unit for data changes. The aggregate root enforces invariants and ensures consistency. A common mistake is letting external services mutate aggregate state directly, bypassing the aggregate’s rules.
public enum OrderStatus
{
Draft,
Placed,
Confirmed,
Cancelled
}
public class OrderLine
{
public ProductId ProductId { get; private set; }
public int Quantity { get; private set; }
public Money UnitPrice { get; private set; }
public OrderLine(ProductId productId, int quantity, Money unitPrice)
{
if (quantity <= 0) throw new ArgumentException("Quantity must be positive.", nameof(quantity));
ProductId = productId ?? throw new ArgumentNullException(nameof(productId));
UnitPrice = unitPrice ?? throw new ArgumentNullException(nameof(unitPrice));
Quantity = quantity;
}
public Money Subtotal => new Money(UnitPrice.Amount * Quantity, UnitPrice.Currency);
}
public class Order
{
public Guid Id { get; private set; }
public OrderStatus Status { get; private set; }
private readonly List<OrderLine> _lines = new();
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
public Order(Guid id)
{
Id = id;
Status = OrderStatus.Draft;
}
public void Place()
{
if (_lines.Count == 0) throw new InvalidOperationException("Cannot place an order with no lines.");
Status = OrderStatus.Placed;
// Domain event example: Add DomainEvent to a list for publishing later
}
public void AddLine(ProductId productId, int quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft) throw new InvalidOperationException("Cannot modify order after it's placed.");
var line = new OrderLine(productId, quantity, unitPrice);
_lines.Add(line);
}
public Money Total()
{
return _lines.Aggregate(new Money(0, _lines.FirstOrDefault()?.UnitPrice.Currency ?? "USD"),
(acc, line) => acc.Add(line.Subtotal));
}
}
This Order is the aggregate root. It enforces that you cannot add lines after the order is placed, and it ensures an order cannot be placed without lines. Invariants belong to the aggregate. If you need to reference external data (e.g., product pricing), pass it in rather than fetching it inside the aggregate.
Repositories and persistence
Repositories abstract persistence, keeping domain logic independent of data infrastructure. They are typically per-aggregate, not per-entity.
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task AddAsync(Order order, CancellationToken ct = default);
Task UpdateAsync(Order order, CancellationToken ct = default);
}
public class EfOrderRepository : IOrderRepository
{
private readonly AppDbContext _context;
public EfOrderRepository(AppDbContext context) => _context = context;
public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
// In real apps, consider loading child entities (lines) as needed
return await _context.Orders
.FirstOrDefaultAsync(o => o.Id == id, ct);
}
public async Task AddAsync(Order order, CancellationToken ct = default)
{
_context.Orders.Add(order);
await _context.SaveChangesAsync(ct);
}
public async Task UpdateAsync(Order order, CancellationToken ct = default)
{
_context.Orders.Update(order);
await _context.SaveChangesAsync(ct);
}
}
If your ORM serializes entities directly, you might still have anemic models. A pragmatic safeguard is to keep entity state private and expose domain behavior publicly, ensuring the ORM can still map. For some domains, persistence-aware aggregates (like using EF backing fields) strike a practical balance.
Domain events and side effects
Domain events capture something meaningful that happened in the domain. They should be raised within aggregates and handled by application services or infrastructure, not by the aggregate itself to avoid coupling.
public interface IDomainEvent { }
public class OrderPlacedEvent : IDomainEvent
{
public Guid OrderId { get; }
public DateTime PlacedAt { get; }
public OrderPlacedEvent(Guid orderId, DateTime placedAt)
{
OrderId = orderId;
PlacedAt = placedAt;
}
}
public class OrderPlacedEmailHandler
{
public Task Handle(OrderPlacedEvent evt)
{
// Send email or queue email job
Console.WriteLine($"Email for order {evt.OrderId} placed at {evt.PlacedAt}");
return Task.CompletedTask;
}
}
Aggregate roots can maintain a list of events raised during a transaction. An application service then publishes those events to a dispatcher. This decouples domain logic from infrastructure concerns like email, audit logs, or integration events.
Domain services and application services
Domain services contain logic that doesn’t naturally belong to a single entity or value object. Application services orchestrate use cases: they load aggregates, call domain behavior, persist, and publish events.
public class PlaceOrderService
{
private readonly IOrderRepository _orders;
private readonly IProductRepository _products;
public PlaceOrderService(IOrderRepository orders, IProductRepository products)
{
_orders = orders;
_products = products;
}
public async Task<Guid> PlaceOrderAsync(IEnumerable<(Guid productId, int quantity)> items, string currency, CancellationToken ct = default)
{
var orderId = Guid.NewGuid();
var order = new Order(orderId);
foreach (var item in items)
{
var product = await _products.GetByIdAsync(new ProductId(item.productId), ct);
if (product == null) throw new InvalidOperationException($"Product {item.productId} not found.");
// In real apps, price may be computed by a pricing domain service
var unitPrice = new Money(product.PriceAmount, currency);
order.AddLine(new ProductId(item.productId), item.quantity, unitPrice);
}
order.Place();
await _orders.AddAsync(order, ct);
await PublishEventsAsync(new OrderPlacedEvent(order.Id, DateTime.UtcNow));
return order.Id;
}
private Task PublishEventsAsync(params IDomainEvent[] events)
{
// Simple dispatcher; in real systems use a robust outbox/inbox
foreach (var e in events)
{
if (e is OrderPlacedEvent evt)
{
new OrderPlacedEmailHandler().Handle(evt);
}
}
return Task.CompletedTask;
}
}
Notice the orchestration: no business rules leak into the service; it just coordinates. This separation makes the domain logic testable in isolation.
A real-world case study: IoT device provisioning
In an IoT platform I contributed to, devices went through a provisioning flow with onboarding, firmware validation, and activation. The domain model needed to enforce that a device cannot be activated before firmware verification, and that provisioning is idempotent given a device serial number.
We used aggregates for Device and DeviceBatch, with domain events like DeviceRegistered, FirmwareValidated, and DeviceActivated. We avoided calling external firmware verification APIs inside the aggregate; instead, we passed the verification result into the aggregate method. Repositories abstracted persistence, and we used an outbox pattern for reliable event publishing.
Project structure looked like this:
src/
Domain/
Models/
Device.cs
DeviceBatch.cs
ValueObjects/
DeviceId.cs
FirmwareChecksum.cs
Events/
DeviceActivatedEvent.cs
FirmwareValidatedEvent.cs
Repositories/
IDeviceRepository.cs
Application/
Services/
ProvisionDeviceService.cs
Queries/
Commands/
Infrastructure/
Persistence/
AppDbContext.cs
EfDeviceRepository.cs
Messaging/
OutboxPublisher.cs
Api/
Controllers/
Middleware/
tests/
Domain.UnitTests/
Application.IntegrationTests/
Device aggregate example:
public enum DeviceStatus
{
Registered,
FirmwareValidated,
Activated
}
public class Device
{
public DeviceId Id { get; private set; }
public DeviceStatus Status { get; private set; }
public FirmwareChecksum Checksum { get; private set; }
private readonly List<IDomainEvent> _events = new();
public IReadOnlyCollection<IDomainEvent> Events => _events.AsReadOnly();
public Device(DeviceId id)
{
Id = id;
Status = DeviceStatus.Registered;
}
public void ValidateFirmware(FirmwareChecksum checksum)
{
if (Status != DeviceStatus.Registered) throw new InvalidOperationException("Firmware already validated or device activated.");
Checksum = checksum;
Status = DeviceStatus.FirmwareValidated;
_events.Add(new FirmwareValidatedEvent(Id.Value, checksum.Value));
}
public void Activate()
{
if (Status != DeviceStatus.FirmwareValidated) throw new InvalidOperationException("Cannot activate before firmware validation.");
Status = DeviceStatus.Activated;
_events.Add(new DeviceActivatedEvent(Id.Value, DateTime.UtcNow));
}
public void ClearEvents() => _events.Clear();
}
Why pass checksum in rather than fetch from external service? Aggregate invariants should not depend on external calls. This makes behavior deterministic and testable. Domain events allowed us to decouple device activation from downstream notifications and telemetry ingestion.
Honest evaluation: strengths, weaknesses, tradeoffs
Tactical DDD shines when:
- Business rules are complex and change frequently.
- Data consistency is critical across multiple operations.
- The domain model needs to evolve independently of the database schema.
It struggles when:
- The domain is simple CRUD without meaningful behavior. Here the overhead isn’t justified.
- Teams lack domain access, making modeling guesswork. You end up with a model that doesn’t match reality.
- Performance constraints demand tight coupling with storage details (e.g., high-throughput analytics).
Tradeoffs to consider:
- Testability improves but requires discipline. Avoid mixing infrastructure in aggregates.
- Event-driven flows improve decoupling but add complexity (ordering, idempotency, error handling).
- Repository patterns abstract persistence but can hide expensive queries. Sometimes a query layer is necessary alongside the domain model.
In high-throughput systems, I’ve paired aggregates for writes with read-optimized models (CQRS-style) to keep things fast. This is pragmatic and avoids over-engineering. The line between DDD and CQRS is often blurred in practice; you don’t need full CQRS to benefit from tactical patterns.
Personal experience: lessons from the trenches
Adopting these patterns takes patience. Early on, I’d put too much logic in services and left entities as simple data holders. This caused rules to spread and bugs to hide. The turning point was refactoring a core use case into an aggregate with clear invariants and unit tests around those invariants. The tests became documentation and a safety net.
Common mistakes I’ve seen:
- Putting validation in controllers or DTOs. Validation belongs in the domain when it reflects business rules.
- Treating domain events as integration events. Keep domain events focused on what happened in the domain; integration events describe external contracts.
- Overusing inheritance. Favor composition and small focused classes.
Another learning: domain services are a code smell if they start accumulating rules that belong in aggregates. Before adding a domain service, ask whether the behavior can live in an entity or value object. If it involves multiple aggregates, a domain service may be appropriate.
One moment stands out: during a compliance audit, having domain events with clear semantics and timestamps made it straightforward to reconstruct decisions. That alone justified the modeling effort.
Getting started: workflow and mental models
You don’t need to refactor everything at once. Start with one bounded context and one complex use case.
- Identify your aggregates. What changes together and needs consistency boundaries?
- Define value objects for critical primitives. Replace strings and decimals with domain types (e.g., Email, Money).
- Use repositories sparingly: one per aggregate. Avoid generic repositories with leaky queries.
- Introduce domain events where behavior triggers side effects. Keep handlers lightweight.
- Write unit tests for aggregate invariants. That’s where the domain logic lives.
- Consider a simple outbox for reliable event publishing in production.
Minimal project setup in C# with EF Core:
dotnet new webapi -n OrderApi
cd OrderApi
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet new classlib -n Domain
dotnet new classlib -n Application
dotnet new classlib -n Infrastructure
dotnet sln add Domain Application Infrastructure
dotnet add Domain reference
dotnet add Application reference Domain
dotnet add Infrastructure reference Application
dotnet add Api reference Application Infrastructure
Example DbContext and registration:
// Infrastructure/Persistence/AppDbContext.cs
using Domain.Models;
using Microsoft.EntityFrameworkCore;
namespace Infrastructure.Persistence
{
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Configure Order/OrderLine as owned types or separate tables
modelBuilder.Entity<Order>().HasKey(o => o.Id);
modelBuilder.Entity<Order>().HasMany(o => o.Lines);
}
}
}
// Api/Program.cs
using Application.Services;
using Domain.Repositories;
using Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
builder.Services.AddScoped<PlaceOrderService>();
var app = builder.Build();
app.MapPost("/orders", async (PlaceOrderService service, PlaceOrderRequest request) =>
{
var id = await service.PlaceOrderAsync(request.Items, "USD");
return Results.Ok(new { OrderId = id });
});
app.Run();
public record PlaceOrderRequest(IEnumerable<(Guid productId, int quantity)> Items);
Mental model shifts:
- Design behavior first, storage second.
- Treat persistence as an implementation detail behind repositories.
- Prefer small, composable value objects to reduce nulls and bugs.
What makes tactical DDD stand out
Compared to pure CRUD or anemic models, tactical DDD yields code that reflects business intent. When someone reads an Order.Place method, they understand the domain concept, not just how data is saved. This clarity pays off during onboarding, audits, and refactoring.
Developer experience improves because:
- Tests are focused on business rules rather than mocking databases extensively.
- Refactoring becomes safer with bounded aggregates and clear invariants.
- Changes are localized when modeling matches real domain boundaries.
Maintainability gains are real. I’ve seen teams move faster after a targeted refactor because they weren’t carrying the cognitive load of implicit, scattered rules.
Free learning resources
- Martin Fowler’s DDD overview: Domain-Driven Design — concise, reliable starting point.
- Vaughn Vernon’s Red Book: Implementing Domain-Driven Design — practical patterns and examples. The examples are in Java but concepts translate well.
- Microsoft’s eShopOnContainers: eShopOnContainers — reference architecture showing DDD and microservices in .NET.
- Nicklas Lundberg’s DDD examples: Domain-Driven Design Example — small, readable projects illustrating aggregates and repositories.
- ThoughtWorks Technology Radar on DDD and CQRS: Technology Radar — frequent discussion of when these patterns are appropriate.
Summary: who should use this and who might skip it
Use tactical DDD when your domain has meaningful complexity, consistency constraints, and rules that evolve. It’s a strong fit for teams willing to invest in modeling and collaboration with domain experts. If you’re building a simple CRUD API, this may be overkill. In those cases, keep domain logic explicit but don’t over-engineer with aggregates and repositories.
If you want to push beyond CRUD and need a shared language with your domain experts, start with one aggregate, one use case, and one event. Measure outcomes: fewer defects, clearer tests, and easier changes. Tactical DDD isn’t a silver bullet, but in the right context, it turns messy business logic into maintainable code that teams can reason about and trust.




