Project Overview
This ASP.NET Core Web API implements a fully layered, testable service using Clean Architecture and Domain-Driven Design. It enforces separation of concerns, promotes maintainability, and scales from small APIs to enterprise solutions.
Purpose
Provide a robust starting point for business-critical APIs by:
- Encapsulating domain logic in isolated layers
- Enabling flexible feature evolution without coupling presentation to persistence
- Delivering standardized patterns for command/query segregation and security
Problems Addressed
- Monolithic controllers with mixed business and infrastructure code
- Difficult integration testing due to tangled dependencies
- Inconsistent handling of read vs. write operations
- Hard-coded authentication rules and manual token management
Core Concepts & Technologies
- Clean Architecture:
• Domain project holds entities, value objects, aggregates, and domain services
• Application project defines use-case handlers, DTOs, interfaces
• Infrastructure project implements persistence (EF Core, MS SQL), external integrations
• WebAPI project hosts controllers, configures Host/Startup, dependency injection - Domain-Driven Design:
• Models business concepts explicitly in the Domain layer
• Applies ubiquitous language across code and documentation - CQRS with MediatR:
• Separates read (queries) from write (commands) workflows
• Defines IRequest and IRequestHandler for each use case
• Dispatches via injected IMediator for loose coupling - JWT Authentication:
• Secures endpoints using Microsoft.AspNetCore.Authentication.JwtBearer
• Issues access tokens on successful login
• Validates scoped roles/claims in policies and controllers
Solution Structure
WebAPI.sln contains five projects:
- Domain: Entities, value objects, domain events, repository interfaces
- Application: Commands, queries, handlers, validators, service interfaces
- Infrastructure: EF Core DbContext, repository implementations, migrations setup
- WebAPI: Controllers, middleware, DI registrations, JWT configuration
- Tests: xUnit suites for unit and integration tests
Getting Started
- Open WebAPI.sln in Visual Studio
- Set connection string in Infrastructure/appsettings.json
- Run migrations:
Update-Database -Project Infrastructure -StartupProject WebAPI
- Launch WebAPI; authenticate via
/api/auth/login
to obtain a JWT
This structure accelerates feature delivery while maintaining code quality, testability, and security.
Getting Started
This guide walks you through cloning the repo, configuring your environment, running the Web API, and making your first authenticated request.
Prerequisites
- .NET 8 SDK
- SQL Server instance (local or remote)
- Visual Studio 2022+ or VS Code with C# extension
1. Clone the Repository
git clone https://github.com/yeucp/CleanArquitecture.git
cd CleanArquitecture
2. Configure Database & Migrations
Update Connection String
Open src/WebAPI/appsettings.Development.json
(or appsettings.json
) and set your SQL Server connection:
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=CleanArchDb;Trusted_Connection=True;"
},
"JwtSettings": { /* ... */ }
}
Automatic Migrations on Startup
The app applies pending EF Core migrations during startup via MigrationExtension
. No manual step required.
In Program.cs
you’ll see:
var app = builder.Build();
app.ApplyMigration(); // Runs pending migrations
app.Run();
Manual Migration (Optional)
dotnet ef migrations add InitialCreate \
--project src/WebAPI \
--startup-project src/WebAPI
dotnet ef database update \
--project src/WebAPI \
--startup-project src/WebAPI
3. Run the Web API
Using .NET CLI
dotnet run --project src/WebAPI
The API listens by default on:
Using Visual Studio
- Open solution in Visual Studio
- Select the WebAPI launch profile
- Press F5
4. Explore Swagger UI
Navigate to https://localhost:5001/swagger to view and test all endpoints. JWT support is configured under “Authorize”.
5. Obtain a JWT
Via Swagger
- Expand Authentication → POST /api/v1/Authentication/login
- Click Try it out
- Provide credentials:
{ "username": "admin", "password": "P@ssw0rd" }
- Execute and copy the returned
token
.
Via Curl
curl -X POST https://localhost:5001/api/v1/Authentication/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"P@ssw0rd"}'
Via C# HttpClient
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
public class LoginRequest { public string Username { get; set; } public string Password { get; set; } }
public class LoginResponse { public string Token { get; set; } public DateTime Expiration { get; set; } }
async Task<LoginResponse> GetJwtAsync()
{
using var client = new HttpClient { BaseAddress = new Uri("https://localhost:5001/") };
var request = new LoginRequest { Username = "admin", Password = "P@ssw0rd" };
var response = await client.PostAsJsonAsync("api/v1/Authentication/login", request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<LoginResponse>();
}
You now have a valid JWT. Add it to the Authorization: Bearer {token}
header for subsequent API calls.
Architecture & Layered Structure
This section describes the Clean Architecture layout of the system and traces the lifecycle of a "Create Customer" request from the API endpoint down to persistence and back.
Clean Architecture Layers
• Presentation (Web API)
– Defines Controllers, DTOs, filters, problem details
– References Application via MediatR
• Application
– Contains Commands/Queries, Handlers, DTOs, Validation, Pipeline Behaviors
– Depends on Domain abstractions (interfaces, entities)
• Domain
– Defines Entities, Value Objects, Aggregates, Domain Events, Repository interfaces
– Pure business logic, no external dependencies
• Infrastructure
– Implements persistence (EF Core, Dapper), messaging, authentication, external APIs
– References Domain (for entities/interfaces) and Application (for DTO mapping, if needed)
Dependency Graph
Presentation → Application → Domain
Infrastructure → Application & Domain
Request Flow: “Create Customer” Example
1. HTTP POST → Controller
using Application.Customers.Create;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class CustomersController : ControllerBase
{
private readonly IMediator _mediator;
public CustomersController(IMediator mediator) => _mediator = mediator;
[HttpPost]
public async Task<IActionResult> Create(CreateCustomerRequest dto)
{
// Map DTO to MediatR Command
var command = new CreateCustomerCommand(
Name: dto.Name,
LastName: dto.LastName,
Email: dto.Email,
PhoneNumber: dto.PhoneNumber,
CustomerStatusId: dto.CustomerStatusId
);
ErrorOr<Guid> result = await _mediator.Send(command);
return result.Match(
id => CreatedAtAction(nameof(GetById), new { id }, new { id }),
errors => Problem(errors)
);
}
}
2. MediatR Pipeline
• ValidationBehavior<CreateCustomerCommand, ErrorOr<Guid>>
runs registered IValidator<CreateCustomerCommand>
.
• On failure returns Error.Validation
list; on success invokes handler.
3. Application Handler
using Domain.Customers;
using Domain.Common.Errors;
using ErrorOr;
public class CreateCustomerCommandHandler
: IRequestHandler<CreateCustomerCommand, ErrorOr<Guid>>
{
private readonly ICustomerRepository _repo;
public CreateCustomerCommandHandler(ICustomerRepository repo) => _repo = repo;
public async Task<ErrorOr<Guid>> Handle(CreateCustomerCommand cmd, CancellationToken ct)
{
// Domain logic: ensure unique email
if (await _repo.ExistsByEmailAsync(cmd.Email, ct))
return Errors.Customer.DuplicateEmail;
var customer = Customer.Create(
id: CustomerId.CreateUnique(),
name: cmd.Name,
lastName: cmd.LastName,
email: cmd.Email,
phoneNumber: PhoneNumber.Create(cmd.PhoneNumber)!,
statusId: new CustomerStatusId(cmd.CustomerStatusId)
);
await _repo.AddAsync(customer, ct);
return customer.Id.Value;
}
}
4. Domain Model
public sealed class Customer : AggregateRoot<CustomerId>
{
public string Name { get; private set; }
public string LastName { get; private set; }
public Email Email { get; private set; }
public PhoneNumber PhoneNumber { get; private set; }
public CustomerStatusId StatusId { get; private set; }
private Customer(...) { /* ORM ctor */ }
private Customer(CustomerId id, ...) : base(id) { /* init */ }
public static Customer Create(CustomerId id, string name, string lastName,
string email, PhoneNumber phoneNumber, CustomerStatusId statusId)
{
// invariants, events
return new Customer(id, name, lastName, Email.Create(email), phoneNumber, statusId);
}
}
5. Infrastructure Persistence
using Microsoft.EntityFrameworkCore;
using Domain.Customers;
public class CustomerRepository : ICustomerRepository
{
private readonly AppDbContext _db;
public CustomerRepository(AppDbContext db) => _db = db;
public Task<bool> ExistsByEmailAsync(string email, CancellationToken ct) =>
_db.Customers.AnyAsync(c => c.Email.Value == email, ct);
public async Task AddAsync(Customer customer, CancellationToken ct)
{
_db.Customers.Add(customer);
await _db.SaveChangesAsync(ct);
}
}
6. Response to Client
• On success returns HTTP 201 with { "id": "{newGuid}" }
.
• On validation or domain error returns HTTP 400 with standard ProblemDetails payload.
Dependency Injection Setup
In Program.cs:
var builder = WebApplication.CreateBuilder(args);
// Presentation & Application
builder.Services.AddControllers();
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssemblyContaining<CreateCustomerCommand>());
builder.Services.AddValidatorsFromAssemblyContaining<CreateCustomerCommandValidator>();
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
// Domain abstractions & Infrastructure
builder.Services.AddScoped<ICustomerRepository, CustomerRepository>();
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
// Authentication, ProblemDetails, etc.
builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection(JwtSettings.SectionName));
builder.Services.AddSingleton<IJwtTokenGenerator, JwtTokenGenerator>();
builder.Services.AddSingleton<ProblemDetailsFactory>(sp =>
new APIProblemDetailsFactory(sp.GetRequiredService<IOptions<ApiBehaviorOptions>>().Value));
var app = builder.Build();
app.MapControllers();
app.Run();
Practical Tips
• Keep Controllers thin: map DTOs to Commands/Queries only.
• Let PipelineBehaviors handle cross-cutting concerns (validation, logging, auth).
• Application layer orchestrates use cases; avoid business rules in Handlers.
• Domain layer remains free of external dependencies.
• Infrastructure implements interfaces, configured by DI.
Core Concepts & Patterns
This section highlights reusable patterns and helper utilities in the yeucp/CleanArchitecture repository. Each subsection explains how to apply or extend these core components in your application.
APIProblemDetailsFactory: Enriching ProblemDetails with Domain Error Codes
Purpose
Automatically surfaces domain validation errors (ErrorOr
/Error
objects) as an errorCodes
extension on every ProblemDetails
/ValidationProblemDetails
response.
Essential Details
- Inherits from ASP.NET Core’s
ProblemDetailsFactory
. - Overrides
CreateProblemDetails
andCreateValidationProblemDetails
. - In
ApplyProblemDetailsDefaults
, reads aList<Error>
fromHttpContext.Items[HttpContextItemKeys.Errors]
. - Exposes each
Error.Code
underextensions.errorCodes
.
Code Snippet
private void ApplyProblemDetailsDefaults(
HttpContext httpContext,
ProblemDetails problemDetails,
int statusCode)
{
problemDetails.Status ??= statusCode;
if (_options.ClientErrorMapping.TryGetValue(statusCode, out var clientErrorData))
{
problemDetails.Title ??= clientErrorData.Title;
problemDetails.Type ??= clientErrorData.Link;
}
var traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier;
if (traceId != null)
problemDetails.Extensions["traceId"] = traceId;
var errors = httpContext.Items[HttpContextItemKeys.Errors] as List<Error>;
if (errors != null)
{
problemDetails.Extensions.Add(
"errorCodes",
errors.Select(e => e.Code)
);
}
}
Registration & Usage
- Register the custom factory:
services.AddSingleton<ProblemDetailsFactory, APIProblemDetailsFactory>(); services.Configure<ApiBehaviorOptions>(opts => { // override client‐error mappings if needed });
- Populate
HttpContext.Items[HttpContextItemKeys.Errors]
before returning a Problem:// Inside a controller or middleware handling ErrorOr<T> if (serviceResult.IsError) { HttpContext.Items[HttpContextItemKeys.Errors] = serviceResult.Errors.ToList(); return Problem( statusCode: 400, detail: "One or more validation errors occurred." ); }
- Sample response payload:
{ "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", "title": "One or more validation errors occurred.", "status": 400, "instance": "/api/customers", "extensions": { "traceId": "00-abc123...", "errorCodes": ["Customer.PhoneNumber","Customer.StatusNotFound"] } }
ValidationBehavior: Automatic FluentValidation in MediatR Pipeline
Purpose
Intercepts every IRequest<TResponse>
in MediatR. If an IValidator<TRequest>
exists, runs validation before invoking the handler. On failure, returns ErrorOr
errors without executing the handler.
How It Works
- Generic behavior where
TRequest : IRequest<TResponse>
andTResponse : IErrorOr
. - Resolves
IValidator<TRequest>
. If none, callsnext()
. - On validation errors, maps each
ValidationFailure
toError.Validation(property, message)
and returns them asTResponse
.
Key Code
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
where TResponse : IErrorOr
{
private readonly IValidator<TRequest>? _validator;
public ValidationBehavior(IValidator<TRequest>? validator = null)
=> _validator = validator;
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
if (_validator == null)
return await next();
var result = await _validator.ValidateAsync(request, ct);
if (result.IsValid)
return await next();
var errors = result.Errors
.ConvertAll(f => Error.Validation(f.PropertyName, f.ErrorMessage));
return (dynamic)errors;
}
}
Configuration
services
.AddValidatorsFromAssembly(typeof(CreateCustomerCommandValidator).Assembly)
.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
Ensure commands/queries implement IRequest<IErrorOr<YourResponse>>
:
public record CreateCustomerCommand(
string Name,
string LastName,
string Email,
string PhoneNumber
) : IRequest<IErrorOr<CustomerResult>>;
Handling Results
var result = await mediator.Send(cmd);
if (result.IsError)
{
foreach (var err in result.Errors)
Console.WriteLine($"Validation failed: {err.Description}");
return;
}
// proceed with result.Value
JwtTokenGenerator: Issuing Signed JWTs
Purpose
Configure and use JwtTokenGenerator
to issue HMAC-SHA256 JWTs based on JwtSettings
.
Configuration
- Add to appsettings.json:
{ "JwtSettings": { "SecretKey": "your-256-bit-secret", "ExpiryMinutes": 60, "Issuer": "YourAppName", "Audience": "YourAppClients" } }
- Register in DI:
builder.Services .Configure<JwtSettings>(configuration.GetSection(JwtSettings.SectionName)) .AddSingleton<IJwtTokenGenerator, JwtTokenGenerator>();
How It Works
- Reads settings via
IOptions<JwtSettings>
. GenerateToken(string userId, string username)
builds signing credentials, sets standard claims (sub
,given_name
), and issues aJwtSecurityToken
:public string GenerateToken(string userId, string username) { var credentials = new SigningCredentials( new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_JwtSettings.SecretKey)), SecurityAlgorithms.HmacSha256); var claims = new[] { new Claim(JwtRegisteredClaimNames.Sub, userId), new Claim(JwtRegisteredClaimNames.GivenName, username) }; var token = new JwtSecurityToken( issuer: _JwtSettings.Issuer, audience: _JwtSettings.Audience, expires: DateTime.Now.AddDays(_JwtSettings.ExpiryMinutes), claims: claims, signingCredentials: credentials); return new JwtSecurityTokenHandler().WriteToken(token); }
Practical Usage
// Inside a login handler/service
var jwt = _jwtTokenGenerator.GenerateToken(user.Id, user.UserName);
return new LoginResult { Token = jwt };
Tip: Use AddMinutes
instead of AddDays
if you intend minute-level expiry.
Raising and Dispatching Domain Events
Purpose
Illustrates how aggregates raise domain events and how ApplicationDbContext
dispatches them via MediatR after a successful save.
Raising Events in Aggregates
AgregateRoot
providesRaise(DomainEvent event)
to queue events.
public record CustomerActivated(Guid CustomerId) : DomainEvent(CustomerId); public class Customer : AgregateRoot { public Guid Id { get; private set; } public bool IsActive { get; private set; } public void Activate() { if (IsActive) return; IsActive = true; Raise(new CustomerActivated(Id)); } }
DomainEvent
implementsINotification
:public record DomainEvent(Guid Id) : INotification;
Publishing in ApplicationDbContext
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) { var domainEvents = ChangeTracker .Entries<AgregateRoot>() .SelectMany(e => e.Entity.GetDomainEvents()) .ToList(); var result = await base.SaveChangesAsync(cancellationToken); foreach (var domainEvent in domainEvents) await _publisher.Publish(domainEvent, cancellationToken); return result; }
Writing an Event Handler
public class CustomerActivatedHandler : INotificationHandler<CustomerActivated> { public Task Handle(CustomerActivated notification, CancellationToken cancellationToken) { Console.WriteLine($"Customer {notification.CustomerId} activated"); return Task.CompletedTask; } }
DI Setup
services .AddDbContext<ApplicationDbContext>(options => /* ... */) .AddMediatR(typeof(ApplicationDbContext).Assembly);
By following these patterns, you maintain consistency in error handling, validation, authentication, and domain event dispatch across your application.
API Reference & Usage
This section describes available REST endpoints, sample requests/responses, and the underlying MediatR commands, queries, and handlers. Register all services in your Startup.cs
or Program.cs
to enable dependency injection.
Authentication
POST /Authentication/login
Verifies credentials and returns a JWT.
Request
• URL: /Authentication/login
• Method: POST
• Headers:
- Content-Type: application/json
• Body:
{ "username": "alice", "password": "P@ssw0rd!" }
Successful Response (200 OK)
{ "token": "<JWT>" }
Error Response (400 Bad Request)
{
"code": "Login.Failure",
"description": "Invalid credentials"
}
cURL Example
curl -X POST https://api.example.com/Authentication/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"P@ssw0rd!"}'
Handler: LoginCommandHandler
public sealed class LoginCommandHandler
: IRequestHandler<LoginCommand, ErrorOr<LoginResult>>
{
private readonly ILoginRepository _loginRepository;
private readonly IJwtTokenGenerator _jwtTokenGenerator;
public LoginCommandHandler(
ILoginRepository loginRepository,
IJwtTokenGenerator jwtTokenGenerator)
{
_loginRepository = loginRepository;
_jwtTokenGenerator = jwtTokenGenerator;
}
public async Task<ErrorOr<LoginResult>> Handle(
LoginCommand command, CancellationToken ct)
{
if (!await _loginRepository
.ValidateUser(command.Username, command.Password))
{
return Error.Unauthorized(
code: "Login.Failure",
description: "Invalid credentials");
}
var token = _jwtTokenGenerator.GenerateToken(
subject: command.Username,
username: command.Username);
return new LoginResult(token);
}
}
public record LoginResult(string Token);
Controller Integration
[ApiController]
[Route("Authentication")]
public class AuthenticationController : ControllerBase
{
private readonly ISender _mediator;
public AuthenticationController(ISender mediator)
=> _mediator = mediator;
[HttpPost("login")]
public async Task<IActionResult> Login(LoginCommand command)
{
var result = await _mediator.Send(command);
if (result.IsError)
return BadRequest(new {
Code = result.FirstError.Code,
Description = result.FirstError.Description
});
return Ok(result.Value);
}
}
Customers
Endpoints for creating, updating, and deleting customer records. All handlers return ErrorOr<Unit>
.
POST /customers
Create a new customer.
Request
• URL: /customers
• Method: POST
• Headers:
- Content-Type: application/json
- Authorization: Bearer {token}
• Body:
{
"name": "Alice",
"lastName": "Smith",
"email": "alice@example.com",
"phoneNumber": "+15551234567",
"customerStatus": "d290f1ee-6c54-4b01-90e6-d701748f0851"
}
Response (201 Created)
No body.
Handler: CreateCustomerCommandHandler
public sealed class CreateCustomerCommandHandler
: IRequestHandler<CreateCustomerCommand, ErrorOr<Unit>>
{
public async Task<ErrorOr<Unit>> Handle(
CreateCustomerCommand cmd, CancellationToken ct)
{
if (PhoneNumber.Create(cmd.PhoneNumber) is not PhoneNumber phone)
return Errors.Customer.PhoneNumberWithBadFormat;
if (await _statusRepo
.GetByIdAsync(new CustomerStatusId(cmd.CustomerStatus))
is not CustomerStatus status)
return Errors.Customer.CustomerStatusNotFound;
var customer = new Customer(
new CustomerId(Guid.NewGuid()),
cmd.Name, cmd.LastName, cmd.Email, true, phone, status);
_repo.Add(customer);
await _uow.SaveChangesAsync(ct);
return Unit.Value;
}
}
PUT /customers/{id}
Update an existing customer.
Request
• URL: /customers/{id}
• Method: PUT
• Headers:
- Content-Type: application/json
- Authorization: Bearer {token}
• Body:
{
"id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"name": "Alice",
"lastName": "Johnson",
"email": "alice.j@example.com",
"active": false,
"phoneNumber": "+15557654321",
"customerStatus": "f7a3b1c3-9c1c-4bf5-8ffe-123456789abc"
}
Response (200 OK)
No body.
Handler: UpdateCustomerCommandHandler
public sealed class UpdateCustomerCommandHandler
: IRequestHandler<UpdateCustomerCommand, ErrorOr<Unit>>
{
public async Task<ErrorOr<Unit>> Handle(
UpdateCustomerCommand cmd, CancellationToken ct)
{
if (!await _repo.ExistsAsync(new CustomerId(cmd.Id)))
return Errors.Customer.CustomerNotFound;
if (PhoneNumber.Create(cmd.PhoneNumber) is not PhoneNumber phone)
return Errors.Customer.PhoneNumberWithBadFormat;
if (await _statusRepo
.GetByIdAsync(new CustomerStatusId(cmd.CustomerStatus))
is not CustomerStatus status)
return Errors.Customer.CustomerStatusNotFound;
var updated = Customer.UpdateCustomer(
cmd.Id, cmd.Name, cmd.LastName, cmd.Email,
cmd.Active, phone, status);
_repo.Update(updated);
await _uow.SaveChangesAsync(ct);
return Unit.Value;
}
}
DELETE /customers/{id}
Delete a customer.
Request
• URL: /customers/{id}
• Method: DELETE
• Headers:
- Authorization: Bearer {token}
Response (200 OK)
No body.
Handler: DeleteCustomerCommandHandler
public sealed class DeleteCustomerCommandHandler
: IRequestHandler<DeleteCustomerCommand, ErrorOr<Unit>>
{
public async Task<ErrorOr<Unit>> Handle(
DeleteCustomerCommand cmd, CancellationToken ct)
{
var id = new CustomerId(cmd.Id);
if (await _repo.GetByIdAsync(id) is not Customer toDelete)
return Error.NotFound("Customer.Id", "Customer not found");
_repo.Delete(toDelete);
await _uow.SaveChangesAsync(ct);
return Unit.Value;
}
}
CustomersController
[ApiController]
[Route("customers")]
public class CustomersController : ControllerBase
{
private readonly ISender _mediator;
public CustomersController(ISender mediator) => _mediator = mediator;
[HttpPost]
public async Task<IActionResult> Post(
[FromBody] CreateCustomerCommand cmd)
{
var result = await _mediator.Send(cmd);
return result.Match(
_ => Created("", null),
errs => Problem(errs));
}
[HttpPut("{id}")]
public async Task<IActionResult> Put(
Guid id, [FromBody] UpdateCustomerCommand cmd)
{
if (id != cmd.Id)
return Problem(new[] {
Error.Validation("Customer.Id","URL id and body id must match")
});
var result = await _mediator.Send(cmd);
return result.Match(_ => Ok(), errs => Problem(errs));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(Guid id)
{
var result = await _mediator.Send(new DeleteCustomerCommand(id));
return result.Match(_ => Ok(), errs => Problem(errs));
}
}
Customer Status
Endpoints for listing and creating customer statuses. All handlers return ErrorOr<…>
.
GET /customer-status
Fetch all statuses.
Request
• URL: /customer-status
• Method: GET
• Headers:
- Authorization: Bearer {token}
Response (200 OK)
[
{ "id": "d290f1ee-6c54-4b01-90e6-d701748f0851", "description": "Active" },
{ "id": "f7a3b1c3-9c1c-4bf5-8ffe-123456789abc", "description": "Inactive" }
]
cURL Example
curl -H "Authorization: Bearer $TOKEN" \
-X GET https://api.example.com/customer-status
Handler: GetAllCustomerStatusQueryHandler
public async Task<ErrorOr<IReadOnlyList<CustomerStatusResponse>>> Handle(
GetAllCustomerStatusQuery _, CancellationToken ct)
{
var statuses = await _customerStatusRepository.GetAllAsync();
return statuses
.Select(cs => new CustomerStatusResponse(
cs.Id.Value, cs.Description))
.ToList();
}
POST /customer-status
Create a new status.
Request
• URL: /customer-status
• Method: POST
• Headers:
- Content-Type: application/json
- Authorization: Bearer {token}
• Body:
{ "description": "VIP" }
Response (200 OK)
No body.
cURL Example
curl -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"description":"VIP"}' \
-X POST https://api.example.com/customer-status
Handler: CreateCustomerStatusCommandHandler
public async Task<ErrorOr<Unit>> Handle(
CreateCustomerStatusCommand command, CancellationToken ct)
{
var customerStatus = new CustomerStatus(
new CustomerStatusId(Guid.NewGuid()),
command.Description);
_customerStatusRepository.Add(customerStatus);
await _unitOfWork.SaveChangesAsync(ct);
return Unit.Value;
}
Controller Integration
[ApiController]
[Route("customer-status")]
public class CustomerStatusController : ControllerBase
{
private readonly ISender _mediator;
public CustomerStatusController(ISender mediator) => _mediator = mediator;
[HttpGet]
public async Task<IActionResult> GetAll()
{
var result = await _mediator.Send(new GetAllCustomerStatusQuery());
return result.Match(
list => Ok(list),
errs => Problem(errs));
}
[HttpPost]
public async Task<IActionResult> Create(
CreateCustomerStatusCommand cmd)
{
var result = await _mediator.Send(cmd);
return result.Match(_ => Ok(), errs => Problem(errs));
}
}
Dependency Injection
services.AddScoped<ILoginRepository, LoginRepository>();
services.AddSingleton<IJwtTokenGenerator, JwtTokenGenerator>();
services.AddScoped<ICustomerRepository, CustomerRepository>();
services.AddScoped<ICustomerStatusRepository, CustomerStatusRepository>();
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddMediatR(typeof(LoginCommandHandler).Assembly);
## Development & Deployment Guide
This guide covers common development tasks, configuration tweaks, automated database migrations, testing patterns, and service registration in the yeucp/CleanArquitecture solution.
---
### Configuring JwtSettings
Define, bind and consume the `JwtSettings` section from configuration for token generation and validation.
#### 1. JwtSettings class
`Infrastructure/Authentication/JwtSettings.cs` maps to the `"JwtSettings"` section:
```csharp
public class JwtSettings
{
public const string SectionName = "JwtSettings";
public string SecretKey { get; init; } = null!;
public int ExpiryMinutes { get; init; }
public string Issuer { get; init; } = null!;
public string Audience { get; init; } = null!;
}
SecretKey
: long, random string.ExpiryMinutes
: token lifetime (> 0).Issuer
&Audience
: used in validation.
2. appsettings.json
Add under root:
"JwtSettings": {
"SecretKey": "your-very-long-random-secret-key",
"ExpiryMinutes": 60,
"Issuer": "MyApi",
"Audience": "MyApiClients"
}
3. Binding in Program.cs
using Infrastructure.Authentication;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
// Bind JwtSettings
builder.Services
.Configure<JwtSettings>(
builder.Configuration.GetSection(JwtSettings.SectionName)
);
// Register concrete JwtSettings for direct injection
builder.Services.AddSingleton(sp =>
sp.GetRequiredService<IOptions<JwtSettings>>().Value
);
// Add authentication, controllers, etc.
4. Consuming in AuthService
using Infrastructure.Authentication;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
public class AuthService
{
private readonly JwtSettings _jwt;
public AuthService(JwtSettings jwtSettings) => _jwt = jwtSettings;
public string GenerateToken(string userId)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.SecretKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[] { new Claim(JwtRegisteredClaimNames.Sub, userId) };
var token = new JwtSecurityToken(
issuer: _jwt.Issuer,
audience: _jwt.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_jwt.ExpiryMinutes),
signingCredentials: creds
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
Tips:
- Always inject
JwtSettings
via DI. - Use identical settings in JWT middleware for validation.
- Rotate
SecretKey
by updating configuration and restarting.
Applying EF Core Migrations at Startup
Automatically apply pending migrations when the API starts.
MigrationExtension
src/WebAPI/Extensions/MigrationExtension.cs
:
using Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace WebAPI.Extensions
{
public static class MigrationExtension
{
public static void ApplyMigration(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
dbContext.Database.Migrate();
}
}
}
Usage in Program.cs
var builder = WebApplication.CreateBuilder(args);
// 1. Register DbContext
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))
);
var app = builder.Build();
// 2. Apply migrations before handling requests
app.ApplyMigration();
app.MapControllers();
app.Run();
Guidance:
- Call
ApplyMigration()
immediately afterBuild()
. - For production, consider controlled migration workflows (CI/CD).
Unit Testing CreateCustomerCommandHandler
Verify validation rules and dependency interactions for CreateCustomerCommandHandler
.
1. Test Class Setup
using Application.Customers.Create;
using Domain.CustomerStatuses;
using FluentAssertions;
using Moq;
using Xunit;
namespace Application.Customers.UnitTests.Create
{
public class CreateCustomerCommandHandlerTests
{
private readonly Mock<ICustomerRepository> _customerRepo = new();
private readonly Mock<ICustomerStatusRepository> _statusRepo = new();
private readonly Mock<IUnitOfWork> _unitOfWork = new();
private readonly CreateCustomerCommandHandler _handler;
public CreateCustomerCommandHandlerTests()
{
_handler = new CreateCustomerCommandHandler(
_customerRepo.Object,
_statusRepo.Object,
_unitOfWork.Object
);
}
// ... test methods ...
}
}
2. Validation Failure Test
[Fact]
public async Task Handle_InvalidPhoneFormat_ReturnsValidationError()
{
var command = new CreateCustomerCommand(
"Yeudi", "Carazo", "yex@gmail.com",
phone: "71941273", // invalid format
statusId: Guid.Parse("70158537-D4A0-477A-8A5D-CEC1A7E76816")
);
var result = await _handler.Handle(command, CancellationToken.None);
result.IsError.Should().BeTrue();
var error = result.FirstError;
error.Type.Should().Be(ErrorType.Validation);
error.Code.Should().Be(Errors.Customer.PhoneNumberWithBadFormat.Code);
}
3. Successful Creation Test
[Fact]
public async Task Handle_ValidCommand_CreatesCustomerAndCommits()
{
var validStatus = new CustomerStatus(Guid.NewGuid(), "Active");
_statusRepo.Setup(r => r.GetByIdAsync(validStatus.Id))
.ReturnsAsync(validStatus);
Customer? createdCustomer = null;
_customerRepo.Setup(r => r.AddAsync(It.IsAny<Customer>()))
.Callback<Customer>(c => createdCustomer = c)
.Returns(Task.CompletedTask);
var command = new CreateCustomerCommand(
"Jane", "Doe", "jane.doe@example.com", "0712345678", validStatus.Id
);
var result = await _handler.Handle(command, CancellationToken.None);
result.IsError.Should().BeFalse();
_customerRepo.Verify(r => r.AddAsync(It.IsAny<Customer>()), Times.Once);
_unitOfWork.Verify(u => u.CommitAsync(), Times.Once);
createdCustomer!.Email.Value.Should().Be("jane.doe@example.com");
}
Tips:
- Reuse mocks in the constructor.
- Name tests
Handle_[Condition]_[ExpectedOutcome]
. - Use
FluentAssertions
for clear assertions. - Verify repository and unit-of-work calls on happy paths.
Registering Application Layer Services
Centralize MediatR handlers and FluentValidation behaviors.
// src/Application/DependencyInjection.cs
using Application.Common.Behavior;
using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
namespace Application
{
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
// MediatR scans this assembly
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssemblyContaining<ApplicationAssemblyReference>());
// FluentValidation pipeline
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
// Register all validators
services.AddValidatorsFromAssemblyContaining<ApplicationAssemblyReference>();
return services;
}
internal class ApplicationAssemblyReference { }
}
}
Usage in Program.cs:
var builder = WebApplication.CreateBuilder(args);
// Register CQRS + validation
builder.Services.AddApplication();
// ... register Infrastructure, Presentation, etc.
var app = builder.Build();
app.Run();
Tips:
- Add an
IValidator<T>
for every command/query. - To introduce custom behaviors (logging, metrics), register additional
IPipelineBehavior<,>
afterAddApplication()
. - Keep Application layer free of external dependencies beyond MediatR and FluentValidation.