Skip to main content

Bounded Contexts

In PearDrop, modules represent bounded contexts - distinct areas of your business domain with clear boundaries and their own domain models.

What is a Bounded Context?

A bounded context is a boundary within which a domain model is defined and applicable. Key characteristics:

  • Explicit boundaries: Clear separation between contexts
  • Distinct domain models: Each context has its own vocabulary and models
  • Isolated data: Separate database schemas or tables
  • Autonomous: Can be developed, tested, and deployed independently
  • Communication via integration: Contexts communicate through well-defined contracts (Integration Events)

In PearDrop, one module = one bounded context.

PearDrop Module Structure

Each module follows a standard structure that enforces bounded context boundaries:

modules/{module-name}/
├── source/
│ ├── PearDrop.{ModuleName}/ # Core module (server-side)
│ │ ├── Domain/ # Domain model
│ │ │ ├── {AggregateContext}/ # Aggregate boundary
│ │ │ │ ├── AggregateRoot/ # Root entity
│ │ │ │ ├── DomainEvents/ # Internal events
│ │ │ │ ├── IntegrationEvents/ # Cross-context events
│ │ │ │ ├── EventHandlers/ # Event handlers
│ │ │ │ └── Configuration/ # EF configuration
│ │ ├── Data/ # Database context
│ │ ├── Infrastructure/ # Implementation details
│ │ └── Queries/ # Query models
│ ├── PearDrop.{ModuleName}.Client/ # Client contracts
│ │ ├── Commands/ # Command DTOs
│ │ ├── Queries/ # Query DTOs
│ │ └── Contracts/ # Public interfaces
│ └── PearDrop.{ModuleName}.Radzen/ # UI components (optional)
└── tests/ # Unit/integration tests

Framework Modules (Bounded Contexts)

PearDrop provides several framework modules representing common bounded contexts:

Authentication Module

Domain: User identity, authentication, authorization

Aggregates:

  • User - User accounts with credentials and profiles
  • Role - Roles with permissions
  • ExternalUser - External identity provider mappings

Responsibilities:

  • User registration and login
  • Password management and history
  • Role-based access control
  • External authentication (Azure AD, OAuth)

Integration Events:

  • UserRegisteredIntegrationEvent - New user created
  • UserEnabledIntegrationEvent - User account activated
  • UserDisabledIntegrationEvent - User account deactivated
  • PasswordChangedIntegrationEvent - Password updated

Files Module

Domain: Document storage and management

Aggregates:

  • Directory - Root-level file directories
  • Subdirectory - Nested folders within directories
  • File - Individual files with metadata

Responsibilities:

  • File upload and download
  • Directory hierarchy management
  • File metadata and versioning
  • Azure Blob Storage or local filesystem integration

Integration Events:

  • FilesDeletedIntegrationEvent - Files removed by user

Multitenancy Module

Domain: Tenant isolation and management

Aggregates:

  • Tenant - Tenant configuration and settings

Responsibilities:

  • Tenant creation and configuration
  • Tenant identification (host/URL-based)
  • Tenant isolation in data access

Integration Events:

  • TenantCreatedIntegrationEvent - New tenant provisioned

Application-Specific Modules

Your application defines its own bounded contexts as modules:

Example: TaskFlow Module

Domain: Task and project management

TaskFlow.App.Module/
├── Domain/
│ ├── TaskAggregate/
│ │ ├── AggregateRoot/
│ │ │ └── Task.cs
│ │ ├── DomainEvents/
│ │ │ ├── TaskCreatedDomainEvent.cs
│ │ │ └── TaskStatusChangedDomainEvent.cs
│ │ ├── IntegrationEvents/
│ │ │ └── TaskCompletedIntegrationEvent.cs
│ │ └── EventHandlers/
│ │ └── TaskCreatedEventHandler.cs
│ └── ProjectAggregate/
│ └── AggregateRoot/
│ └── Project.cs
├── Data/
│ ├── TaskFlowContext.cs # Write model DbContext
│ ├── ReadModel/
│ │ └── TaskFlowReadDbContext.cs # Read model DbContext
│ └── TaskFlowReadModels.cs # IModuleReadModels implementation
└── Queries/
└── Projections/
├── TaskProjection.cs # Read-only task model
└── ProjectProjection.cs # Read-only project model

Aggregates:

  • Task - Individual work items with status, assignments
  • Project - Collections of related tasks

Integration Events:

  • TaskCompletedIntegrationEvent - Notify other systems when tasks finish
  • ProjectArchivedIntegrationEvent - Project moved to archived state

Context Boundaries and Communication

Within a Bounded Context (Domain Events)

Use domain events for coordination within the same bounded context:

// Location: TaskFlow.App.Module/Domain/TaskAggregate/AggregateRoot/Task.cs
public class Task : Entity, IAggregateRoot
{
public ResultWithError<BluQubeErrorData> Complete(DateTime completedAt)
{
if (this.Status == TaskStatus.Completed)
{
return ResultWithError.Fail<BluQubeErrorData>(
new BluQubeErrorData(ErrorCodes.CoreValidation, "Task is already completed"));
}

this.Status = TaskStatus.Completed;
this.CompletedAt = completedAt;

// Raise domain event - same context
this.AddDomainEvent(new TaskCompletedDomainEvent(
this.Id,
this.ProjectId,
completedAt));

return ResultWithError.Ok<BluQubeErrorData>();
}
}

// Location: TaskFlow.App.Module/Domain/ProjectAggregate/EventHandlers/
public class TaskCompletedEventHandler : INotificationHandler<TaskCompletedDomainEvent>
{
public async Task Handle(TaskCompletedDomainEvent evt, CancellationToken ct)
{
// Update Project aggregate in SAME TRANSACTION
var project = await projectRepo.FindOne(...);
project.Value.UpdateTaskCount();
// SaveChanges called by command handler
}
}

Domain events characteristics:

  • Execute before SaveChanges (same transaction)
  • Can block/rollback the operation
  • Used for maintaining invariants within context
  • MediatR dispatches events synchronously

Across Bounded Contexts (Integration Events)

Use integration events for cross-context communication:

// Command handler publishes integration event
if (result.IsSuccess)
{
await integrationEventPublisher.PublishAsync(
new TaskCompletedIntegrationEvent(
task.Id,
task.Title,
timeProvider.GetUtcNow()),
cancellationToken);
}

// Subscriber in DIFFERENT module (e.g., Notifications module)
public class TaskCompletedSubscriber : ICapSubscribe
{
[CapSubscribe(TaskflowCapTopics.TaskCompleted)]
public async Task Handle(
TaskCompletedIntegrationEvent @event,
CancellationToken cancellationToken)
{
// Send email notification (separate transaction)
await emailService.SendTaskCompletedEmailAsync(@event, cancellationToken);
}
}

Integration events characteristics:

  • Published after SaveChanges succeeds
  • CAP library provides durable delivery
  • Cannot block/rollback the originating operation
  • Eventual consistency across contexts
  • Subscribers process asynchronously with automatic retries

Extracting New Bounded Contexts

As your application grows, you may identify new bounded contexts to extract:

Signs You Need Context Extraction

  • Different rates of change: Parts of your domain evolve independently
  • Different teams: Separate teams own different areas
  • Scalability needs: Parts of the system have different scaling requirements
  • Distinct domain languages: Different vocabularies for different features
  • Low coupling: Few dependencies between areas

Example: Equipment Management → Separate Module

Starting state (monolithic):

MyApp.Module/
├── Domain/
│ ├── TaskAggregate/
│ ├── ProjectAggregate/
│ └── EquipmentAggregate/ # Mixed with tasks/projects

Extracted state:

MyApp.TaskManagement/           # Task/Project context
├── Domain/
│ ├── TaskAggregate/
│ └── ProjectAggregate/

MyApp.EquipmentManagement/ # Equipment context
├── Domain/
│ └── EquipmentAggregate/

PearDrop CLI support:

# Extract Equipment context to new module
peardrop extract-context Equipment \
--from-module MyApp.Module \
--to-module MyApp.EquipmentManagement

See Bounded Context Extraction Guide for detailed extraction workflows.

Best Practices

✅ DO

  • One module per bounded context: Clear organizational boundaries
  • Use integration events for cross-context communication: Avoid direct database access
  • Define explicit contracts in .Client projects: Public API for the context
  • Separate read/write models per context: Independent DbContexts
  • Version integration events carefully: Breaking changes affect multiple contexts

❌ DON'T

  • Share aggregates across contexts: Each context owns its aggregates
  • Query other contexts' databases directly: Use integration events or dedicated query APIs
  • Create circular dependencies between contexts: Use integration events to break cycles
  • Mix business domain concerns: Keep contexts focused on a single area
  • Leak domain entities in public APIs: Use DTOs in commands/queries

Module Registration Pattern

Each module registers its services via extension methods:

// Server-side registration (Program.cs)
builder.Services.AddPearDropAuthentication(builder.Configuration);
builder.Services.AddPearDropFiles(builder.Configuration);
builder.Services.AddTaskFlowModule(builder.Configuration);

// Client-side registration (Blazor WebAssembly)
builder.Services.TryAddPearDropClient(builder.HostEnvironment.BaseAddress);
builder.Services.AddPearDropAuthentication(); // Auth client
builder.Services.AddTaskFlowModule(); // App client

Module independence:

  • Each module configures its own DbContexts
  • Each module registers its own read models
  • Each module handles its own migrations
  • Each module can be deployed as separate NuGet package

Testing Bounded Contexts

Test each bounded context independently:

// Integration test for TaskFlow context
public class TaskCommandTests : IClassFixture<TaskFlowContextFixture>
{
[Fact]
public async Task CreateTask_ShouldSucceed()
{
// Arrange - only TaskFlow dependencies
var command = new CreateTaskCommand("Test Task", projectId);

// Act - within TaskFlow context
var result = await commandRunner.RunAsync(command);

// Assert - TaskFlow invariants
Assert.True(result.IsSuccessful);
var task = await readModels.Tasks.FirstOrDefaultAsync(t => t.Id == result.Data!.TaskId);
Assert.NotNull(task);
}
}

Summary

Modules = Bounded Contexts: One-to-one mapping in PearDrop
Clear Boundaries: Each context owns domain model, database schema, business logic
Domain Events: Strong consistency within context (before SaveChanges)
Integration Events: Eventual consistency across contexts (after SaveChanges)
Independent Evolution: Contexts can change independently
CLI Support: Extract contexts as applications grow

Bounded contexts in PearDrop provide clear organizational boundaries that enable teams to work independently while maintaining system coherence through well-defined integration points.

Further Reading