Domain-Driven Design in PearDrop
PearDrop applies tactical Domain-Driven Design (DDD) patterns to create maintainable, scalable modular applications. This section explains how PearDrop implements DDD concepts at both the strategic and tactical levels.
What is Domain-Driven Design?
Domain-Driven Design is an approach to software development that focuses on:
- Business domains as the primary organizing principle
- Ubiquitous language shared between developers and domain experts
- Bounded contexts to define clear boundaries between subsystems
- Tactical patterns (Aggregates, Entities, Value Objects) to model complex domains
PearDrop provides framework support for DDD patterns, making it easier to build applications that model your business domain accurately.
Command Pipeline: Authorizers, Validators, Handlers
Write-side command execution follows a consistent pipeline:
- Authorizer checks whether the caller can execute the command
- Validator checks command payload correctness
- Handler executes domain logic and persists changes
See Command Validators and Authorizers for full patterns and examples.
Strategic DDD: Modules as Bounded Contexts
In PearDrop, modules represent bounded contexts - distinct areas of your business domain with clear boundaries:
- Auth Module: Identity, authentication, authorization (Users, Roles, Permissions)
- Files Module: Document storage and management (Directories, Files, Metadata)
- Multitenancy Module: Tenant isolation and management (Tenants, Tenant Configuration)
- Your Application Module: Your specific business domain
Each module owns its domain model, database schema, and business logic. Modules communicate through Integration Events (cross-context communication) rather than direct database access.
See Bounded Contexts for details on how modules define and enforce boundaries.
Next Steps
→ Learn about Read Models for optimizing your queries on the read side.
Tactical DDD: Aggregates and Entities
PearDrop provides base classes and interfaces for tactical DDD patterns:
Entity Base Class
All domain objects inherit from Entity:
public class Equipment : Entity, IAggregateRoot
{
public string Name { get; private set; }
public string Category { get; private set; }
public EquipmentStatus Status { get; private set; }
// Business logic methods
public ResultWithError<BluQubeErrorData> UpdateStatus(EquipmentStatus newStatus, DateTime timestamp)
{
if (this.Status == EquipmentStatus.Retired)
{
return ResultWithError.Fail<BluQubeErrorData>(
new BluQubeErrorData(ErrorCodes.CoreValidation, "Cannot update retired equipment"));
}
if (this.Status == newStatus)
{
return ResultWithError.Ok<BluQubeErrorData>();
}
this.Status = newStatus;
this.LastModified = timestamp;
// Raise domain event for coordination
this.AddDomainEvent(new EquipmentStatusChangedDomainEvent(
this.Id,
this.Status,
timestamp));
return ResultWithError.Ok<BluQubeErrorData>();
}
}
Entity capabilities:
- Identity: Unique
Guid Idfor all entities - Equality: Value-based equality using identity
- Domain Events: Built-in support for raising events
- EF Core integration: Works seamlessly with EF Core change tracking
Aggregate Roots
The IAggregateRoot marker interface designates consistency boundaries:
public class Equipment : Entity, IAggregateRoot
{
// Equipment is the aggregate root
// All access to its child entities goes through Equipment
}
Aggregate rules in PearDrop:
- One repository per aggregate root
- Aggregate mutation methods usually return
ResultWithError<BluQubeErrorData>(orResult<TValue, BluQubeErrorData>when returning data) - Changes to the aggregate graph are atomic (SaveChanges is all-or-nothing)
- External access only through the root (child entities are private/protected)
- Cross-aggregate references use IDs, not object references
See Aggregates for detailed patterns and examples.
Repository Pattern
PearDrop uses a generic repository per aggregate root:
// Inject repository factory for an aggregate
public MyCommandHandler(
IRepositoryFactory<Equipment> repositoryFactory)
{
this.repositoryFactory = repositoryFactory;
}
// In command handler
protected override async Task<CommandResult> HandleInternalWithRepository(...)
{
// Use this.Repository for primary aggregate
var equipmentMaybe = await this.Repository.FindOne(
new ByIdSpecification<Equipment>(equipmentId),
cancellationToken);
if (equipmentMaybe.HasNoValue)
{
return CommandResult.Failed(
new BluQubeErrorData(ErrorCodes.NotFound, "Equipment not found"));
}
var equipment = equipmentMaybe.Value;
var updateResult = equipment.UpdateStatus(newStatus, timeProvider.GetUtcNow());
if (updateResult.IsFailure)
{
return CommandResult.Failed(updateResult.Error!);
}
// Save changes (dispatches domain events first)
var result = await this.Repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
return result.IsSuccess
? CommandResult.Succeeded()
: CommandResult.Failed(result.Error!);
}
Key repository features:
FindOne()returnsMaybe<T>(functional null handling)FindMany()returnsIList<T>Add(),Update(),Delete()for modificationsUnitOfWork.SaveEntitiesAsync()commits transaction- Specification pattern for queries
CQRS: Separating Reads from Writes
PearDrop enforces Command-Query Responsibility Segregation (CQRS):
Write Side (Commands)
Commands modify state using domain aggregates:
// Command handler uses Repository<EquipmentAggregate>
var equipment = await this.Repository.FindOne(...);
equipment.Value.UpdateStatus(newStatus, timestamp);
await this.Repository.UnitOfWork.SaveEntitiesAsync();
Read Side (Queries)
Queries use read models (projections) optimized for queries:
// Query handler uses IModuleReadModels (no direct DbContext access)
public class GetEquipmentQueryHandler(IMyReadModels readModels)
{
public async Task<QueryResult<EquipmentDto>> Handle(...)
{
var equipment = await readModels.Equipment
.Where(e => e.Id == id)
.Select(e => new EquipmentDto(e.Id, e.Name, e.Category))
.FirstOrDefaultAsync(cancellationToken);
return equipment != null
? QueryResult<EquipmentDto>.Succeeded(equipment)
: QueryResult<EquipmentDto>.Failed();
}
}
Benefits:
- Domain aggregates enforce business rules (write side)
- Read models bypass business logic for fast queries (read side)
- Separate optimization strategies for each side
- DbContext never exposed to query handlers
See CQRS Pattern and Read Models for implementation details.
Domain Events vs Integration Events
PearDrop supports two event types with different guarantees:
Domain Events (Strong Consistency)
Execute before SaveChanges in the same transaction:
// Aggregate raises event
equipment.AddDomainEvent(new EquipmentCreatedDomainEvent(equipment.Id, ...));
// Handler coordinates with other aggregate
public class EquipmentCreatedEventHandler : INotificationHandler<EquipmentCreatedDomainEvent>
{
public async Task Handle(EquipmentCreatedDomainEvent evt, CancellationToken ct)
{
// Load another aggregate
var inventory = await inventoryRepo.FindOne(...);
inventory.Value.AddEquipment(evt.EquipmentId);
// Changes saved by command's SaveChanges - same transaction
// If this fails, equipment creation rolls back
}
}
Use domain events:
- When coordinating aggregates within the same bounded context
- When failure should block/rollback the operation
- When maintaining invariants that require strong consistency
See Domain Events for implementation patterns.
Integration Events (Eventual Consistency)
Execute after SaveChanges using CAP library:
// Command handler publishes after successful save
if (result.IsSuccess)
{
await integrationEventPublisher.PublishAsync(
new EquipmentCreatedIntegrationEvent(equipment.Id, ...),
cancellationToken);
}
// Subscriber in another module
public class EquipmentCreatedSubscriber : ICapSubscribe
{
[CapSubscribe(MymoduleCapTopics.EquipmentCreated)]
public async Task Handle(
EquipmentCreatedIntegrationEvent @event,
CancellationToken cancellationToken)
{
// Process in separate transaction
// Retries handled automatically by CAP
}
}
Use integration events:
- Cross-module communication
- External notifications (email, webhooks)
- Operations that shouldn't block the user's request
- Eventual consistency is acceptable
See Integration Events for CAP patterns and best practices.
PearDrop DDD Tooling
The PearDrop CLI generates DDD components following framework patterns:
# Generate aggregate with Entity + IAggregateRoot + repository registration
peardrop add aggregate Equipment --properties "Name:string,Category:string"
# Generate domain event and handler
peardrop add domain-event EquipmentCreated --aggregate Equipment
# Generate integration event with CAP subscriber
peardrop add integration-event EquipmentCreated --aggregate Equipment
# Generate command with aggregate access
peardrop add command UpdateEquipmentStatus --aggregate Equipment
# Generate query with read model access
peardrop add query GetEquipmentById --aggregate Equipment
All generated code follows PearDrop conventions:
- Aggregates:
Domain/{Name}Aggregate/AggregateRoot/ - Domain Events:
Domain/{Name}Aggregate/DomainEvents/ - Integration Events:
Domain/{Name}Aggregate/IntegrationEvents/ - Read Models:
Queries/Projections/ - Command/Query Handlers: Generated with correct dependencies
Further Reading
This overview introduces PearDrop's DDD approach. Explore specific topics:
- Bounded Contexts - Modules as bounded contexts, module boundaries
- Aggregates - Entity base class, IAggregateRoot, aggregate design rules
- CQRS Pattern - Commands, queries, command handlers
- Read Models - IProjection, IModuleReadModels, separation from write models
- Domain Events - Transactional events for aggregate coordination
- Integration Events - CAP library, cross-module communication
Key Takeaways
✅ Modules = Bounded Contexts: Clear boundaries with distinct domain models
✅ Entity Base Class: All domain objects inherit from Entity
✅ IAggregateRoot: Marks consistency boundaries, one repository per root
✅ Repository Pattern: Generic repositories with specification pattern
✅ CQRS: Commands use aggregates, queries use read models
✅ Domain Events: Strong consistency within bounded context
✅ Integration Events: Eventual consistency across modules
✅ CLI Support: Generate DDD components following framework patterns
PearDrop's DDD support helps you build applications that accurately model your business domain while maintaining flexibility and testability.