Skip to main content

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-PatternProblemSolution
Anemic Domain ModelNo behavior in entitiesMove logic to aggregates
God AggregateToo large, too many responsibilitiesSplit into smaller aggregates
Leaky AbstractionInfrastructure in domainUse interfaces, dependency inversion
Distributed MonolithTightly coupled servicesProper bounded contexts
DDD EverywhereOver-engineering simple domainsUse 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​