A production-ready Clean Architecture template for .NET 10 Web APIs.
Built on DDD, CQRS, Event Sourcing, Hexagonal Architecture, and SOLID principles.
- Architecture Overview
- Project Structure
- Domain Layer
- Application Layer
- Infrastructure Layer
- Presentation Layer
- Getting Started
- Testing
- Deployment
- Contributing
┌─────────────────────────────────────────────────────────┐
│ Presentation (WebApi) │
│ Controllers · DTOs · Swagger │
├─────────────────────────────────────────────────────────┤
│ Application Layer │
│ Commands · Queries · Handlers · DTOs │
├─────────────────────────────────────────────────────────┤
│ Domain Layer │
│ Entities · Value Objects · Events · Repositories │
├─────────────────────────────────────────────────────────┤
│ Infrastructure Layer │
│ EF Core · DbContext · Repositories │
└─────────────────────────────────────────────────────────┘
Dependencies always point inward — Infrastructure and Presentation depend on the inner layers, never the reverse.
src/
├── Core/
│ ├── Domain/ # Innermost layer — no dependencies
│ │ ├── Entities/
│ │ │ ├── BaseEntity.cs # Base class with Id + domain events
│ │ │ ├── IAggregateRoot.cs # Marker interface for aggregate roots
│ │ │ └── Customer.cs # Sample aggregate root entity
│ │ ├── Events/
│ │ │ ├── IDomainEvent.cs # Domain event marker interface
│ │ │ └── Customers/
│ │ │ └── CustomerCreatedEvent.cs
│ │ ├── ValueObjects/
│ │ │ ├── IValueObject.cs # Value object marker interface
│ │ │ └── Email.cs # Sample value object with validation
│ │ ├── Repositories/
│ │ │ ├── IBaseRepository.cs # Generic repository port (interface)
│ │ │ └── ICustomerRepository.cs
│ │ └── Common/
│ │ └── Paginate/ # Pagination abstractions
│ │
│ └── Application/ # Use cases — depends on Domain only
│ ├── Commands/
│ │ ├── ICommand.cs
│ │ ├── ICommandHandler.cs
│ │ └── Customers/
│ │ ├── CreateCustomerCommand.cs
│ │ └── CreateCustomerCommandHandler.cs
│ ├── Queries/
│ │ ├── IQuery.cs
│ │ ├── IQueryHandler.cs
│ │ └── Customers/
│ │ ├── CustomerDto.cs
│ │ ├── GetCustomerByIdQuery.cs
│ │ └── GetCustomerByIdQueryHandler.cs
│ ├── Services/
│ │ └── IApplicationService.cs
│ └── DTO/
│ ├── Result.cs # Non-generic result (success/failure)
│ └── ResultGeneric.cs # Generic result with typed value
│
├── Infra/
│ └── Crosscutting.SqlServer/ # EF Core adapter — depends on Domain
│ ├── DbContexts/
│ │ └── ApplicationDbContext.cs
│ └── Repositories/
│ ├── BaseRepository.cs # Generic EF Core repository
│ └── CustomerRepository.cs
│
├── Host/
│ └── Boilerplate.WebApi/ # Presentation — depends on Application + Infra
│ ├── Controllers/
│ │ └── CustomersController.cs
│ ├── Extensions/
│ │ └── AppExtensions.cs # Swagger + versioning setup
│ ├── ExceptionHandler.cs
│ └── Program.cs
│
└── Tests/
└── Unit.Tests/
The innermost layer — has zero dependencies on any other project or NuGet package (except EF Core for IQueryable pagination helpers).
Every domain entity inherits from BaseEntity. It provides:
Id—Guid, initialized on construction; never changes.- Domain Events — entities raise events via
AddDomainEvent(). Events are dispatched after persistence to guarantee consistency. - Identity Equality — two entities are equal if they share the same
Id, regardless of other properties.
public class Order : BaseEntity, IAggregateRoot
{
public void Place()
{
// ... business logic ...
AddDomainEvent(new OrderPlacedEvent(Id));
}
}Marker interface. Apply it to entities that are the boundary of a consistency group (e.g., Order owns OrderLines). External code must always interact through the aggregate root, never with its internal entities directly.
Marker interface for domain events. Events must be:
- Immutable — use C#
recordtypes. - Named in past tense —
CustomerCreated,OrderPlaced,PaymentFailed.
Events carry only the data needed by downstream handlers. They decouple the producer from consumers.
A Value Object is defined entirely by its attributes, has no identity, and is always immutable:
var email = Email.Create("user@example.com"); // validated, normalized
string raw = email; // implicit string conversionEmail.Create() throws ArgumentException on invalid input — making invalid state unrepresentable.
Repository interfaces live in the Domain layer (the port definition). This keeps domain logic free of any persistence detail.
| Method | Description |
|---|---|
Query(predicate, orderBy, include, enableTracking) |
Composable LINQ query with optional filter, sort, and eager-load |
Insert(entity) |
Stages entity for insert; call SaveChanges() to persist |
InsertNotExists(predicate, entity) |
Idempotent insert — returns existing if found |
Update(entity) |
Marks entity as modified |
Delete(entity) |
Stages entity for deletion |
Delete(IEnumerable<T>) |
Stages a collection for deletion |
SaveChanges() |
Commits all pending changes; returns true if rows affected |
Create domain-specific interfaces that extend IBaseRepository<T>:
public interface IOrderRepository : IBaseRepository<Order>
{
Task<IEnumerable<Order>> GetPendingOrdersAsync(CancellationToken ct = default);
}Coordinates use cases using Domain objects and repository ports. Never contains business rules — those belong in entities.
Every handler returns a Result or ResultGeneric<T> to provide a uniform response contract:
// Failure
return Result.Failure("Email already exists.");
// Success with value
return ResultGeneric<Guid>.Success(customer.Id);A Command is an intent to change state. It uses ICommand / ICommandHandler<TCommand, TResult>:
// Define the command
public record CreateCustomerCommand(string Name, string Email) : ICommand<Guid>;
// Implement the handler
public class CreateCustomerCommandHandler(ICustomerRepository repo)
: ICommandHandler<CreateCustomerCommand, Guid>
{
public async Task<ResultGeneric<Guid>> HandleAsync(CreateCustomerCommand cmd, CancellationToken ct)
{
var customer = Customer.Create(cmd.Name, cmd.Email);
repo.Insert(customer);
repo.SaveChanges();
return ResultGeneric<Guid>.Success(customer.Id);
}
}Rules:
- Commands mutate state, never return domain entities.
- Handlers are processed 1-to-1 (one command → one handler).
- Register in DI:
services.AddScoped<ICommandHandler<CreateCustomerCommand, Guid>, CreateCustomerCommandHandler>()
A Query reads state without side effects. It uses IQuery<TResult> / IQueryHandler<TQuery, TResult>:
public record GetCustomerByIdQuery(Guid CustomerId) : IQuery<CustomerDto>;The handler projects the domain entity to a DTO — never expose raw entities outside the application layer.
Marker interface for Application Services — higher-level orchestrators that may coordinate multiple use cases. In a strict CQRS design, prefer individual CommandHandler/QueryHandler classes per use case over a monolithic service.
Implements all ports defined in the Domain layer. Depends on both Domain and EF Core.
The EF Core DbContext. Add DbSet<T> for each aggregate root and configure mappings in OnModelCreating:
public DbSet<Customer> Customers => Set<Customer>();
protected override void OnModelCreating(ModelBuilder builder)
{
builder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
}Provides full generic CRUD for any entity. Concrete repositories inherit from it and implement domain-specific interfaces:
public class OrderRepository(ApplicationDbContext ctx)
: BaseRepository<Order>(ctx), IOrderRepository
{
public async Task<IEnumerable<Order>> GetPendingOrdersAsync(CancellationToken ct = default)
=> await _dbSet.AsNoTracking().Where(o => o.Status == OrderStatus.Pending).ToListAsync(ct);
}Key patterns demonstrated in CustomerRepository:
FindByEmailAsync— case-insensitive lookup withAsNoTracking()for read performanceGetAllActiveAsync— filtered query viaWherepredicate
Register infrastructure services in Program.cs (or a dedicated extension method):
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddScoped<ICustomerRepository, CustomerRepository>();
builder.Services.AddScoped<ICommandHandler<CreateCustomerCommand, Guid>, CreateCustomerCommandHandler>();
builder.Services.AddScoped<IQueryHandler<GetCustomerByIdQuery, CustomerDto>, GetCustomerByIdHandler>();Thin HTTP layer — controllers accept input, forward to handlers, and return HTTP responses.
Shows the full REST pattern with API versioning:
GET /api/v1/customers/{id} → 200 OK (CustomerDto) | 404 Not Found
POST /api/v1/customers → 201 Created (Guid) | 400 Bad Request
Controller rules:
- Zero business logic.
- Always delegates to a command/query handler.
- Returns
Result-based responses with appropriate HTTP status codes.
Global exception handler converts unhandled exceptions to RFC 7807 ProblemDetails responses:
ArgumentException→400 Bad Request- All others →
500 Internal Server Error
Swagger UI is available at /swagger in Development mode. API versioning is configured via URL segment (/api/v1/) and X-Api-Version header.
- .NET 10 SDK
- SQL Server (or update
ApplicationDbContextto use another provider)
git clone https://github.com/MarcosFerreira17/clean-architecture-boilerplate.git
cd clean-architecture-boilerplate
dotnet restoreConfigure your database connection in appsettings.json:
{
"ConnectionStrings": {
"Default": "Server=localhost;Database=BoilerplateDb;Trusted_Connection=True;"
}
}Run the application:
dotnet run --project src/Host/Boilerplate.WebApiThe API is available at http://localhost:5000. Swagger UI at http://localhost:5000/swagger.
Unit tests live in src/Tests/Unit.Tests. Run with:
dotnet test| Layer | What to test |
|---|---|
| Domain | Entity factory methods, value object validation, domain event assertions |
| Application | Command handler flows, query projections, boundary conditions |
| Infrastructure | Repository queries against an in-memory or test DB |
dotnet publish src/Host/Boilerplate.WebApi --configuration Release --output ./publishDocker:
FROM mcr.microsoft.com/dotnet/aspnet:10.0
COPY ./publish /app
WORKDIR /app
ENTRYPOINT ["dotnet", "Boilerplate.WebApi.dll"]- Fork the repository.
- Create a feature branch:
git checkout -b feature/my-feature - Commit changes:
git commit -m "Add my feature" - Push to your fork:
git push origin feature/my-feature - Submit a pull request.
Please include tests and follow the existing code style.