Skip to main content

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:

  1. Authorizer checks whether the caller can execute the command
  2. Validator checks command payload correctness
  3. 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 Id for 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> (or Result<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() returns Maybe<T> (functional null handling)
  • FindMany() returns IList<T>
  • Add(), Update(), Delete() for modifications
  • UnitOfWork.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:

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.