DDD Anti-Patterns: Mistakes That Will Hurt You
Even experienced teams fall into DDD anti-patterns. This chapter catalogs the most common mistakes and how to recognize and fix them.
TL;DRβ
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Anemic Domain Model | No behavior in entities | Move logic to aggregates |
| God Aggregate | Too large, too many responsibilities | Split into smaller aggregates |
| Leaky Abstraction | Infrastructure in domain | Use interfaces, dependency inversion |
| Distributed Monolith | Tightly coupled services | Proper bounded contexts |
| DDD Everywhere | Over-engineering simple domains | Use DDD only where complexity warrants |
Anti-Pattern 1: Anemic Domain Modelβ
The Problemβ
Entities are just data containers with getters/setters. All logic lives in services.
// β Anemic - no behavior
public class Order
{
public Guid Id { get; set; }
public string Status { get; set; }
public decimal Total { get; set; }
public List<OrderLine> Lines { get; set; } = new();
}
public class OrderService
{
public void PlaceOrder(Order order)
{
// All logic here
if (order.Status != "Draft")
throw new InvalidOperationException();
if (!order.Lines.Any())
throw new InvalidOperationException();
order.Status = "Placed";
order.Total = order.Lines.Sum(l => l.Price * l.Quantity);
_repository.Save(order);
}
}
Why It's Badβ
- Business rules scattered across services
- Easy to bypass rules (direct property access)
- Duplicated validation logic
- Hard to understand domain behavior
The Fixβ
// β
Rich domain model
public class Order : AggregateRoot<OrderId>
{
public OrderStatus Status { get; private set; }
public Money Total { get; private set; }
private readonly List<OrderLine> _lines = new();
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
public void Place()
{
if (Status != OrderStatus.Draft)
throw new InvalidOrderStateException("Only draft orders can be placed");
if (!_lines.Any())
throw new CannotPlaceEmptyOrderException();
Status = OrderStatus.Placed;
AddDomainEvent(new OrderPlaced(Id, Total));
}
public void AddLine(ProductId productId, int quantity, Money price)
{
if (Status != OrderStatus.Draft)
throw new InvalidOrderStateException("Cannot modify non-draft order");
_lines.Add(new OrderLine(productId, quantity, price));
RecalculateTotal();
}
}
Anti-Pattern 2: God Aggregateβ
The Problemβ
One aggregate does everything, has too many responsibilities.
// β God aggregate
public class Customer : AggregateRoot<CustomerId>
{
public string Name { get; }
public List<Order> Orders { get; } // Could be millions
public List<Address> Addresses { get; }
public List<PaymentMethod> PaymentMethods { get; }
public List<Review> Reviews { get; }
public ShoppingCart Cart { get; }
public WishList WishList { get; }
public LoyaltyAccount Loyalty { get; }
public List<Ticket> SupportTickets { get; }
public Preferences Preferences { get; }
// 20 more collections...
public void PlaceOrder() { }
public void AddToCart() { }
public void WriteReview() { }
public void OpenTicket() { }
// 50 more methods...
}
Why It's Badβ
- Massive object to load/save
- Concurrency nightmares
- Single point of failure
- Hard to understand
- Violates SRP
The Fixβ
Split into focused aggregates:
// β
Focused aggregates
public class Customer : AggregateRoot<CustomerId>
{
public CustomerName Name { get; }
public EmailAddress Email { get; }
public Address DefaultShippingAddress { get; }
}
public class Order : AggregateRoot<OrderId>
{
public CustomerId CustomerId { get; } // Reference by ID
// Order-specific data
}
public class ShoppingCart : AggregateRoot<CartId>
{
public CustomerId CustomerId { get; }
// Cart-specific data
}
public class LoyaltyAccount : AggregateRoot<LoyaltyAccountId>
{
public CustomerId CustomerId { get; }
// Loyalty-specific data
}
Anti-Pattern 3: Repository for Everythingβ
The Problemβ
Creating repositories for non-aggregate entities.
// β Repositories for everything
public interface IOrderRepository { }
public interface IOrderLineRepository { } // Wrong!
public interface IOrderAddressRepository { } // Wrong!
public interface IOrderStatusHistoryRepository { } // Wrong!
Why It's Badβ
- Breaks aggregate boundary
- Allows inconsistent state
- Bypasses invariants
- Complicates transactions
The Fixβ
// β
One repository per aggregate root
public interface IOrderRepository
{
Task<Order?> GetById(OrderId id); // Returns complete aggregate
Task Save(Order order); // Saves entire aggregate
}
// Access children through aggregate
var order = await _orderRepository.GetById(orderId);
var line = order.Lines.FirstOrDefault(l => l.ProductId == productId);
Anti-Pattern 4: Leaky Abstractionsβ
The Problemβ
Infrastructure concerns leak into domain layer.
// β Infrastructure in domain
public class Order : AggregateRoot<OrderId>
{
[JsonProperty("order_id")] // Serialization concern
public OrderId Id { get; }
[Column("cust_id")] // Database concern
public CustomerId CustomerId { get; }
public async Task Place(IEmailService emailService) // Infrastructure dependency
{
Status = OrderStatus.Placed;
await emailService.SendConfirmation(this); // Side effect in domain
}
}
Why It's Badβ
- Domain coupled to infrastructure
- Hard to test
- Can't change infrastructure without changing domain
- Violates clean architecture
The Fixβ
// β
Clean domain
public class Order : AggregateRoot<OrderId>
{
public OrderId Id { get; }
public CustomerId CustomerId { get; }
public void Place()
{
Status = OrderStatus.Placed;
AddDomainEvent(new OrderPlaced(Id, CustomerId));
// No infrastructure dependencies
}
}
// Infrastructure handles mapping
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.Property(o => o.Id).HasColumnName("order_id");
builder.Property(o => o.CustomerId).HasColumnName("cust_id");
}
}
// Event handler sends email
public class SendOrderConfirmationHandler : IEventHandler<OrderPlaced>
{
public async Task Handle(OrderPlaced @event)
{
await _emailService.SendConfirmation(@event.OrderId);
}
}
Anti-Pattern 5: DDD Everywhereβ
The Problemβ
Applying full DDD to every part of the system, including simple CRUD.
// β Over-engineering simple settings
public class UserSettings : AggregateRoot<UserSettingsId>
{
public UserId UserId { get; }
public Theme Theme { get; private set; }
public Language Language { get; private set; }
public bool EmailNotifications { get; private set; }
public void ChangeTheme(Theme newTheme)
{
if (Theme == newTheme)
throw new ThemeAlreadySetException();
Theme = newTheme;
AddDomainEvent(new ThemeChanged(UserId, newTheme));
}
// 10 more methods for simple settings...
}
Why It's Badβ
- Unnecessary complexity
- Slower development
- Over-engineering simple problems
- Team frustration
The Fixβ
Use DDD for core domain, simple CRUD for the rest:
// β
Simple CRUD for settings (Generic subdomain)
public class UserSettingsService
{
public async Task<UserSettings> GetSettings(UserId userId)
{
return await _context.UserSettings.FindAsync(userId);
}
public async Task UpdateSettings(UserId userId, UpdateSettingsDto dto)
{
var settings = await _context.UserSettings.FindAsync(userId);
settings.Theme = dto.Theme;
settings.Language = dto.Language;
await _context.SaveChangesAsync();
}
}
// β
Full DDD for core domain (Pricing, Orders)
public class PricingEngine : AggregateRoot<PricingEngineId>
{
// Complex domain logic here
}
Anti-Pattern 6: Distributed Monolithβ
The Problemβ
Microservices that are tightly coupled, requiring synchronous calls.
// β Synchronous coupling
public class OrderService
{
public async Task PlaceOrder(PlaceOrderCommand cmd)
{
// Must call all services synchronously
var customer = await _customerService.GetCustomer(cmd.CustomerId);
var products = await _catalogService.GetProducts(cmd.ProductIds);
var prices = await _pricingService.CalculatePrices(products, customer);
var inventory = await _inventoryService.Reserve(cmd.Lines);
var payment = await _paymentService.Charge(cmd.PaymentMethod, total);
// If any service is down, order fails
var order = new Order(...);
await _orderRepository.Save(order);
}
}
Why It's Badβ
- All services must be up
- Latency compounds
- No real independence
- Distributed transactions
- Worse than monolith
The Fixβ
// β
Loosely coupled with events
public class OrderService
{
public async Task PlaceOrder(PlaceOrderCommand cmd)
{
// Use local data where possible
var order = Order.Create(cmd.CustomerId, cmd.ShippingAddress);
foreach (var line in cmd.Lines)
{
// Price captured at order time
order.AddLine(line.ProductId, line.Quantity, line.Price);
}
order.Place();
await _orderRepository.Save(order);
// Other services react to event asynchronously
// OrderPlaced β Inventory reserves stock
// OrderPlaced β Payment processes charge
// etc.
}
}
Anti-Pattern 7: Primitive Obsessionβ
The Problemβ
Using primitives instead of value objects.
// β Primitives everywhere
public class Customer
{
public Guid Id { get; set; }
public string Email { get; set; } // Any string?
public string Phone { get; set; } // Any format?
public decimal Balance { get; set; } // What currency?
public string Country { get; set; } // Valid country?
}
// Easy to make mistakes
var customer = new Customer
{
Email = "not-an-email",
Phone = "hello",
Balance = -1000,
Country = "Narnia"
};
Why It's Badβ
- No validation
- No type safety
- No behavior
- Bugs from invalid data
The Fixβ
// β
Value objects
public class Customer
{
public CustomerId Id { get; }
public EmailAddress Email { get; }
public PhoneNumber Phone { get; }
public Money Balance { get; }
public Country Country { get; }
}
// Invalid states are impossible
var email = new EmailAddress("not-an-email"); // Throws!
var money = new Money(-1000, Currency.USD); // Throws!
Anti-Pattern Detection Checklistβ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β ANTI-PATTERN DETECTION CHECKLIST β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β β‘ Are entities just data with getters/setters? β
β β Anemic Domain Model β
β β
β β‘ Does one aggregate have 10+ entity collections? β
β β God Aggregate β
β β
β β‘ Are there repositories for non-root entities? β
β β Repository Misuse β
β β
β β‘ Does domain code import infrastructure packages? β
β β Leaky Abstraction β
β β
β β‘ Is full DDD applied to simple CRUD? β
β β DDD Everywhere β
β β
β β‘ Do services make synchronous calls to many others? β
β β Distributed Monolith β
β β
β β‘ Are there lots of string/int/decimal parameters? β
β β Primitive Obsession β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Quick Reference Cardβ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β DDD ANTI-PATTERNS QUICK REFERENCE β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β ANEMIC MODEL β Move logic to aggregates β
β GOD AGGREGATE β Split into smaller aggregates β
β REPO FOR ALL β One repo per aggregate root β
β LEAKY ABSTRACTION β Use interfaces, DI β
β DDD EVERYWHERE β Apply DDD only to core domain β
β DISTRIBUTED MONOLITH β Use events, eventual consistencyβ
β PRIMITIVE OBSESSION β Use value objects β
β β
β PREVENTION β
β β’ Code reviews with DDD checklist β
β β’ Architecture tests β
β β’ Regular refactoring β
β β’ Team training β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Next Stepsβ
- Case Studies - Real examples of fixing anti-patterns
- Refactoring to DDD - How to fix these issues
- Testing DDD Code - Tests catch anti-patterns