Accepted
In layered architectures following DDD principles, there is often confusion about where to place different types of logic. Without clear boundaries, codebases suffer from:
- Bloated Domain Entities: Entities with infrastructure concerns (calling repositories, external services)
- Anemic Domain Models: All logic in services, entities become simple data holders
- Inconsistent Placement: Similar logic scattered between domain and application layers
- Tight Coupling: Domain entities dependent on infrastructure
- Testing Complexity: Cannot test domain logic independently of infrastructure
The application needs clear separation between:
- Workflow orchestration and coordination (application concern)
- Business rules and invariants (domain concern)
Application logic is handled in Commands/Queries and their Handlers in the Application layer.
Use Case Orchestration:
- Coordinating multiple domain operations in a workflow
- Sequencing operations (generate number → create entity → persist → map)
- Transaction boundary definition
Cross-Aggregate Coordination:
- Working with multiple repositories
- Coordinating changes across different aggregates
- Ensuring consistency across aggregate boundaries
External Service Integration:
- Calling sequence number generators
- Sending notifications/emails
- Integrating with external APIs
Infrastructure Interaction:
- Repository operations (find, insert, update, delete)
- Unit of work management
- Query specification composition
Data Transformation:
- Mapping between domain entities and DTOs
- Transforming external service responses
- Assembling response models
Pipeline Composition:
- Using Result pattern to chain operations
- Error handling and recovery
- Logging and telemetry
- Single Responsibility: Domain focuses on invariants; Application focuses on workflows
- Testability: Handlers can be unit tested with mocked dependencies
- Reusability: Domain logic reused across different use cases
- Framework Independence: Domain remains pure; Application uses bITdevKit features
- Clear Boundaries: Developers know where to add new logic
- Result Pattern Composition: Handlers use Result railway for clean error flow
- Separation of Concerns: Domain doesn't know about persistence, external services, or DTOs
- Clear separation between domain invariants and workflow orchestration
- Domain entities remain focused on business rules
- Handlers provide explicit documentation of use case workflows
- Easy to add cross-cutting concerns via pipeline behaviors
- Testable orchestration logic independent of domain
- Consistent pattern across all features in codebase
- Domain can be reused in different application contexts
- Some duplication between handlers (mitigated by base classes and Result helpers)
- Developers must understand where to place logic (learning curve)
- More classes to maintain (command + handler + validator per use case)
- Handlers follow functional composition style using Result pattern
- Each command/query represents a single use case
- Context pattern used to accumulate state across pipeline steps
-
Alternative 1: Domain Services for All Logic
- Rejected because it leads to anemic domain models with unclear responsibilities
- Domain services would need infrastructure dependencies (repositories, external services)
-
Alternative 2: Rich Domain Entities with All Logic
- Rejected because domain would need to know about infrastructure concerns
- Entities would become bloated with orchestration logic
- Violates single responsibility principle
-
Alternative 3: Application Services (Traditional Service Layer)
- Rejected in favor of CQRS-style commands/queries for better separation
- Commands/Queries provide explicit contracts and better discoverability
- ADR-0001: Clean Architecture defines layer boundaries
- ADR-0002: Result Pattern used for composing application logic
- ADR-0005: Requester executes commands/queries
- ADR-0012: Complementary decision defining domain responsibilities
- bITdevKit Application Commands & Queries
- README - Request Processing Flow
- CoreModule README - Handler Implementation
- Martin Fowler - Anemic Domain Model
public class CustomerCreateCommandHandler(
ILogger<CustomerCreateCommandHandler> logger,
IMapper mapper,
IGenericRepository<Customer> repository,
ISequenceNumberGenerator numberGenerator,
TimeProvider timeProvider)
: RequestHandlerBase<CustomerCreateCommand, CustomerModel>(logger)
{
// APPLICATION LOGIC: Orchestration
protected override async Task<Result<CustomerModel>> HandleAsync(
CustomerCreateCommand request,
SendOptions options,
CancellationToken cancellationToken) =>
await Result<CustomerModel>
// 1. Create context (application concern)
.Bind<CustomerCreateContext>(() => new(request.Model))
// 2. Inline validation (application concern)
.Ensure((ctx) => ctx.Model.FirstName != ctx.Model.LastName,
new ValidationError("Firstname cannot be same as lastname"))
// 3. Business rules (delegates to domain)
.UnlessAsync(async (ctx, ct) => await Rule
.Add(RuleSet.IsNotEmpty(ctx.Model.FirstName))
.Add(new EmailShouldBeUniqueRule(ctx.Model.Email, repository))
.CheckAsync(ct), cancellationToken: cancellationToken)
// 4. Generate sequence number (application concern - external service)
.BindResultAsync(this.GenerateSequenceAsync, this.CaptureNumber, cancellationToken)
// 5. Create aggregate (domain factory method)
.Bind(this.CreateEntity)
// 6. Persist (application concern - infrastructure)
.BindResultAsync(this.PersistEntityAsync, this.CapturePersistedEntity, cancellationToken)
.Log(logger, "Customer {Id} created", r => [r.Value.Entity.Id])
// 7. Map to DTO (application concern)
.Map(this.ToModel);
// Helper methods demonstrate application concerns
private async Task<Result<CustomerNumber>> GenerateSequenceAsync(CustomerCreateContext ctx, CancellationToken ct) =>
await numberGenerator.NextAsync(timeProvider.GetUtcNow().Year, ct);
private Result<CustomerCreateContext> CreateEntity(CustomerCreateContext ctx)
{
// Call domain factory method
var createResult = Customer.Create(ctx.Model.FirstName, ctx.Model.LastName, ctx.Model.Email, ctx.Number);
if (createResult.IsFailure)
return createResult.Unwrap();
ctx.Entity = createResult.Value;
return ctx;
}
private async Task<Result<Customer>> PersistEntityAsync(CustomerCreateContext ctx, CancellationToken ct) =>
await repository.InsertResultAsync(ctx.Entity, ct).AnyContext();
private CustomerModel ToModel(CustomerCreateContext ctx) =>
mapper.Map<Customer, CustomerModel>(ctx.Entity);
}The Context pattern accumulates state across pipeline steps:
private class CustomerCreateContext(CustomerModel model)
{
public CustomerModel Model { get; init; } = model; // Input
public CustomerNumber Number { get; set; } // From sequence generator
public Customer Entity { get; set; } // From domain factory
}This avoids nested closures and makes state flow explicit.
Queries focus on reading data and mapping:
public class CustomerFindAllQueryHandler(
IMapper mapper,
IGenericRepository<Customer> repository)
: RequestHandlerBase<CustomerFindAllQuery, IEnumerable<CustomerModel>>
{
protected override async Task<Result<IEnumerable<CustomerModel>>> HandleAsync(
CustomerFindAllQuery request,
SendOptions options,
CancellationToken cancellationToken) =>
await repository
.FindAllResultAsync(request.Filter, cancellationToken: cancellationToken)
.Map(mapper.Map<Customer, CustomerModel>);
}| Concern | Layer | Example |
|---|---|---|
| Sequence number generation | Application | numberGenerator.NextAsync() |
| Email format validation | Domain | EmailAddress.Create() |
| Aggregate creation | Domain | Customer.Create() |
| Persistence | Application | repository.InsertResultAsync() |
| DTO mapping | Application | mapper.Map<Customer, CustomerModel>() |
| Business rule checking | Domain (definition) | EmailShouldBeUniqueRule |
| Rule orchestration | Application (execution) | Rule.Add(...).CheckAsync() |
| Domain event registration | Domain | customer.DomainEvents.Register(...) |
| Cross-aggregate coordination | Application | Multiple repository calls in handler |
| Transaction boundaries | Application | Handler defines unit of work |
Update handlers demonstrate coordination:
protected override async Task<Result<CustomerModel>> HandleAsync(...)
{
return await Result<CustomerModel>
// 1. Find existing (application concern)
.BindResultAsync(async ct => await repository.FindOneResultAsync(request.Model.Id, ct))
// 2. Check concurrency (application concern)
.Ensure(entity => entity.ConcurrencyVersion.ToString() == request.Model.ConcurrencyToken,
new ConcurrencyError("Entity was modified by another user"))
// 3. Change name (domain method)
.Bind(entity => entity.ChangeName(request.Model.FirstName, request.Model.LastName))
// 4. Persist (application concern)
.BindResultAsync(async (entity, ct) => await repository.UpdateResultAsync(entity, ct))
// 5. Map (application concern)
.Map(mapper.Map<Customer, CustomerModel>);
}When creating a new handler:
- V Inherit from
RequestHandlerBase<TRequest, TResponse> - V Inject dependencies (logger, mapper, repository, domain services)
- V Use context pattern if multiple steps accumulate state
- V Validate input with
EnsureandUnless(fail fast) - V Call domain methods (factories, behavior methods)
- V Persist via repository Result methods
- V Map aggregate to DTO before returning
- V Write unit tests for happy path and error scenarios
- Command:
src/Modules/CoreModule/CoreModule.Application/Commands/CustomerCreateCommand.cs - Handler:
src/Modules/CoreModule/CoreModule.Application/Commands/CustomerCreateCommandHandler.cs - Query:
src/Modules/CoreModule/CoreModule.Application/Queries/CustomerFindAllQuery.cs - Tests:
tests/Modules/CoreModule/CoreModule.UnitTests/Application/Commands/CustomerCreateCommandHandlerTests.cs