👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
From Anemic to Rich Domain Models in .NET - Moving Logic into Entities

From Anemic to Rich Domain Models in .NET - Moving Logic into Entities

Author - Abdul Rahman (Bhai)

DDD

22 Articles

Improve

Table of Contents

  1. What we gonna do?
  2. Why we gonna do?
  3. How we gonna do?
  4. Summary

What we gonna do?

In this article, we'll explore how to transform anemic domain models into rich domain models using Domain-Driven Design principles in .NET. An anemic model is one where domain objects are mere data containers with getters and setters, while all business logic lives in external service classes.

We'll discover how to move behavior from service classes into domain entities themselves, creating objects that encapsulate both data and the operations that make sense for that data. This transformation leads to more maintainable, testable, and expressive code that better represents your business domain.

By following the "Tell, Don't Ask" principle and proper encapsulation, you'll learn to build domain models that protect their invariants, communicate business rules clearly, and reduce the coupling between domain logic and infrastructure code.

Why we gonna do?

The Anemic Model Anti-Pattern

An anemic domain model looks like object-oriented design on the surface—you have classes, properties, and methods—but it violates the fundamental principle of encapsulation. Let's examine a typical anemic model:


// Anemic License entity - just a data bag
public class License
{
    public Guid Id { get; set; }
    public string LicenseKey { get; set; }
    public DateTime IssueDate { get; set; }
    public DateTime? ExpirationDate { get; set; }
    public int ActivationCount { get; set; }
    public int MaxActivations { get; set; }
    public string Status { get; set; }  // "Active", "Suspended", "Expired"
    public List<Activation> Activations { get; set; }
}

// Anemic Activation entity
public class Activation
{
    public Guid Id { get; set; }
    public DateTime ActivatedAt { get; set; }
    public string MachineName { get; set; }
    public string HardwareId { get; set; }
}

// All logic lives in service classes
public class LicenseService
{
    private readonly ILicenseRepository _repository;
    
    public LicenseService(ILicenseRepository repository)
    {
        _repository = repository;
    }
    
    public void ActivateLicense(Guid licenseId, string machineName, string hardwareId)
    {
        // Fetch entity
        var license = _repository.GetById(licenseId);
        
        // Check if license exists
        if (license == null)
            throw new Exception("License not found");
        
        // Check if license status is valid
        if (license.Status != "Active")
            throw new Exception("License is not active");
        
        // Check if license is expired
        if (license.ExpirationDate.HasValue && 
            license.ExpirationDate.Value < DateTime.UtcNow)
            throw new Exception("License has expired");
        
        // Check if activation limit reached
        if (license.ActivationCount >= license.MaxActivations)
            throw new Exception("Maximum activations reached");
        
        // Check if already activated on this machine
        if (license.Activations.Any(a => a.HardwareId == hardwareId))
            throw new Exception("Already activated on this machine");
        
        // Create activation
        var activation = new Activation
        {
            Id = Guid.NewGuid(),
            ActivatedAt = DateTime.UtcNow,
            MachineName = machineName,
            HardwareId = hardwareId
        };
        
        // Update license
        license.Activations.Add(activation);
        license.ActivationCount++;
        
        // Save changes
        _repository.Save(license);
    }
}
      

Problems with this approach:

  • Broken Encapsulation: Anyone can modify ActivationCount without adding to Activations
  • Scattered Logic: Activation rules are spread across multiple service methods
  • Duplicated Validation: Every operation must re-check the same conditions
  • Primitive Obsession: Status as string allows invalid values like "Actve" (typo) or "Pending"
  • Violated Invariants: ActivationCount can drift from Activations.Count
  • Testing Difficulty: Must mock repositories and set up database to test business rules

Real-World Consequences

Anemic models lead to several practical problems in production systems:


// Problem 1: Business rules can be bypassed
var license = _repository.GetById(licenseId);
license.ActivationCount = 100;  // ❌ No validation!
license.Status = "Invalid Status";  // ❌ No constraints!
_repository.Save(license);

// Problem 2: Inconsistent state
license.Activations.Add(new Activation { ... });
// ❌ Forgot to increment ActivationCount!
_repository.Save(license);  // Data corruption

// Problem 3: Logic duplication
// SuspendLicense checks if license is active
// ActivateLicense checks if license is active  
// DeactivateLicense checks if license is active
// Same logic appears in 3+ places!

// Problem 4: Hard to find business rules
// Where is the "can activate" logic?
// LicenseService? ActivationService? ValidationService?
// Must search entire codebase to understand rules
      

Impact on teams:

  • New developers can't find where business logic lives
  • Changes require updating multiple service classes
  • Bugs from inconsistent state management
  • Unit tests require complex mocking infrastructure
  • Domain experts can't read the code to verify business rules

Benefits of Rich Domain Models

Rich domain models solve these problems by moving behavior into entities:

  • Encapsulation: Business rules are enforced at the object level
  • Testability: Test domain logic without infrastructure
  • Discoverability: Open an entity to see its capabilities
  • Consistency: Invariants are always maintained
  • Domain Language: Code reads like business requirements

How we gonna do?

Step 1: Identify the Domain Object

Start by identifying the core domain entity. In our example, it's a License that needs to enforce business rules around activations.

Step 2: Create Value Objects for Concepts

Replace primitive types with record value objects to enforce constraints:


// LicenseStatus value object - prevents invalid states
public sealed record LicenseStatus
{
    public string Value { get; }
    
    private LicenseStatus(string value) => Value = value;
    
    public static readonly LicenseStatus Active = new("Active");
    public static readonly LicenseStatus Suspended = new("Suspended");
    public static readonly LicenseStatus Expired = new("Expired");
    public static readonly LicenseStatus Revoked = new("Revoked");
    
    public bool IsActive => Value == "Active";
    public bool CanBeActivated => IsActive;
}

// HardwareId value object - encapsulates hardware identifier logic
public sealed record HardwareId
{
    public string Value { get; }
    
    private HardwareId(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
            throw new ArgumentException("Hardware ID cannot be empty");
        
        if (value.Length < 10)
            throw new ArgumentException("Hardware ID must be at least 10 characters");
        
        Value = value.ToUpperInvariant();
    }
    
    public static HardwareId Create(string value) => new(value);
}
      

Step 3: Make Entity Properties Private

Change public setters to private to prevent external code from bypassing business rules:


public class License
{
    // Identity
    public Guid Id { get; private set; }
    
    // Immutable attributes
    public string LicenseKey { get; private set; }
    public DateTime IssueDate { get; private set; }
    public int MaxActivations { get; private set; }
    
    // Mutable attributes (private setters)
    public DateTime? ExpirationDate { get; private set; }
    public LicenseStatus Status { get; private set; }
    
    // Encapsulated collection - expose as read-only
    private readonly List<Activation> _activations = new();
    public IReadOnlyCollection<Activation> Activations => _activations.AsReadOnly();
    
    // Computed property - no longer stored separately
    public int ActivationCount => _activations.Count;
    
    // Constructor is private - use factory methods
    private License(Guid id, string licenseKey, int maxActivations, 
        DateTime issueDate, DateTime? expirationDate)
    {
        Id = id;
        LicenseKey = licenseKey ?? throw new ArgumentNullException(nameof(licenseKey));
        MaxActivations = maxActivations;
        IssueDate = issueDate;
        ExpirationDate = expirationDate;
        Status = LicenseStatus.Active;
    }
    
    // Factory method for creating new licenses
    public static License Create(string licenseKey, int maxActivations, 
        DateTime? expirationDate)
    {
        if (maxActivations <= 0)
            throw new ArgumentException("Max activations must be positive");
        
        return new License(Guid.NewGuid(), licenseKey, maxActivations, 
            DateTime.UtcNow, expirationDate);
    }
}
      

Step 4: Move Business Logic into the Entity

Add behavior methods that encapsulate business rules. These methods should have names from the ubiquitous language:


public class License
{
    // ... properties from step 3 ...
    
    // Domain behavior: Activate on a machine
    public void Activate(string machineName, HardwareId hardwareId)
    {
        // All business rules enforced in one place
        EnsureCanBeActivated();
        EnsureNotExpired();
        EnsureActivationLimitNotReached();
        EnsureNotAlreadyActivatedOnMachine(hardwareId);
        
        // Create activation (encapsulated)
        var activation = new Activation(Guid.NewGuid(), machineName, hardwareId);
        _activations.Add(activation);
    }
    
    // Domain behavior: Deactivate from a machine
    public void Deactivate(HardwareId hardwareId)
    {
        var activation = _activations.FirstOrDefault(a => a.HardwareId.Equals(hardwareId));
        if (activation == null)
            throw new InvalidOperationException(
                $"License is not activated on machine {hardwareId}");
        
        _activations.Remove(activation);
    }
    
    // Domain behavior: Suspend license
    public void Suspend()
    {
        if (Status == LicenseStatus.Suspended)
            throw new InvalidOperationException("License is already suspended");
        
        if (Status == LicenseStatus.Revoked)
            throw new InvalidOperationException("Cannot suspend a revoked license");
        
        Status = LicenseStatus.Suspended;
    }
    
    // Domain behavior: Reactivate license
    public void Reactivate()
    {
        if (Status != LicenseStatus.Suspended)
            throw new InvalidOperationException("Only suspended licenses can be reactivated");
        
        EnsureNotExpired();
        Status = LicenseStatus.Active;
    }
    
    // Domain behavior: Extend expiration
    public void Extend(TimeSpan duration)
    {
        if (duration <= TimeSpan.Zero)
            throw new ArgumentException("Duration must be positive");
        
        var newExpiration = (ExpirationDate ?? DateTime.UtcNow).Add(duration);
        ExpirationDate = newExpiration;
        
        // If extending an expired license, reactivate it
        if (Status == LicenseStatus.Expired)
            Status = LicenseStatus.Active;
    }
    
    // Query methods
    public bool IsExpired => 
        ExpirationDate.HasValue && ExpirationDate.Value < DateTime.UtcNow;
    
    public bool CanActivate => 
        Status.CanBeActivated && !IsExpired && !HasReachedActivationLimit;
    
    public bool HasReachedActivationLimit => 
        ActivationCount >= MaxActivations;
    
    public bool IsActivatedOnMachine(HardwareId hardwareId) => 
        _activations.Any(a => a.HardwareId.Equals(hardwareId));
    
    // Private validation methods
    private void EnsureCanBeActivated()
    {
        if (!Status.CanBeActivated)
            throw new InvalidOperationException(
                $"License status is {Status}, cannot activate");
    }
    
    private void EnsureNotExpired()
    {
        if (IsExpired)
        {
            Status = LicenseStatus.Expired;
            throw new InvalidOperationException("License has expired");
        }
    }
    
    private void EnsureActivationLimitNotReached()
    {
        if (HasReachedActivationLimit)
            throw new InvalidOperationException(
                $"Maximum activations ({MaxActivations}) reached");
    }
    
    private void EnsureNotAlreadyActivatedOnMachine(HardwareId hardwareId)
    {
        if (IsActivatedOnMachine(hardwareId))
            throw new InvalidOperationException(
                $"License already activated on machine {hardwareId}");
    }
}

// Activation entity (also rich)
public class Activation
{
    public Guid Id { get; private set; }
    public DateTime ActivatedAt { get; private set; }
    public string MachineName { get; private set; }
    public HardwareId HardwareId { get; private set; }
    
    internal Activation(Guid id, string machineName, HardwareId hardwareId)
    {
        Id = id;
        MachineName = machineName ?? throw new ArgumentNullException(nameof(machineName));
        HardwareId = hardwareId ?? throw new ArgumentNullException(nameof(hardwareId));
        ActivatedAt = DateTime.UtcNow;
    }
}
      

Step 5: Simplify Service Layer

Services become thin orchestrators that delegate to domain objects:


// Rich model approach - service is thin
public class LicenseService
{
    private readonly ILicenseRepository _repository;
    
    public LicenseService(ILicenseRepository repository)
    {
        _repository = repository;
    }
    
    public void ActivateLicense(Guid licenseId, string machineName, string hardwareIdValue)
    {
        // Fetch entity
        var license = _repository.GetById(licenseId);
        if (license == null)
            throw new LicenseNotFoundException(licenseId);
        
        // Tell the entity to activate (business logic is inside the entity)
        var hardwareId = HardwareId.Create(hardwareIdValue);
        license.Activate(machineName, hardwareId);
        
        // Save changes
        _repository.Save(license);
    }
    
    public void DeactivateLicense(Guid licenseId, string hardwareIdValue)
    {
        var license = _repository.GetById(licenseId);
        if (license == null)
            throw new LicenseNotFoundException(licenseId);
        
        var hardwareId = HardwareId.Create(hardwareIdValue);
        license.Deactivate(hardwareId);
        
        _repository.Save(license);
    }
    
    public void SuspendLicense(Guid licenseId)
    {
        var license = _repository.GetById(licenseId);
        if (license == null)
            throw new LicenseNotFoundException(licenseId);
        
        license.Suspend();
        _repository.Save(license);
    }
}
      

Notice how simple the service becomes:

  • Fetch entity from repository
  • Tell entity to perform domain operation
  • Save entity back to repository

All business logic lives in the domain entity, making it easy to test and maintain.

Step 6: Test Domain Logic Independently

Rich models are easy to test without infrastructure:


[Fact]
public void Activate_WhenLicenseIsValid_ShouldSucceed()
{
    // Arrange
    var license = License.Create("ABC-123", maxActivations: 5, expirationDate: null);
    var hardwareId = HardwareId.Create("MACHINE-12345");
    
    // Act
    license.Activate("Dev Machine", hardwareId);
    
    // Assert
    Assert.Equal(1, license.ActivationCount);
    Assert.True(license.IsActivatedOnMachine(hardwareId));
}

[Fact]
public void Activate_WhenLimitReached_ShouldThrowException()
{
    // Arrange
    var license = License.Create("ABC-123", maxActivations: 2, expirationDate: null);
    license.Activate("Machine1", HardwareId.Create("HW-001"));
    license.Activate("Machine2", HardwareId.Create("HW-002"));
    
    // Act & Assert
    var ex = Assert.Throws<InvalidOperationException>(() => 
        license.Activate("Machine3", HardwareId.Create("HW-003")));
    
    Assert.Contains("Maximum activations", ex.Message);
}

[Fact]
public void Suspend_WhenLicenseIsActive_ShouldChangeTate()
{
    // Arrange
    var license = License.Create("ABC-123", maxActivations: 5, expirationDate: null);
    
    // Act
    license.Suspend();
    
    // Assert
    Assert.Equal(LicenseStatus.Suspended, license.Status);
}

// No mocking required! No database! Pure domain logic testing.
      

Comparison: Before and After


// BEFORE: Anemic Model
var license = _repository.GetById(licenseId);
if (license.Status == "Active" && 
    license.ActivationCount < license.MaxActivations)
{
    license.ActivationCount++;
    license.Activations.Add(...);
    _repository.Save(license);
}

// AFTER: Rich Model
var license = _repository.GetById(licenseId);
license.Activate(machineName, hardwareId);  // ✅ Business logic inside entity
_repository.Save(license);
      

Summary

  • Anemic domain models are data containers with all logic in service classes, violating encapsulation
  • Rich domain models encapsulate both data and behavior within domain entities
  • Move from public setters to private setters and behavior methods that enforce business rules
  • Replace primitive types with value objects to prevent invalid states and add domain meaning
  • Expose collections as IReadOnlyCollection to prevent external manipulation
  • Derive computed properties (like ActivationCount) from actual data rather than storing them separately
  • Use factory methods for entity creation to ensure all instances start in valid states
  • Service classes become thin orchestrators: fetch, tell entity to act, save
  • Rich models are testable without infrastructure—no mocks or databases needed
  • Follow "Tell, Don't Ask" principle: tell entities what to do instead of asking for data and making decisions externally
👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
  • DDD
  • Domain-Driven Design
  • DDD
  • Anemic Models
  • Rich Domain Models
  • Encapsulation
  • .NET
  • C#
  • Business Logic
  • Entity Design
  • Tell Don't Ask