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
ifstatements.
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<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");
}
}
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);
}
}
References & Further Reading
- Martin Fowler (2003): AnemicDomainModel — Read article.
- Eric Evans (2003): Domain-Driven Design: Tackling Complexity in the Heart of Software — Addison-Wesley Professional.
- Robert C. Martin (2017): Clean Architecture: A Craftsman's Guide to Software Structure and Design — Prentice Hall.
Top comments (2)
Nice approach @stevsharp
Thx, Nic!