Table of Contents
- Introduction
- Architecture Overview
- Achieving Reliability
- Team Collaboration & Growth
- Strengthening Teams Through Simplicity
- Adapting to Change
- Looking Ahead
- Conclusion
1. Introduction
1.1. What Is Clean Architecture & Why It Matters
Clean Architecture splits application code into distinct layers. Its main goal is separation of concerns, simplicity, and adaptability. This lets us change or add components without breaking existing logic.
Key Points:
- Easier Maintenance: Clearly separated code is simpler to test, debug, and update.
- Flexibility: Not tied to a specific framework or database.
- Long-Term Viability: Scalable and easier to maintain as the project grows.
In .NET, it means isolating business logic (Core) from implementations (Infrastructure, API, etc.), boosting reliability and ease of change.
1.2. Our Commitment to Quality & Simplicity
Clean Architecture isn’t just a buzzword. We focus on:
- Quality: Transparent code structure minimizes errors and duplication.
- Simplicity: Less noise means faster feature delivery and maintenance.
We invest in processes like ADRs to document decisions and help everyone on the team understand the architecture and its evolution.
2. Architecture Overview
2.1. Project Structure Overview
Solution/
├── src/
│ ├── API // ASP.NET Core Web API: Controllers, middleware, routing, configuration.
│ ├── Core // Business logic, use cases, interfaces, dependency injection registration.
│ ├── Infrastructure // EF Core DbContext, repository implementations, external integrations, caching, dependency injection registration.
│ ├── Consumers // Message consumers (e.g., using Kafka/RabbitMQ clients).
│ ├── Jobs // Background job processing (using Hangfire or similar).
│ ├── Contracts // Domain models, Shared DTOs and API contracts.
│ ├── Data.Migration // Console app for running database migrations.
│ └── Tests // Tests
│ ├── Tests.Unit // Unit tests for Core and isolated components.
│ └── Tests.Integration // Integration tests using Docker/Testcontainers.
Our .NET ecosystem is split into several projects with defined roles:
-
API
- Handles HTTP requests/responses.
- Focuses on controllers and middleware for security, routing, and input validation.
- Uses Core for business logic and Infrastructure for data access.
-
Core
- Contains business logic (Services, UseCases), rules (Validators), and interfaces.
- Framework-agnostic to avoid binding logic to specific implementations.
-
Infrastructure
- Implements data access, external service integration, etc.
- Provides concrete implementations of Core interfaces.
-
Consumers
- Processes messages from external queues (e.g., Kafka, RabbitMQ).
- Leverages Core for business logic and Infrastructure for data operations.
-
Jobs
- Manages background tasks (using tools like Hangfire).
- Uses Core for rules and Infrastructure for data access.
-
Tests
- Unit Tests: Isolated testing of Core components with mocks.
- Integration Tests: Verify interactions between API, Core, and Infrastructure, often using Docker Compose.
-
Data Migrations
- Contains scripts and migrations to update the database schema.
-
Contracts
- Defines shared DTOs, models, and controller interfaces.
- Provides universal descriptions for communication across services.
Dependency Diagram
PlantUml
@startuml CleanArchitectureDependencies
allowmixing
!define ProjectColor LightSteelBlue
!define FolderColor LightGray
!define InterfaceColor LightGoldenRodYellow
!define ClassColor PaleGreen
skinparam package {
BackgroundColor ProjectColor
BorderColor Black
}
skinparam class {
BackgroundColor FolderColor
BorderColor Black
ArrowColor Black
}
package "API Project" as Api {
folder "Controllers" as Api.Controllers {
class Controller {
}
}
}
package "Contracts Project" as Contracts {
folder "DTOs" as Contracts.DTOs {
class DTOs
}
folder "Models" as Contracts.Models {
class Models
}
folder "Controller Interfaces" as Contracts.ControllerInterfaces {
interface IController
}
folder "Clients" as Contracts.Clients {
interface IApiClient
}
folder "Events" as Contracts.Events {
class Event
}
}
package "Core Project" as Core {
folder "UseCases" as Core.UseCases {
class UseCase
}
folder "Services" as Core.Services {
class Service
}
folder "Interfaces" as Core.Interfaces {
interface IRepository
interface IExternalServiceClient
}
folder "Validators" as Core.Validators {
class Validator
}
}
package "Infrastructure Project" as Infrastructure {
folder "Repositories" as Infrastructure.Repositories {
class Repository {
+DbContext
}
}
folder "Entities" as Infrastructure.Entities {
class Entity
}
folder "External Service Clients" as Infrastructure.ExternalServiceClients {
class ExternalServiceClient {
+HttpClient
}
}
folder "DI" as Infrastructure.DI {
class InfrastructureDI
}
}
package "Consumers Project" as Consumers {
folder "Event Processing" as Consumers.EventProcessing {
class EventProcessor
}
}
package "Jobs Project" as Jobs {
folder "Jobs" as Jobs.Jobs {
class Job
}
}
package "Data Migrations Project" as DataMigrations {
folder "Migrations" as DataMigrations.Migrations {
class Migration
}
}
package "Tests" as Tests {
package "Unit Tests Project" as Tests.Unit {
folder "Unit Tests" as Tests.Unit.UnitTests {
class UnitTest
}
}
package "Integration Tests Project" as Tests.Integration {
folder "Integration Tests" as Tests.Integration.IntegrationTests {
class IntegrationTest
}
}
}
database "Database" as DB
' Dependencies arrows
Api.Controllers.Controller -u-> Contracts.ControllerInterfaces.IController : Implements
Api.Controllers.Controller --> Core.UseCases.UseCase : Uses
Api.Controllers.Controller -d-> Contracts.Events.Event : Uses
Api --> Infrastructure.DI.InfrastructureDI : Uses
Api -r-> Contracts : Uses
Contracts.ControllerInterfaces.IController -d-> Contracts.DTOs.DTOs : Uses
Contracts.Clients.IApiClient -u-> Contracts.ControllerInterfaces.IController : Implements
Core.UseCases.UseCase --> Core.Services.Service : Uses
Core.UseCases.UseCase --> Core.Interfaces.IRepository : Uses
Core.UseCases.UseCase --> Core.Interfaces.IExternalServiceClient : Uses
Core.UseCases.UseCase -r-> Contracts.DTOs.DTOs : Uses
Core.Services.Service --> Core.Interfaces.IRepository : Uses
Core.Services.Service -u-> Core.Validators.Validator : Uses
Core.Services.Service -r-> Contracts.Models.Models : Uses
Infrastructure.Repositories.Repository -u-> Core.Interfaces.IRepository : Implements
Infrastructure.Repositories.Repository --> Infrastructure.Entities.Entity : Uses
Infrastructure.Repositories.Repository -d-> DB : Uses
Infrastructure.ExternalServiceClients.ExternalServiceClient -u-> Core.Interfaces.IExternalServiceClient : Implements
Infrastructure.ExternalServiceClients.ExternalServiceClient -r-> Contracts.Clients.IApiClient : Uses
Infrastructure.DI.InfrastructureDI --> Core.Interfaces.IRepository : Registers
Infrastructure.DI.InfrastructureDI --> Core.Interfaces.IExternalServiceClient : Registers
Consumers.EventProcessing.EventProcessor -u-> Contracts.Events.Event : Uses
Consumers.EventProcessing.EventProcessor -d-> Contracts.Clients.IApiClient : Uses
Jobs.Jobs.Job --> Core.UseCases.UseCase : Uses
DataMigrations.Migrations.Migration --> Infrastructure.Repositories.Repository : Uses
Tests.Unit.UnitTests.UnitTest --> Core : Tests
Tests.Integration.IntegrationTests.IntegrationTest -r-> Api : Tests
Tests.Integration.IntegrationTests.IntegrationTest --> Core : Tests
Tests.Integration.IntegrationTests.IntegrationTest -l-> Infrastructure : Tests
' Styling
hide empty members
@enduml
2.2. Principles: Separation of Concerns, Clear Boundaries, Scalability
-
Separation of Concerns (SoC)
- Each class or module has a single responsibility.
- Controllers don’t contain business logic; they simply forward data to Use Cases.
-
Clear Boundaries
- Layers interact only through well-defined interfaces.
- Core relies on abstractions, not on specific implementations.
- Infrastructure handles external communication without business logic.
-
Scalability
- Modular design lets us scale API, Consumers, or Jobs independently.
- Facilitates refactoring and task distribution across teams.
3. Achieving Reliability
3.1. Flexible Testing Strategy
We cover all application layers:
-
Unit Tests
- Test individual Core classes and methods with mocks.
- Fast feedback and isolated error detection.
-
Integration Tests
- Verify interactions among API, Core, and Infrastructure.
- Use Testcontainers (PostgreSQL) with Docker Compose for real-environment emulation.
-
End-to-End Tests (E2E)
- Simulate the full system flow from HTTP request to DB write.
- Run tests in Docker Compose, including external services, for full-cycle validation.
Benefits:
- Unit tests catch logic errors quickly.
- Integration/E2E tests prevent surprises with real services.
- All tests run in CI/CD to catch regressions early.
- Use retry policies (Polly) to handle unstable environments.
- Scripts like
dotnet test
and ReportGenerator simplify test execution and analysis.
3.2. Automated Migrations & Monitoring
-
Data Migrations
- A dedicated project holds SQL scripts and FluentMigrator migrations.
- Schema updates are automatically checked during startup or CI/CD.
- Ensures consistent DB evolution without hidden changes.
-
Monitoring
- Tools like Elastic APM track performance, errors, and metrics in real time.
- Helps spot and fix issues before they impact users.
-
Alerting
- Alerts (e.g., via Slack) trigger on abnormal response times or error rates.
- Enables quick incident response.
3.3. Health Checks & Observability
-
Health Checks
- Each service exposes a
/health
endpoint checking DB, cache, external APIs, etc.
- Each service exposes a
-
Logging
- Use Serilog for structured logging to the console.
- Logs integrate with tools like Filebeat; sensitive data is carefully handled.
-
Tracing & Metrics
- OpenTelemetry or Elastic APM trace requests across all layers.
- Metrics (operation durations, message counts) help analyze load and scale services.
4. Team Collaboration & Growth
4.1. ADR as a Knowledge Sharing Tool
Architecture Decision Records (ADRs) document key decisions:
- Record decisions on project structure, tools, data schemas, etc.
- Explain the rationale and alternatives considered.
- Provide transparent context for the whole team, aiding new members.
4.2. Code Reviews & Mentorship
- Code Reviews: Every pull request is checked for both correctness and adherence to Clean Architecture.
- Pair/Mob Programming: Accelerate complex tasks and share expertise.
- Mentorship: Senior developers guide newcomers, reducing the onboarding curve.
4.3. Reducing Onboarding Time
- Clear Separation: Distinct roles for API, Core, Infrastructure, Consumers, etc.
- ADRs: Offer historical context for decisions.
- Standardized Practices: Unified DI, logging, and testing setups help new developers get up to speed fast.
5. Strengthening Teams Through Simplicity
5.1. Controllers & Use Cases Free of Excess Logic
- Controllers: Handle request validation and forward data to Use Cases.
- Use Cases: Encapsulate business logic without peripheral concerns.
- Reduces coupling and makes functionality easy to locate.
5.2. Transparent Folder Structure & Code Style
- Core: Contains models, services, use cases, and interfaces.
- Infrastructure: Houses entities, repositories, external clients, and DI.
- API, Jobs, Consumers: Each has its own folders and middleware.
- Contracts: Defines shared DTOs and interfaces.
- EditorConfig and Directory.Build.props standardize formatting and linting.
5.3. Minimal Boilerplate via Shared Configs
-
Shared Configs:
- Directory.Packages.props manages NuGet package versions.
- DI extensions centralize service configuration (logging, Swagger, Health Checks).
- Docker Compose provides a one-command local setup.
- This minimizes boilerplate and lets developers focus on business logic.
6. Adapting to Change
6.1. Service Scaling
As the project grows, new subdomains emerge:
-
Subdomain Segregation
- In Core, create namespaces like
Core.Services.[Subdomain]
andCore.UseCases.[Subdomain]
. - In Infrastructure, segregate repositories similarly.
-
Example: For Customer Management:
Core.Services.CustomerManagement
Core.UseCases.CustomerManagement
Infrastructure.Repositories.CustomerManagement
- In Core, create namespaces like
-
Gradual Splitting
- When a module becomes too complex, extract it into its own microservice.
- Core logic remains reusable, thanks to clear separations.
-
Example: For Order Management:
- Separate libraries for Core and Infrastructure.
- Create an OrderService with its own repository.
- Communicate via REST or message brokers.
Scaling Examples:
- API: Horizontal scaling via load balancers; caching (Redis) to reduce DB load.
- Consumers: Use competing consumers or partition topics for parallel processing.
- Jobs: Run tasks in parallel (Hangfire) or distribute by task type.
6.2. Long-Term Maintenance
-
Regular Refactoring
- Scheduled sessions to address technical debt and optimize code.
- ADRs track changes and reasons.
- Example: Splitting complex modules or optimizing DB queries.
-
Smooth Tech Transitions
- Switching technologies (e.g., PostgreSQL to MS SQL) only affects Infrastructure.
- Example: Implement a new
IRepository
for MS SQL, update DI, test, and adjust migrations.
7. Looking Ahead
7.1. Continuous Improvement Plan
- Regularly review and update ADRs.
- Internal meetups and workshops on Clean Architecture, DevOps, etc.
- Maximize automation—from testing to environment setup—to focus on business logic.
7.2. Invitation to Collaborate
We’re growing and always seek talented developers, DevOps engineers, and analysts who value:
- Clean, Simple Code
- Openness to New Ideas & Continuous Learning
- Teamwork & Knowledge Sharing
8. Conclusion
8.1. Key Benefits for Clients, Developers, & Business
- Clients: Stable, reliable services that evolve quickly.
- Developers: Clean, modular code reduces risk and boosts professional growth.
- Business: Faster time-to-market with high quality and predictable support.
8.2. Final Thoughts on Innovation & Culture
Our architecture is a living system reflecting our values: transparency, collaboration, and sustainable growth. Regular ADRs, comprehensive testing, flexible infrastructure, and a supportive team environment keep us competitive and adaptive in a changing market.