The TaskHub.Shared.Domain module provides the foundational building blocks for implementing a rich, observable domain model following the principles of Domain-Driven Design (DDD). It enforces strong encapsulation, invariant protection, and a reliable event-driven architecture.
The AggregateBase<TId> class is the heart of the domain. It manages more than just an identifier:
private set properties, forcing all state mutations to go through public methods that validate business rules (Invariants).protected readonly List<IDomainEvent> MyEvents. When a business rule is executed, the aggregate “records” what happened by calling AddEvent().UnitOfWork during the persistence phase, ensuring that business data and event records are saved in one atomic database transaction.TaskHub uses a specialized exception hierarchy to distinguish between Expected Business Rule Violations and Unexpected Technical Failures:
DomainException: The base class for all business errors. When thrown, the BasicHostBuilder global exception handler catches it and converts it into a Result object with an appropriate error code, preventing 500 internal server errors for known business rules.DomainErrorBase: A record-based pattern for defining structured error details that can be localized and sent to the client.AggregateBase<TId>| Member | Type | Description |
| :— | :— | :— |
| Id | TId | The unique, strongly-typed identity of the aggregate. |
| Events | IReadOnlyCollection<IDomainEvent> | Public access to captured events (for the persistence layer). |
| AddEvent(IDomainEvent event) | void | Captures a new domain event. |
| ClearEvents() | void | Resets the event list (called by UnitOfWork after save). |
IDomainEvent| Property | Type | Description |
| :— | :— | :— |
| CreatedAt | DateTime | Timestamp of when the event occurred in UTC. |
using TaskHub.Shared.Domain.Aggregates;
using TaskHub.Shared.Domain.Events;
using TaskHub.Shared.Domain.Exceptions;
public class Project : AggregateBase<Guid>
{
private readonly List<ProjectTask> _tasks = new();
public Project(Guid id, string name, Guid ownerId) : base(id)
{
if (string.IsNullOrWhiteSpace(name)) throw new DomainException("Project name cannot be empty.");
Name = name;
OwnerId = ownerId;
Status = ProjectStatus.Active;
AddEvent(new ProjectCreatedDomainEvent(id, name, ownerId));
}
public string Name { get; private set; }
public Guid OwnerId { get; private set; }
public ProjectStatus Status { get; private set; }
public IReadOnlyCollection<ProjectTask> Tasks => _tasks.AsReadOnly();
public void AddTask(string title, string description)
{
if (Status != ProjectStatus.Active)
throw new DomainException("Cannot add tasks to an inactive project.");
if (_tasks.Any(t => t.Title == title))
throw new DomainException($"Task with title '{title}' already exists.");
var task = new ProjectTask(Guid.NewGuid(), title, description);
_tasks.Add(task);
AddEvent(new TaskAddedToProjectDomainEvent(Id, task.Id, title));
}
public void Complete()
{
if (_tasks.Any(t => !t.IsCompleted))
throw new DomainException("Cannot complete project while tasks are pending.");
Status = ProjectStatus.Completed;
AddEvent(new ProjectCompletedDomainEvent(Id));
}
}
The domain model is designed to be mapped seamlessly to EF Core. Use IEntityTypeConfiguration to map the private fields and collections.
public class ProjectConfiguration : IEntityTypeConfiguration<Project>
{
public void Configure(EntityTypeBuilder<Project> builder)
{
builder.HasKey(x => x.Id);
// Map the private list to a backing field
builder.HasMany(x => x.Tasks)
.WithOne()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade);
var navigation = builder.Metadata.FindNavigation(nameof(Project.Tasks));
navigation?.SetPropertyAccessMode(PropertyAccessMode.Field);
}
}
When events are dispatched via the IEventsBus (integrated with the Outbox), the system automatically adds:
event.type: The full name of the event class.aggregate.id: The ID of the aggregate that produced the event.DateTime.UtcNow.Id. Use navigation properties only for entities within the same aggregate boundary.public record to ensure they cannot be modified after creation.