DEV Community

Cover image for From Anemic Models to Rich Domain Models, DDD Base Entities, and C# Records
Spyros Ponaris
Spyros Ponaris

Posted on

From Anemic Models to Rich Domain Models, DDD Base Entities, and C# Records

When building enterprise applications in C#, a common architectural trap is writing procedural code disguised as object-oriented code. This usually happens when we rely entirely on anemic data bags (public get; set; DTOs) for business logic, forcing our application services to manage all validation and state changes manually.

In this guide, we will tie together Object-Oriented Programming (OOP), the Single Responsibility Principle (SRP), Domain-Driven Design (DDD) Entity base classes, C# Records for Value Objects, Combining Interfaces & Abstract Classes, EF Core Persistence, and Unit Testing into a complete, publication-ready reference.


Part 1: The Anti-Pattern (Anemic Models & DTO Misuse)

An anemic model treats classes as passive data structures with zero behavior. When every model exposes public setters, any part of the codebase can mutate state arbitrarily, bypassing business rules.

The Problematic Approach

// 1. The Anemic Entity (Just a data bag)
public class OrderDto
{
    public Guid Id { get; set; }
    public string CustomerEmail { get; set; }
    public decimal TotalAmount { get; set; }
    public string Status { get; set; } // Can be set to anything from anywhere!
}

// 2. The God Service handling all procedural logic
public class OrderService
{
    public void ProcessOrder(OrderDto order)
    {
        // Business logic scattered in services instead of living in the model
        if (order.TotalAmount <= 0) throw new Exception("Invalid amount");
        order.Status = "Processed"; // Direct mutation without invariant checks
    }
}

Enter fullscreen mode Exit fullscreen mode

Why Anemic Models Fail

  • Repeated Validation Checks: Since anemic models can't check their own rules, every service or controller writes the same validation code over and over. If a developer forgets one check, a business rule breaks silently.
  • Unprotected Data (Broken States): Public setters mean code can accidentally create broken data—like a negative order total or an incomplete order marked as completed—which then gets saved to the database.
  • Hard-to-Fix Rules ("Shotgun Surgery"): When business rules change, you have to hunt through 10 different files and controllers to update identical if statements.

Part 2: DDD Foundation — Entity Base Class & C# Records

In Domain-Driven Design, Entities are defined by their unique identity rather than their attributes, while Value Objects (idiomatically implemented using C# records) are defined entirely by their structural values and are strictly immutable.

1. DDD Entity Base Class

Notice how EqualityComparer<TId> is used below to robustly handle generic identifier comparisons across different types (Guid, int, string, etc.) while ensuring unpersisted entities do not falsely equal each other:

public abstract class Entity<TId>
{
    public TId Id { get; protected set; }

    public override bool Equals(object? obj)
    {
        if (obj is not Entity<TId> other) return false;
        if (ReferenceEquals(this, other)) return true;
        if (GetType() != other.GetType()) return false;
        if (EqualityComparer<TId>.Default.Equals(Id, default!) || 
            EqualityComparer<TId>.Default.Equals(other.Id, default!))
            return false;

        return EqualityComparer<TId>.Default.Equals(Id, other.Id);
    }

    public override int GetHashCode() => (GetType().ToString() + Id).GetHashCode();

    public static bool operator ==(Entity<TId>? a, Entity<TId>? b) => a?.Equals(b) ?? b is null;
    public static bool operator !=(Entity<TId>? a, Entity<TId>? b) => !(a == b);
}

Enter fullscreen mode Exit fullscreen mode

2. C# Records for Value Objects

Using record gives us value-based structural equality out-of-the-box and immutability by default (init setters).

public record EmailAddress
{
    public string Value { get; init; }

    public EmailAddress(string value)
    {
        if (string.IsNullOrWhiteSpace(value) || !value.Contains("@"))
            throw new ArgumentException("Invalid email format.");
        Value = value;
    }
}

Enter fullscreen mode Exit fullscreen mode

Part 3: Combining Interfaces, Abstract Classes, and DDD Entities

Now we structure our customer hierarchy by inheriting from our DDD Entity<Guid>, implementing our ICustomer interface, and using our EmailAddress value object.

// 1. The Interface defines the full contract
public interface ICustomer
{
    Guid Id { get; }
    EmailAddress Email { get; }
    decimal TotalSpent { get; }

    decimal CalculateDiscount(decimal orderAmount);
    void RecordPurchase(decimal amount);
}

// 2. The Abstract Base Class inherits from DDD Entity and implements ICustomer
public abstract class CustomerBase : Entity<Guid>, ICustomer
{
    public EmailAddress Email { get; private set; }
    public decimal TotalSpent { get; private set; }

    protected CustomerBase(Guid id, EmailAddress email)
    {
        Id = id;
        Email = email ?? throw new ArgumentNullException(nameof(email));
        TotalSpent = 0m;
    }

    public void RecordPurchase(decimal amount)
    {
        if (amount <= 0) throw new ArgumentException("Purchase amount must be positive.");
        TotalSpent += amount;
    }

    public abstract decimal CalculateDiscount(decimal orderAmount);
}

// 3. Concrete Implementations
public class RegularCustomer : CustomerBase
{
    public RegularCustomer(Guid id, EmailAddress email) : base(id, email) { }

    public override decimal CalculateDiscount(decimal orderAmount) => 0m;
}

public class VipCustomer : CustomerBase
{
    public decimal CreditLimit { get; private set; }

    public VipCustomer(Guid id, EmailAddress email, decimal creditLimit) : base(id, email)
    {
        if (creditLimit < 0) throw new ArgumentException("Credit limit cannot be negative.");
        CreditLimit = creditLimit;
    }

    public override decimal CalculateDiscount(decimal orderAmount) => orderAmount * 0.10m;
}

Enter fullscreen mode Exit fullscreen mode

Part 4: The Rich Domain Model (Order)

Our Order inherits from Entity<Guid>, enforces business invariants, and coordinates with ICustomer.

public class Order : Entity<Guid>
{
    public ICustomer Customer { get; }
    public decimal TotalAmount { get; private set; }
    public OrderStatus Status { get; private set; }

    public Order(Guid id, ICustomer customer, decimal initialAmount)
    {
        Id = id;
        Customer = customer ?? throw new ArgumentNullException(nameof(customer));

        if (initialAmount <= 0)
            throw new ArgumentException("Order amount must be greater than zero.");

        TotalAmount = initialAmount;
        Status = OrderStatus.Draft;

        ApplyCustomerDiscount();
    }

    private void ApplyCustomerDiscount()
    {
        decimal discount = Customer.CalculateDiscount(TotalAmount);
        TotalAmount -= discount;
    }

    public void AddItem(decimal itemPrice)
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Cannot modify an order after it has been submitted.");

        TotalAmount += itemPrice;
    }

    public void SubmitPayment()
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Order is already processed.");

        Status = OrderStatus.Submitted;
    }
}

public enum OrderStatus { Draft, Submitted, Completed }

Enter fullscreen mode Exit fullscreen mode

Part 5: Orchestrating with SRP and Dependency Injection

Our application service (OrderProcessor) focuses purely on coordination (following the Single Responsibility Principle).

public class OrderProcessor
{
    private readonly IOrderRepository _repository;
    private readonly IPaymentGateway _paymentGateway;
    private readonly IEmailNotifier _notifier;
    private readonly ILogger<OrderProcessor> _logger;

    public OrderProcessor(
        IOrderRepository repository,
        IPaymentGateway paymentGateway,
        IEmailNotifier notifier,
        ILogger<OrderProcessor> logger)
    {
        _repository = repository;
        _paymentGateway = paymentGateway;
        _notifier = notifier;
        _logger = logger;
    }

    public async Task ProcessAsync(Guid orderId)
    {
        var order = await _repository.GetByIdAsync(orderId);
        if (order == null) throw new KeyNotFoundException("Order not found.");

        order.SubmitPayment();

        if (!await _paymentGateway.ChargeAsync(order.TotalAmount))
        {
            throw new InvalidOperationException("Payment failed.");
        }

        order.Customer.RecordPurchase(order.TotalAmount);

        await _repository.SaveAsync(order);
        await _notifier.SendConfirmationAsync(order.Customer.Email.Value);

        _logger.LogInformation("Successfully processed order {OrderId}", order.Id);
    }
}

Enter fullscreen mode Exit fullscreen mode

Part 6: Persistence with EF Core (Mapping Records & TPH)

Using EF Core Entity configurations to map our DDD entities, value objects (records via owned types), and inheritance hierarchy.

public class AppDbContext : DbContext
{
    public DbSet<CustomerBase> Customers => Set<CustomerBase>();
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Configure Customer Inheritance (TPH)
        modelBuilder.Entity<CustomerBase>()
            .HasKey(c => c.Id);

        modelBuilder.Entity<CustomerBase>()
            .OwnsOne(c => c.Email, email =>
            {
                email.Property(e => e.Value).HasColumnName("Email").IsRequired();
            });

        modelBuilder.Entity<CustomerBase>()
            .HasDiscriminator<string>("CustomerType")
            .HasValue<RegularCustomer>("Regular")
            .HasValue<VipCustomer>("VIP");

        // Configure Order Entity
        modelBuilder.Entity<Order>()
            .HasKey(o => o.Id);

        modelBuilder.Entity<Order>()
            .HasOne(o => o.Customer)
            .WithMany()
            .HasForeignKey("CustomerId");
    }
}

Enter fullscreen mode Exit fullscreen mode

Part 7: Unit Testing with Records & Entities

public class DomainTests
{
    [Fact]
    public void VipCustomer_WithValidData_CalculatesDiscountAndTracksSpend()
    {
        var email = new EmailAddress("vip@test.com");
        ICustomer customer = new VipCustomer(Guid.NewGuid(), email, 5000.00m);
        var order = new Order(Guid.NewGuid(), customer, 200.00m);

        // 200 - 10% = 180
        Assert.Equal(180.00m, order.TotalAmount);

        customer.RecordPurchase(order.TotalAmount);
        Assert.Equal(180.00m, customer.TotalSpent);
    }
}

Enter fullscreen mode Exit fullscreen mode

References & Further Reading

  1. Martin Fowler (2003): AnemicDomainModel — Read article.
  2. Eric Evans (2003): Domain-Driven Design: Tackling Complexity in the Heart of Software — Addison-Wesley Professional.
  3. Robert C. Martin (2017): Clean Architecture: A Craftsman's Guide to Software Structure and Design — Prentice Hall.

Top comments (2)

Collapse
 
__b63657 profile image
nikosst • • Edited

Nice approach @stevsharp

Collapse
 
stevsharp profile image
Spyros Ponaris •

Thx, Nic!