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

Leader ●28 ●71 ●160
calendar_today ago • schedule1 min read

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
    }
}

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);

}

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;
    }
}


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;
}


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 }


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);
    }
}


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()

        .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");

    }
}


Part 7: Unit Testing with Records & Entities

public class DomainTests
{
    [Fact]
    public void VipCustomer_WithValidData_CalculatesDiscountAndTracksSpend()
    {
        var email = new EmailAddress("*Emails are not allowed*");
        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);
    }
}


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.

🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Cisco's Amy Chang: A Model's "Passport" Doesn't Tell You Where It Actually Came From

Tom Smithverified - Aug 27

Attention-Free Score: How Domain Reports Show Which Pages Need Work

ApogeeWatcherverified - Sep 1

AAIF Hits 10 Projects. Its CTO on What to Adopt Now and the Security Gaps Left Open

Tom Smithverified - Oct 23

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelski - Mar 19

Why Most .NET Developers Misuse Records — and What It’s Costing Them

Yaseer Arafat - Jul 8, 2025
chevron_left
10.5k Points • 259 Badges
Athens Greece • web-partner.gr
54Posts
199Comments
151Connections
Passionate about building robust and scalable software solutions with a focus on .NET technologies. ... Show more

Related Jobs

View all jobs →

Commenters (This Week)

3 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!