Accepted
When building enterprise applications, maintaining long-term maintainability and testability requires a clear architectural structure. Traditional layered architectures often suffer from:
- Tight coupling between business logic and infrastructure concerns (databases, frameworks, external services)
- Difficulty testing core business logic without standing up infrastructure
- Framework lock-in where changing a framework requires rewriting business logic
- Unclear dependencies leading to circular references and tangled codebases
- Fragile architecture that degrades over time as boundaries erode
The application needed an architecture that:
- Protects core business logic from infrastructure changes
- Enables independent testing of business rules
- Makes dependency directions explicit and enforceable
- Supports long-term maintainability as the codebase grows
- Allows infrastructure technology changes without rewriting business logic
Adopt Clean/Onion Architecture with strictly enforced layer boundaries and inward-pointing dependencies.
-
Domain Layer (innermost): Pure business logic
- Aggregates, Entities (e.g.,
Customer) - Value Objects (e.g.,
EmailAddress,CustomerNumber) - Domain Events (e.g.,
CustomerCreatedDomainEvent) - Business Rules (e.g.,
EmailShouldBeUniqueRule) - Enumerations (e.g.,
CustomerStatus) - Dependencies: None (only bITdevKit domain abstractions)
- Aggregates, Entities (e.g.,
-
Application Layer: Use case orchestration
- Commands & Queries (e.g.,
CustomerCreateCommand) - Request/Response Handlers
- DTOs (e.g.,
CustomerModel) - Specifications for queries
- Dependencies: Domain layer only
- Commands & Queries (e.g.,
-
Infrastructure Layer: Technical implementation
- DbContext and EF Core configurations
- Repository implementations
- External service integrations
- Migrations
- Dependencies: Domain and Application (implements their abstractions)
-
Presentation Layer: User/API interface
- Minimal API endpoints
- Module registration
- Mapping profiles
- Dependencies: Application layer (through IRequester/INotifier)
Dependencies point inward only. Inner layers must never depend on outer layers.
- Domain → None
- Application → Domain
- Infrastructure → Domain + Application
- Presentation → Application
- Persistence Ignorance: Domain logic doesn't know about databases, allowing database technology changes without domain rewrites
- Testability: Domain and Application can be tested independently of infrastructure
- Framework Independence: Business logic isn't coupled to ASP.NET, EF Core, or any framework
- Enforceability: Architecture boundaries are validated by automated architecture tests
- Team Scalability: Clear rules prevent confusion about where to place code
- Long-term Maintainability: Architecture doesn't degrade because violations are caught early
- Technology Agnostic Core: Domain can be reused in different contexts (web, console, microservices)
- Domain logic is completely isolated and reusable across different delivery mechanisms
- Infrastructure can be replaced without touching business logic (e.g., switch from SQL Server to PostgreSQL)
- All layers can be tested independently with appropriate test doubles
- Clear separation of concerns makes codebase easier to understand and navigate
- Architecture boundaries are enforced by
ArchitectureTests.cspreventing violations at build time - New developers can quickly understand where to place new code
- More projects/folders to manage (4 projects per module: Domain, Application, Infrastructure, Presentation)
- Indirection through abstractions adds some complexity (repository interfaces, etc.)
- Learning curve for developers unfamiliar with Clean Architecture principles
- Initial setup overhead when creating new modules
- Requires discipline to maintain boundaries (mitigated by automated tests)
- Application layer acts as orchestration coordinator between domain and infrastructure
- Each module follows the same layering pattern for consistency
-
Alternative 1: Traditional N-Tier Architecture (UI → Business Logic → Data Access)
- Rejected because it often leads to tight coupling between business logic and data access layer
- Business logic layer typically has direct references to ORM entities and database concerns
-
Alternative 2: Anemic Domain Model with Service Layer
- Rejected because it pushes all logic into services, creating procedural rather than object-oriented code
- Domain entities become simple data containers with no behavior
-
Alternative 3: Vertical Slice Architecture (no layering)
- Rejected at the architectural level (though modules are vertical slices)
- Still need layering within each module to separate concerns appropriately
- ADR-0003: Modular Monolith - defines how modules are organized
- ADR-0011: Application logic placement
- ADR-0012: Domain logic placement
- Robert C. Martin - Clean Architecture
- Jeffrey Palermo - Onion Architecture
- README - Clean Architecture Overview
- README - Layer Responsibilities
- CoreModule README - Architecture
Architecture boundaries are enforced via ArchitectureTests.cs in unit tests:
[Fact]
public void Domain_Should_Not_HaveDependencyOnOtherLayers()
{
var result = Types.InAssembly(DomainAssembly)
.Should().NotHaveDependencyOn("Application")
.And().NotHaveDependencyOn("Infrastructure")
.And().NotHaveDependencyOn("Presentation")
.GetResult();
result.IsSuccessful.Should().BeTrue();
}CoreModule/
├── CoreModule.Domain/ # No dependencies on other layers
├── CoreModule.Application/ # References: Domain
├── CoreModule.Infrastructure/ # References: Domain, Application
└── CoreModule.Presentation/ # References: Application
- Domain logic:
src/Modules/CoreModule/CoreModule.Domain/ - Application logic:
src/Modules/CoreModule/CoreModule.Application/ - Infrastructure:
src/Modules/CoreModule/CoreModule.Infrastructure/ - Presentation:
src/Modules/CoreModule/CoreModule.Presentation/ - Architecture tests:
tests/Modules/CoreModule/CoreModule.UnitTests/ArchitectureTests.cs