Implementing Domain Entities With Rich Business Logic
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
andAddAuthor
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 ofint
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, usinglong
for better scalability.- Audit Fields:
CreatedAt
,UpdatedAt
,CreatedBy
, andUpdatedBy
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
, andFilePath
value objects for data integrity. - Factory Method: The
Create
method encapsulates the creation logic and raises aBookCreatedEvent
. - Business Methods:
UpdateDetails
,AddAuthor
,RemoveAuthor
,SetPdfFile
,SetImage
, andSoftDelete
encapsulate business rules and raise domain events. - Encapsulated Collections: The
_bookAuthors
and_bookKeywords
collections are private, and changes are made through theAddAuthor
andRemoveAuthor
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
- Create Common Classes
BaseEntity
abstract classValueObject
abstract classIDomainEvent
interfaceISoftDeletable
interfaceGuard
static class for validations
Day 2: Value Objects
- Implement Value Objects
Slug
with Persian/English supportTitle
with validation rulesPageCount
with range validationFilePath
with path validation
Day 3: Domain Entities
- Book Entity
- Rich Book entity with business logic
- Factory methods and business methods
- Domain events integration
- Author Entity
- Rich Author entity
- Slug generation and validation
- Business methods
Day 4: Remaining Entities & Events
- Category Entity
- Hierarchical category support
- Business logic implementation
- Domain Events
BookCreatedEvent
,BookUpdatedEvent
AuthorCreatedEvent
,AuthorUpdatedEvent
CategoryCreatedEvent
- 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:
- Domain-Driven Design by Eric Evans: The definitive guide to DDD.
- Value Objects in C#: A great explanation of value objects.
- Domain Events Pattern: Microsoft's documentation on domain events.
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!