Implementing Domain Entities With Rich Business Logic

by James Vasile 54 views

Hey guys! Let's dive deep into the world of Domain-Driven Design (DDD) and how we can transform our anemic data models into rich, robust domain entities. This article will guide you through the process of implementing domain entities with encapsulated business logic, focusing on the practical aspects and providing clear examples. This is super important for building scalable and maintainable applications, so buckle up!

Introduction to Rich Domain Entities

In traditional software development, we often start with simple data models that represent our database tables. These models, sometimes referred to as "anemic domain models," lack behavior and business logic. They are essentially data containers with properties and little else. The business logic resides in separate services, leading to a scattered and hard-to-maintain codebase. That's a headache we want to avoid, right?

Rich domain entities, on the other hand, encapsulate both data and behavior. They are objects that represent real-world concepts and contain the business logic that operates on that data. This approach leads to a more cohesive and expressive domain model, making our code easier to understand, test, and maintain. So, let's break down how we can achieve this.

Benefits of Rich Domain Entities

  • Encapsulation: Business logic is tightly coupled with the data it operates on, reducing the risk of inconsistencies.
  • Maintainability: Changes to business rules are localized within the entity, minimizing the impact on other parts of the system.
  • Testability: Entities can be tested in isolation, ensuring that the business logic behaves as expected.
  • Readability: The domain model becomes more expressive and easier to understand, reflecting the real-world domain.

Key Concepts

Before we jump into the implementation details, let's quickly recap some key concepts:

  • Entities: Objects with a unique identity that persist over time.
  • Value Objects: Immutable objects that represent a concept without a unique identity.
  • BaseEntity: A common base class for all entities, providing shared functionality like audit fields and domain event support.
  • Domain Events: Notifications that something significant has occurred within the domain.
  • Factory Methods: Static methods that encapsulate the creation logic of entities.

Current State Analysis: The Problem with Anemic Models

Let's start by examining the problem with anemic models. Imagine we have a Book class that looks like this:

public class Book
{
    public int Id { get; set; }          
    public string Title { get; set; }    
    public string Slug { get; set; }     
    public bool IsDeleted { get; set; }  
    // ... other properties with no encapsulation
}

This model is a simple data container. It lacks any business logic related to books. For example, there's no validation for the Title, no logic for generating the Slug, and no audit trail for when the book is deleted. All these responsibilities would typically be handled in separate services, leading to a scattered and hard-to-manage codebase. Yikes!

Identifying the Pain Points

  • Lack of Validation: The Title property can be any string, potentially leading to invalid data.
  • No Slug Generation: The Slug property is likely generated elsewhere, leading to duplication and potential inconsistencies.
  • No Audit Trail: The IsDeleted property doesn't provide information about when and by whom the book was deleted.
  • Missing Business Logic: There's no logic for creating, updating, or managing authors and keywords.

Target: Rich Domain Entities to the Rescue!

Now, let's look at how we can transform this anemic model into a rich domain entity. Our goal is to encapsulate the business logic within the Book class, making it responsible for its own state and behavior. Our target Book entity will look something like this:

public class Book : BaseEntity
{
    private Book() { } // Private constructor for EF
    
    public static Book Create(string title, string description, Category category)
    {
        // Business logic for creation
        // Validation rules
        // Domain events
    }
    
    public void UpdateDetails(string title, string description)
    {
        // Business rules for updates
        // Domain events
    }
    
    public void AddAuthor(Author author)
    {
        // Business rules for author addition
        // Prevent duplicates, validate constraints
    }
}

Key Improvements

  • Factory Method: The Create method encapsulates the creation logic and enforces validation rules.
  • Business Methods: The UpdateDetails and AddAuthor methods encapsulate business rules for updating the book and adding authors.
  • Domain Events: The entity raises domain events to notify other parts of the system about important changes.
  • Encapsulation: The entity's internal state is protected, and changes are made through well-defined methods.

Acceptance Criteria: Setting the Bar High

To ensure we're on the right track, let's define some acceptance criteria for our implementation:

1. BaseEntity Implementation

  • [ ] Create BaseEntity abstract class with audit fields.
  • [ ] Implement ISoftDeletable interface for soft delete pattern.
  • [ ] Add IDomainEvent support for domain events.
  • [ ] Use long instead of int for primary keys (better scalability).

2. Value Objects

  • [ ] Slug value object with validation and generation logic.
  • [ ] FilePath value object for file path validation.
  • [ ] PageCount value object with range validation.
  • [ ] Title value object with length and content validation.

3. Rich Domain Entities

Book Entity

  • [ ] Factory method Book.Create() with validation.
  • [ ] Business methods: UpdateDetails(), AddAuthor(), RemoveAuthor().
  • [ ] Domain events: BookCreated, BookUpdated, AuthorAdded.
  • [ ] Business rules: Title uniqueness per category, slug generation.
  • [ ] Encapsulated collections for authors, keywords.

Author Entity

  • [ ] Factory method Author.Create() with validation.
  • [ ] Business methods: UpdateProfile(), AddExpertise().
  • [ ] Domain events: AuthorCreated, AuthorUpdated.
  • [ ] Business rules: Slug uniqueness, full name validation.

Category Entity

  • [ ] Factory method Category.Create() with validation.
  • [ ] Business methods: UpdateDetails(), AddSubCategory().
  • [ ] Hierarchical category support.
  • [ ] Domain events: CategoryCreated, CategoryUpdated.

4. Domain Interfaces

  • [ ] IBookRepository with domain-specific methods.
  • [ ] IAuthorRepository with domain-specific methods.
  • [ ] ICategoryRepository with domain-specific methods.
  • [ ] ISlugGenerator for slug generation logic.

Implementation Details: Getting Our Hands Dirty

Let's dive into the implementation details. We'll start with the BaseEntity and then move on to value objects and domain entities.

BaseEntity Structure

The BaseEntity class provides shared functionality for all our entities. It includes properties for the primary key, audit fields, and domain event support. Think of it as the foundation upon which our entities are built.

namespace Refhub.Domain.Common;

public abstract class BaseEntity : IEquatable<BaseEntity>
{
    public long Id { get; protected set; }
    public DateTime CreatedAt { get; protected set; }
    public DateTime? UpdatedAt { get; protected set; }
    public string? CreatedBy { get; protected set; }
    public string? UpdatedBy { get; protected set; }

    private readonly List<IDomainEvent> _domainEvents = new();
    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    protected void AddDomainEvent(IDomainEvent domainEvent)
    {
        _domainEvents.Add(domainEvent);
    }

    public void ClearDomainEvents()
    {
        _domainEvents.Clear();
    }

    public bool Equals(BaseEntity? other)
    {
        return other is not null && Id == other.Id && GetType() == other.GetType();
    }

    public override bool Equals(object? obj)
    {
        return obj is BaseEntity entity && Equals(entity);
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(Id, GetType());
    }

    public static bool operator ==(BaseEntity? left, BaseEntity? right)
    {
        return Equals(left, right);
    }

    public static bool operator !=(BaseEntity? left, BaseEntity? right)
    {
        return !Equals(left, right);
    }
}

Key Features:

  • Id: The primary key, using long for better scalability.
  • Audit Fields: CreatedAt, UpdatedAt, CreatedBy, and UpdatedBy for tracking changes.
  • Domain Events: Support for raising and handling domain events.
  • Equality: Implementation of IEquatable<BaseEntity> for comparing entities.

Value Object Example: Slug

Value objects are immutable objects that represent a concept without a unique identity. They are crucial for enforcing business rules and ensuring data integrity. Let's look at the Slug value object as an example.

namespace Refhub.Domain.ValueObjects;

public sealed class Slug : ValueObject
{
    public string Value { get; }

    private Slug(string value)
    {
        Value = value;
    }

    public static Slug Create(string input)
    {
        if (string.IsNullOrWhiteSpace(input))
            throw new ArgumentException("Slug cannot be empty", nameof(input));

        var slug = GenerateSlug(input);
        return new Slug(slug);
    }

    private static string GenerateSlug(string input)
    {
        // Persian/English slug generation logic
        return input.ToLowerInvariant()
                   .Replace(" ", "-")
                   .RemoveSpecialCharacters()
                   .Trim('-');
    }

    protected override IEnumerable<object> GetEqualityComponents()
    {
        yield return Value;
    }

    public static implicit operator string(Slug slug) => slug.Value;
}

Key Features:

  • Immutability: The Value property is read-only, ensuring that the slug cannot be modified after creation.
  • Validation: The Create method validates the input and throws an exception if it's invalid.
  • Slug Generation: The GenerateSlug method encapsulates the logic for generating a slug from an input string.
  • Equality: The GetEqualityComponents method defines how to compare two slugs for equality.
  • Implicit Conversion: An implicit operator allows us to use a Slug object as a string.

Rich Book Entity Example

Now, let's look at the Book entity and how we can make it richer by encapsulating business logic and using value objects.

namespace Refhub.Domain.Entities;

public class Book : BaseEntity, ISoftDeletable
{
    private readonly List<BookAuthor> _bookAuthors = new();
    private readonly List<BookKeyword> _bookKeywords = new();

    public Title Title { get; private set; }
    public Slug Slug { get; private set; }
    public PageCount PageCount { get; private set; }
    public FilePath? PdfFilePath { get; private set; }
    public FilePath? ImagePath { get; private set; }
    public string? Description { get; private set; }
    public long CategoryId { get; private set; }
    public string? UserId { get; private set; }
    public bool IsDeleted { get; private set; }

    // Navigation properties
    public Category Category { get; private set; } = null!;
    public ApplicationUser? User { get; private set; }
    public IReadOnlyList<BookAuthor> BookAuthors => _bookAuthors.AsReadOnly();
    public IReadOnlyList<BookKeyword> BookKeywords => _bookKeywords.AsReadOnly();

    private Book() { } // EF Core constructor

    public static Book Create(
        string title,
        string description,
        long categoryId,
        int pageCount,
        string? userId = null)
    {
        var book = new Book
        {
            Title = Title.Create(title),
            Slug = Slug.Create(title),
            Description = description,
            CategoryId = categoryId,
            PageCount = PageCount.Create(pageCount),
            UserId = userId,
            CreatedAt = DateTime.UtcNow
        };

        book.AddDomainEvent(new BookCreatedEvent(book));
        return book;
    }

    public void UpdateDetails(string title, string description, long categoryId, int pageCount)
    {
        Guard.Against.NullOrEmpty(title, nameof(title));
        Guard.Against.Negative(pageCount, nameof(pageCount));

        Title = Title.Create(title);
        Slug = Slug.Create(title); // Regenerate slug
        Description = description;
        CategoryId = categoryId;
        PageCount = PageCount.Create(pageCount);
        UpdatedAt = DateTime.UtcNow;

        AddDomainEvent(new BookUpdatedEvent(this));
    }

    public void AddAuthor(long authorId)
    {
        if (_bookAuthors.Any(ba => ba.AuthorId == authorId))
            return; // Already exists

        var bookAuthor = BookAuthor.Create(Id, authorId);
        _bookAuthors.Add(bookAuthor);
        
        AddDomainEvent(new AuthorAddedToBookEvent(Id, authorId));
    }

    public void RemoveAuthor(long authorId)
    {
        var bookAuthor = _bookAuthors.FirstOrDefault(ba => ba.AuthorId == authorId);
        if (bookAuthor != null)
        {
            _bookAuthors.Remove(bookAuthor);
            AddDomainEvent(new AuthorRemovedFromBookEvent(Id, authorId));
        }
    }

    public void SetPdfFile(string filePath)
    {
        PdfFilePath = FilePath.Create(filePath);
        UpdatedAt = DateTime.UtcNow;
    }

    public void SetImage(string imagePath)
    {
        ImagePath = FilePath.Create(imagePath);
        UpdatedAt = DateTime.UtcNow;
    }

    public void SoftDelete()
    {
        IsDeleted = true;
        UpdatedAt = DateTime.UtcNow;
        AddDomainEvent(new BookDeletedEvent(this));
    }
}

Key Features:

  • Value Objects: Uses Title, Slug, PageCount, and FilePath value objects for data integrity.
  • Factory Method: The Create method encapsulates the creation logic and raises a BookCreatedEvent.
  • Business Methods: UpdateDetails, AddAuthor, RemoveAuthor, SetPdfFile, SetImage, and SoftDelete encapsulate business rules and raise domain events.
  • Encapsulated Collections: The _bookAuthors and _bookKeywords collections are private, and changes are made through the AddAuthor and RemoveAuthor methods.
  • Domain Events: Raises domain events to notify other parts of the system about important changes.
  • Soft Delete: Implements the ISoftDeletable interface for soft delete functionality.

Implementation Steps: A Day-by-Day Plan

To make the implementation process more manageable, let's break it down into a day-by-day plan.

Day 1: Base Infrastructure

  1. Create Common Classes
    • BaseEntity abstract class
    • ValueObject abstract class
    • IDomainEvent interface
    • ISoftDeletable interface
    • Guard static class for validations

Day 2: Value Objects

  1. Implement Value Objects
    • Slug with Persian/English support
    • Title with validation rules
    • PageCount with range validation
    • FilePath with path validation

Day 3: Domain Entities

  1. Book Entity
    • Rich Book entity with business logic
    • Factory methods and business methods
    • Domain events integration
  2. Author Entity
    • Rich Author entity
    • Slug generation and validation
    • Business methods

Day 4: Remaining Entities & Events

  1. Category Entity
    • Hierarchical category support
    • Business logic implementation
  2. Domain Events
    • BookCreatedEvent, BookUpdatedEvent
    • AuthorCreatedEvent, AuthorUpdatedEvent
    • CategoryCreatedEvent
  3. Domain Interfaces
    • Repository interfaces with domain methods
    • Domain service interfaces

Domain Events to Implement: Keeping the System Informed

Domain events are crucial for decoupling the domain layer from other parts of the system. They allow us to notify other components about important changes without creating direct dependencies. Let's define some domain events for our Book entity.

public record BookCreatedEvent(Book Book) : IDomainEvent;
public record BookUpdatedEvent(Book Book) : IDomainEvent;
public record BookDeletedEvent(Book Book) : IDomainEvent;
public record AuthorAddedToBookEvent(long BookId, long AuthorId) : IDomainEvent;
public record AuthorRemovedFromBookEvent(long BookId, long AuthorId) : IDomainEvent;

Key Events:

  • BookCreatedEvent: Raised when a new book is created.
  • BookUpdatedEvent: Raised when a book is updated.
  • BookDeletedEvent: Raised when a book is deleted.
  • AuthorAddedToBookEvent: Raised when an author is added to a book.
  • AuthorRemovedFromBookEvent: Raised when an author is removed from a book.

Testing Criteria: Ensuring Quality

Testing is paramount to ensure that our domain entities behave as expected and that our business rules are enforced. Let's define some testing criteria.

  • [ ] All domain entities have unit tests.
  • [ ] Business rules are tested.
  • [ ] Domain events are triggered correctly.
  • [ ] Value objects validate input correctly.
  • [ ] Factory methods create valid entities.
  • [ ] Business methods maintain invariants.

Related Tasks: Connecting the Dots

This task is part of a larger effort to build a robust domain model. It depends on the project structure being set up correctly and precedes the CQRS setup. This is how we build a solid foundation, brick by brick!

  • Depends On: #122 (Project Structure)
  • Precedes: CQRS Setup (#TBD)
  • Parallel: N/A

Resources: Level Up Your Knowledge

To deepen your understanding of DDD and rich domain entities, here are some valuable resources:

Conclusion: Embracing the Richness

Implementing domain entities with rich business logic is a crucial step towards building maintainable, scalable, and expressive applications. By encapsulating data and behavior within our entities, we create a more cohesive and understandable domain model. So, let's embrace the richness and build awesome software! You've got this!