Master DDD: strategic patterns (bounded contexts, ubiquitous language) and tactical building blocks (aggregates, value objects, domain events).
Domain-Driven Design (DDD) is a software development approach that places the business domain at the center of design. It provides strategic patterns for decomposing large systems into bounded contexts and tactical building blocks for modeling rich domain logic in code.
Strategic DDD divides the system into bounded contexts with explicit contracts:
1// Strategic DDD - High-Level Design Patterns
2/*
3 * Bounded Contexts
4 * ================
5 * A bounded context defines a clear boundary within which
6 * a domain model applies. Different contexts may have
7 * different models for the same concept.
8 *
9 * E-Commerce System Bounded Contexts:
10 *
11 * ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
12 * │ Sales Context │ │ Inventory Ctx │ │ Shipping Ctx │
13 * ├─────────────────┤ ├─────────────────┤ ├─────────────────┤
14 * │ • Order │ │ • Product │ │ • Shipment │
15 * │ • Customer │ │ • Stock │ │ • Address │
16 * │ • Cart │ │ • Warehouse │ │ • Carrier │
17 * │ • Payment │ │ • Supplier │ │ • Tracking │
18 * └─────────────────┘ └─────────────────┘ └─────────────────┘
19 * │ │ │
20 * └────────────────────┼────────────────────┘
21 * │
22 * Integration Events
23 */
24
25// Ubiquitous Language
26// Same term, different meaning in each context
27
28// Sales Context - "Product" is what we sell
29namespace Sales.Domain;
30public class Product
31{
32 public Guid Id { get; }
33 public string Name { get; }
34 public Money Price { get; }
35 public string Description { get; }
36 public List<Category> Categories { get; }
37 public decimal DiscountPercentage { get; }
38}
39
40// Inventory Context - "Product" is what we stock
41namespace Inventory.Domain;
42public class Product
43{
44 public Guid Id { get; }
45 public string SKU { get; }
46 public int StockLevel { get; }
47 public int ReorderPoint { get; }
48 public Guid WarehouseId { get; }
49 public List<StockMovement> Movements { get; }
50}
51
52// Shipping Context - "Product" is what we ship
53namespace Shipping.Domain;
54public class Product
55{
56 public Guid Id { get; }
57 public string SKU { get; }
58 public Weight Weight { get; }
59 public Dimensions Dimensions { get; }
60 public bool RequiresSpecialHandling { get; }
61 public HazardClass? HazardClassification { get; }
62}
63
64// Context Map - Relationships between contexts
65/*
66 * Context Relationships:
67 *
68 * Sales ──[Customer-Supplier]──> Inventory
69 * Sales depends on Inventory for stock checks
70 *
71 * Sales ──[Published Language]──> Shipping
72 * Sales publishes orders that Shipping consumes
73 *
74 * Inventory ──[Conformist]──> Shipping
75 * Inventory conforms to Shipping's address model
76 *
77 * Anti-Corruption Layer (ACL):
78 * Used when integrating with legacy systems
79 * or external services with different models
80 */
81
82// Anti-Corruption Layer Example
83public class LegacyOrderAdapter
84{
85 private readonly ILegacyOrderSystem _legacySystem;
86
87 public async Task<Order> GetOrder(string legacyOrderId)
88 {
89 // Fetch from legacy system
90 var legacyOrder = await _legacySystem.GetOrderAsync(legacyOrderId);
91
92 // Translate to our domain model
93 return new Order
94 {
95 Id = Guid.Parse(legacyOrder.OrderNumber),
96 CustomerId = MapCustomerId(legacyOrder.CustomerCode),
97 Items = MapOrderItems(legacyOrder.LineItems),
98 Status = MapStatus(legacyOrder.StatusCode),
99 TotalAmount = Money.Create(legacyOrder.TotalAmt, "USD")
100 };
101 }
102
103 private Guid MapCustomerId(string customerCode)
104 {
105 // Translation logic
106 return _customerMapping.GetOrCreate(customerCode);
107 }
108}Tactical building blocks model domain concepts in code:
1// Tactical DDD - Implementation Patterns
2
3// 1. Entity - Has identity, mutable
4public abstract class Entity
5{
6 public Guid Id { get; protected set; }
7
8 public override bool Equals(object? obj)
9 {
10 if (obj is not Entity other) return false;
11 if (ReferenceEquals(this, other)) return true;
12 if (GetType() != other.GetType()) return false;
13 return Id == other.Id;
14 }
15
16 public override int GetHashCode() => Id.GetHashCode();
17
18 public static bool operator ==(Entity? left, Entity? right)
19 => Equals(left, right);
20
21 public static bool operator !=(Entity? left, Entity? right)
22 => !Equals(left, right);
23}
24
25// 2. Value Object - No identity, immutable
26public abstract class ValueObject
27{
28 protected abstract IEnumerable<object> GetEqualityComponents();
29
30 public override bool Equals(object? obj)
31 {
32 if (obj is not ValueObject other) return false;
33 return GetEqualityComponents()
34 .SequenceEqual(other.GetEqualityComponents());
35 }
36
37 public override int GetHashCode()
38 {
39 return GetEqualityComponents()
40 .Select(x => x?.GetHashCode() ?? 0)
41 .Aggregate((x, y) => x ^ y);
42 }
43}
44
45// Concrete Value Object
46public class Money : ValueObject
47{
48 public decimal Amount { get; }
49 public string Currency { get; }
50
51 private Money(decimal amount, string currency)
52 {
53 Amount = amount;
54 Currency = currency;
55 }
56
57 public static Money Create(decimal amount, string currency)
58 {
59 if (amount < 0)
60 throw new DomainException("Amount cannot be negative");
61 if (string.IsNullOrWhiteSpace(currency))
62 throw new DomainException("Currency is required");
63
64 return new Money(Math.Round(amount, 2), currency.ToUpper());
65 }
66
67 public Money Add(Money other)
68 {
69 if (Currency != other.Currency)
70 throw new DomainException("Cannot add different currencies");
71 return Create(Amount + other.Amount, Currency);
72 }
73
74 protected override IEnumerable<object> GetEqualityComponents()
75 {
76 yield return Amount;
77 yield return Currency;
78 }
79}
80
81// Address Value Object
82public class Address : ValueObject
83{
84 public string Street { get; }
85 public string City { get; }
86 public string State { get; }
87 public string ZipCode { get; }
88 public string Country { get; }
89
90 private Address(string street, string city, string state,
91 string zipCode, string country)
92 {
93 Street = street;
94 City = city;
95 State = state;
96 ZipCode = zipCode;
97 Country = country;
98 }
99
100 public static Address Create(string street, string city,
101 string state, string zipCode, string country)
102 {
103 // Validation
104 if (string.IsNullOrWhiteSpace(street))
105 throw new DomainException("Street is required");
106 // ... more validation
107
108 return new Address(street, city, state, zipCode, country);
109 }
110
111 protected override IEnumerable<object> GetEqualityComponents()
112 {
113 yield return Street;
114 yield return City;
115 yield return State;
116 yield return ZipCode;
117 yield return Country;
118 }
119}
120
121// 3. Domain Service - Stateless operations
122public class PricingService
123{
124 private readonly IDiscountRepository _discountRepository;
125
126 public async Task<Money> CalculatePriceAsync(
127 Product product,
128 Customer customer,
129 int quantity)
130 {
131 var basePrice = product.Price.Amount * quantity;
132
133 // Apply customer tier discount
134 var tierDiscount = customer.Tier switch
135 {
136 CustomerTier.Gold => 0.15m,
137 CustomerTier.Silver => 0.10m,
138 CustomerTier.Bronze => 0.05m,
139 _ => 0m
140 };
141
142 // Apply promotional discounts
143 var promoDiscount = await _discountRepository
144 .GetActiveDiscountAsync(product.CategoryId);
145
146 var totalDiscount = Math.Min(tierDiscount + promoDiscount, 0.30m);
147 var finalPrice = basePrice * (1 - totalDiscount);
148
149 return Money.Create(finalPrice, product.Price.Currency);
150 }
151}Aggregates enforce consistency boundaries and are accessed only through the root:
1// Aggregate - A cluster of domain objects treated as a unit
2/*
3 * Aggregate Rules:
4 * 1. External objects can only reference the Aggregate Root
5 * 2. All modifications go through the Aggregate Root
6 * 3. Aggregates are persisted/deleted as a whole
7 * 4. Keep aggregates small (prefer smaller boundaries)
8 */
9
10// Aggregate Root - The entry point to the aggregate
11public abstract class AggregateRoot : Entity
12{
13 private readonly List<IDomainEvent> _domainEvents = new();
14
15 public IReadOnlyList<IDomainEvent> DomainEvents
16 => _domainEvents.AsReadOnly();
17
18 protected void AddDomainEvent(IDomainEvent domainEvent)
19 {
20 _domainEvents.Add(domainEvent);
21 }
22
23 public void ClearDomainEvents() => _domainEvents.Clear();
24
25 public int Version { get; protected set; }
26}
27
28// Order Aggregate
29public class Order : AggregateRoot
30{
31 private readonly List<OrderItem> _items = new();
32
33 public Guid CustomerId { get; private set; }
34 public OrderStatus Status { get; private set; }
35 public Money TotalAmount { get; private set; }
36 public Address ShippingAddress { get; private set; }
37 public DateTime CreatedAt { get; private set; }
38 public DateTime? CompletedAt { get; private set; }
39
40 public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
41
42 private Order() { } // EF Core
43
44 public static Order Create(Guid customerId, Address shippingAddress)
45 {
46 var order = new Order
47 {
48 Id = Guid.NewGuid(),
49 CustomerId = customerId,
50 ShippingAddress = shippingAddress,
51 Status = OrderStatus.Draft,
52 TotalAmount = Money.Create(0, "USD"),
53 CreatedAt = DateTime.UtcNow
54 };
55
56 order.AddDomainEvent(new OrderCreatedEvent(order.Id, customerId));
57 return order;
58 }
59
60 public void AddItem(Guid productId, string productName,
61 Money unitPrice, int quantity)
62 {
63 EnsureOrderIsModifiable();
64
65 var existingItem = _items.FirstOrDefault(i => i.ProductId == productId);
66
67 if (existingItem != null)
68 {
69 existingItem.IncreaseQuantity(quantity);
70 }
71 else
72 {
73 var item = OrderItem.Create(productId, productName, unitPrice, quantity);
74 _items.Add(item);
75 }
76
77 RecalculateTotal();
78 AddDomainEvent(new OrderItemAddedEvent(Id, productId, quantity));
79 }
80
81 public void RemoveItem(Guid productId)
82 {
83 EnsureOrderIsModifiable();
84
85 var item = _items.FirstOrDefault(i => i.ProductId == productId);
86 if (item == null)
87 throw new DomainException($"Item {productId} not found");
88
89 _items.Remove(item);
90 RecalculateTotal();
91 AddDomainEvent(new OrderItemRemovedEvent(Id, productId));
92 }
93
94 public void Submit()
95 {
96 if (Status != OrderStatus.Draft)
97 throw new DomainException("Only draft orders can be submitted");
98
99 if (!_items.Any())
100 throw new DomainException("Cannot submit empty order");
101
102 Status = OrderStatus.Submitted;
103 AddDomainEvent(new OrderSubmittedEvent(Id, TotalAmount));
104 }
105
106 public void Confirm()
107 {
108 if (Status != OrderStatus.Submitted)
109 throw new DomainException("Only submitted orders can be confirmed");
110
111 Status = OrderStatus.Confirmed;
112 AddDomainEvent(new OrderConfirmedEvent(Id));
113 }
114
115 public void Ship(string trackingNumber)
116 {
117 if (Status != OrderStatus.Confirmed)
118 throw new DomainException("Only confirmed orders can be shipped");
119
120 Status = OrderStatus.Shipped;
121 AddDomainEvent(new OrderShippedEvent(Id, trackingNumber));
122 }
123
124 public void Complete()
125 {
126 if (Status != OrderStatus.Shipped)
127 throw new DomainException("Only shipped orders can be completed");
128
129 Status = OrderStatus.Completed;
130 CompletedAt = DateTime.UtcNow;
131 AddDomainEvent(new OrderCompletedEvent(Id));
132 }
133
134 public void Cancel(string reason)
135 {
136 if (Status is OrderStatus.Shipped or OrderStatus.Completed)
137 throw new DomainException("Cannot cancel shipped/completed order");
138
139 Status = OrderStatus.Cancelled;
140 AddDomainEvent(new OrderCancelledEvent(Id, reason));
141 }
142
143 private void EnsureOrderIsModifiable()
144 {
145 if (Status != OrderStatus.Draft)
146 throw new DomainException("Cannot modify non-draft order");
147 }
148
149 private void RecalculateTotal()
150 {
151 TotalAmount = _items
152 .Select(i => i.TotalPrice)
153 .Aggregate(Money.Create(0, "USD"), (a, b) => a.Add(b));
154 }
155}
156
157// Entity within the aggregate (not accessible externally)
158public class OrderItem : Entity
159{
160 public Guid ProductId { get; private set; }
161 public string ProductName { get; private set; }
162 public Money UnitPrice { get; private set; }
163 public int Quantity { get; private set; }
164 public Money TotalPrice => UnitPrice.Multiply(Quantity);
165
166 private OrderItem() { }
167
168 internal static OrderItem Create(Guid productId, string productName,
169 Money unitPrice, int quantity)
170 {
171 if (quantity <= 0)
172 throw new DomainException("Quantity must be positive");
173
174 return new OrderItem
175 {
176 Id = Guid.NewGuid(),
177 ProductId = productId,
178 ProductName = productName,
179 UnitPrice = unitPrice,
180 Quantity = quantity
181 };
182 }
183
184 internal void IncreaseQuantity(int amount)
185 {
186 if (amount <= 0)
187 throw new DomainException("Amount must be positive");
188 Quantity += amount;
189 }
190}Domain events model things that happened and enable loose coupling between bounded contexts:
1// Domain Events - Something significant that happened in the domain
2public interface IDomainEvent
3{
4 Guid EventId { get; }
5 DateTime OccurredOn { get; }
6}
7
8public abstract record DomainEvent : IDomainEvent
9{
10 public Guid EventId { get; } = Guid.NewGuid();
11 public DateTime OccurredOn { get; } = DateTime.UtcNow;
12}
13
14// Concrete Domain Events
15public record OrderCreatedEvent(
16 Guid OrderId,
17 Guid CustomerId
18) : DomainEvent;
19
20public record OrderSubmittedEvent(
21 Guid OrderId,
22 Money TotalAmount
23) : DomainEvent;
24
25public record OrderConfirmedEvent(Guid OrderId) : DomainEvent;
26
27public record OrderShippedEvent(
28 Guid OrderId,
29 string TrackingNumber
30) : DomainEvent;
31
32public record OrderCompletedEvent(Guid OrderId) : DomainEvent;
33
34public record OrderCancelledEvent(
35 Guid OrderId,
36 string Reason
37) : DomainEvent;
38
39// Domain Event Handlers
40public interface IDomainEventHandler<in TEvent> where TEvent : IDomainEvent
41{
42 Task HandleAsync(TEvent domainEvent, CancellationToken ct = default);
43}
44
45// Send confirmation email when order is submitted
46public class OrderSubmittedEmailHandler
47 : IDomainEventHandler<OrderSubmittedEvent>
48{
49 private readonly IEmailService _emailService;
50 private readonly IOrderRepository _orderRepository;
51
52 public async Task HandleAsync(
53 OrderSubmittedEvent domainEvent,
54 CancellationToken ct = default)
55 {
56 var order = await _orderRepository.GetByIdAsync(domainEvent.OrderId, ct);
57 var customer = await _orderRepository.GetCustomerAsync(order!.CustomerId, ct);
58
59 await _emailService.SendAsync(new OrderConfirmationEmail
60 {
61 To = customer!.Email,
62 OrderId = order.Id,
63 TotalAmount = domainEvent.TotalAmount,
64 Items = order.Items.ToList()
65 }, ct);
66 }
67}
68
69// Update inventory when order is confirmed
70public class OrderConfirmedInventoryHandler
71 : IDomainEventHandler<OrderConfirmedEvent>
72{
73 private readonly IInventoryService _inventoryService;
74 private readonly IOrderRepository _orderRepository;
75
76 public async Task HandleAsync(
77 OrderConfirmedEvent domainEvent,
78 CancellationToken ct = default)
79 {
80 var order = await _orderRepository.GetByIdAsync(domainEvent.OrderId, ct);
81
82 foreach (var item in order!.Items)
83 {
84 await _inventoryService.ReserveStockAsync(
85 item.ProductId,
86 item.Quantity,
87 ct);
88 }
89 }
90}
91
92// Dispatch events after saving
93public class DomainEventDispatcher
94{
95 private readonly IServiceProvider _serviceProvider;
96 private readonly ILogger<DomainEventDispatcher> _logger;
97
98 public async Task DispatchEventsAsync(
99 IEnumerable<IDomainEvent> events,
100 CancellationToken ct = default)
101 {
102 foreach (var domainEvent in events)
103 {
104 var eventType = domainEvent.GetType();
105 var handlerType = typeof(IDomainEventHandler<>)
106 .MakeGenericType(eventType);
107
108 var handlers = _serviceProvider.GetServices(handlerType);
109
110 foreach (var handler in handlers)
111 {
112 try
113 {
114 var handleMethod = handlerType.GetMethod("HandleAsync");
115 await (Task)handleMethod!.Invoke(
116 handler,
117 new object[] { domainEvent, ct })!;
118 }
119 catch (Exception ex)
120 {
121 _logger.LogError(ex,
122 "Error handling {EventType}",
123 eventType.Name);
124 }
125 }
126 }
127 }
128}Your progress is saved in this browser, on this device only.