A Guide to Building Scalable, Maintainable Web API using ASP .NET Core
The term “Clean Architecture” has become increasingly popular in software development in recent years. Clean Architecture is a software design pattern that prioritizes the separation of concerns, making it easier to maintain, test, and evolve an application over time. This article will look at Clean Architecture and how it can be applied to ASP.NET Core applications.
What is Clean Architecture?
Clean Architecture is a design pattern that separates an application into different layers based on their responsibility. It’s a way of organizing your code into independent, testable, and reusable components. This architecture pattern is a software design methodology that emphasizes the separation of concerns and separates the application into distinct modules.
The primary goal of Clean Architecture is to create a structure that makes it easy to manage and maintain an application as it grows and changes over time. It also makes it easy to add new features, fix bugs, and make changes to existing functionality without affecting the rest of the application.
Benefits of Clean Architecture
- Better Scalability: Clean Architecture makes it easier to scale an application as it grows and changes over time. By separating the application into distinct layers, you can add new features, fix bugs, and make changes to existing functionality without affecting the rest of the application.
- Improved Maintainability: Clean Architecture makes it easier to maintain an application over time. Separating the application into distinct layers allows you to change one layer without affecting the rest.
- Increased Testability: Clean Architecture makes it easier to test an application. By separating the application into distinct layers, you can write automated tests for each layer, ensuring that changes to one layer do not break the functionality of the rest of the application.
Implementing Clean Architecture in ASP .NET Core Web API
To apply Clean Architecture, we can divide the application into four primary layers:
- Domain Layer → the project that contains the domain layer, including the entities, value objects, and domain services.
- Application Layer → the project that contains the application layer and implements the application services, DTOs (data transfer objects), and mappers. It should reference the Domain project.
- Infrastructure Layer → The project contains the infrastructure layer, including the implementation of data access, logging, email, and other communication mechanisms. It should reference the Application project.
- Presentation Layer → The main project contains the presentation layer and implements the ASP.NET Core web API. It should reference the Application and Infrastructure projects.
Clean Architecture Schema
Project Structure
The project structure for a Clean Architecture in ASP.NET Core Web API might look like this:
Project Structure
We can start by creating a Blank Solution and add four solution folders: Core, Infrastructure, Presentation, and Test.
Domain Layer
The domain layer is a core component of Clean Architecture, representing an application’s business logic and entities. It contains all the business rules and knowledge of the application and should be independent of any specific implementation details or technologies. The domain layer defines the entities, value objects, services, and business rules that comprise the application’s core.
The goal of the domain layer is to encapsulate the knowledge of the application in a way that is easily testable, reusable, and independent of any specific infrastructure or technology. This layer should not depend on external components, such as databases or APIs, and should only interact with them through abstractions. Keeping the domain layer free of infrastructure concerns makes changing or replacing infrastructure components easier without affecting the application’s business logic.
The domain layer is a class library project located inside the Core folder. The domain layer has no reference to the other layer.
Domain Layer Structure
There are two folders inside the Domain project: Common and Entities.
■ Common
The Common folder is used to store BaseEntity class and other aggregates:
namespace CleanArchitecture.Domain.Common;
public abstract class BaseEntity
{
public Guid Id { get; set; }
public DateTimeOffset DateCreated { get; set; }
public DateTimeOffset? DateUpdated { get; set; }
public DateTimeOffset? DateDeleted { get; set; }
}
■ Domain
All domain entities are stored inside the Entities folder:
using CleanArchitecture.Domain.Common;
namespace CleanArchitecture.Domain.Entities;
public sealed class User : BaseEntity
{
public string Email { get; set; }
public string Name { get; set; }
}
Application Layer
The application layer is a component of Clean Architecture that acts as a bridge between the domain layer and the external interfaces of an application, such as the presentation layer or data access layer. This layer coordinates the interactions between the domain layer and the external components and transforms data between the different layers.
The application layer contains application services and classes containing the application’s business logic. These services interact with the domain layer to perform tasks such as creating or updating entities or invoking domain services. The application layer also acts as an intermediary between the domain layer and the presentation layer or data access layer, translating domain objects into presentation objects or data access objects and vice versa.
The application layer should not contain any infrastructure-specific code and should not depend on any specific technology or data access mechanism. Instead, it should use abstractions and interfaces to interact with external components, making changing or replacing them easier without affecting the application’s core business logic.
The application layer is a class library project located inside the Core folder. The application layer should reference the domain layer.
Application Layer Structure
■ Repositories
The Repositories folder contains repository interfaces. A repository interface defines the methods to access the data, such as reading, creating, updating, and deleting data. The interface of the unit of work is also located inside the Repositories folder.
Repositories Folder Structure
// IBaseRepository interface
using CleanArchitecture.Domain.Common;
namespace CleanArchitecture.Application.Repositories;
public interface IBaseRepository<T> where T : BaseEntity
{
void Create(T entity);
void Update(T entity);
void Delete(T entity);
Task<T> Get(Guid id, CancellationToken cancellationToken);
Task<List<T>> GetAll(CancellationToken cancellationToken);
}
// IUserRepository interface
using CleanArchitecture.Domain.Entities;
namespace CleanArchitecture.Application.Repositories;
public interface IUserRepository : IBaseRepository<User>
{
Task<User> GetByEmail(string email, CancellationToken cancellationToken);
}
// IUnitOfWork interface
namespace CleanArchitecture.Application.Repositories;
public interface IUnitOfWork
{
Task Save(CancellationToken cancellationToken);
}
■ Features
The Features folder inside the application layer is a standard convention in Clean Architecture that provides a way to organize the application code by functional feature. This makes it easier to understand the application’s overall structure and maintain the codebase over time.
In the Features folder, each feature is represented by its subfolder, and the code for that feature is organized within that subfolder. The code for a feature might include application services or handlers, data transfer objects (DTOs), mappers, validators, and any other components that are specific to that feature.
Features Folder Structure
Each feature will have Handler, Request, Response, Mapper, and Validator class.
// Handler class
using AutoMapper;
using CleanArchitecture.Application.Repositories;
using CleanArchitecture.Domain.Entities;
using MediatR;
namespace CleanArchitecture.Application.Features.UserFeatures.CreateUser;
public sealed class CreateUserHandler : IRequestHandler<CreateUserRequest, CreateUserResponse>
{
private readonly IUnitOfWork _unitOfWork;
private readonly IUserRepository _userRepository;
private readonly IMapper _mapper;
public CreateUserHandler(IUnitOfWork unitOfWork, IUserRepository userRepository, IMapper mapper)
{
_unitOfWork = unitOfWork;
_userRepository = userRepository;
_mapper = mapper;
}
public async Task<CreateUserResponse> Handle(CreateUserRequest request, CancellationToken cancellationToken)
{
var user = _mapper.Map<User>(request);
_userRepository.Create(user);
await _unitOfWork.Save(cancellationToken);
return _mapper.Map<CreateUserResponse>(user);
}
}
// Request class
using MediatR;
namespace CleanArchitecture.Application.Features.UserFeatures.CreateUser;
public sealed record CreateUserRequest(string Email, string Name) : IRequest<CreateUserResponse>;
// Response class
namespace CleanArchitecture.Application.Features.UserFeatures.CreateUser;
public sealed record CreateUserResponse
{
public Guid Id { get; set; }
public string Email { get; set; }
public string Name { get; set; }
}
// Mapper class
using AutoMapper;
using CleanArchitecture.Domain.Entities;
namespace CleanArchitecture.Application.Features.UserFeatures.CreateUser;
public sealed class CreateUserMapper : Profile
{
public CreateUserMapper()
{
CreateMap<CreateUserRequest, User>();
CreateMap<User, CreateUserResponse>();
}
}
// Validator class
using FluentValidation;
namespace CleanArchitecture.Application.Features.UserFeatures.CreateUser;
public sealed class CreateUserValidator : AbstractValidator<CreateUserRequest>
{
public CreateUserValidator()
{
RuleFor(x => x.Email).NotEmpty().MaximumLength(50).EmailAddress();
RuleFor(x => x.Name).NotEmpty().MinimumLength(3).MaximumLength(50);
}
}
Several libraries need to be used to achieve the “featurized” structure:
- MediatR is a library to implement the mediator pattern in applications. The mediator pattern is a software design pattern that provides a centralized place to manage communication between different components in an application.
- AutoMapper is a library that provides a simple and flexible way to map objects of different types. It is commonly used in applications that need to convert data from one format to another, such as from domain objects to data transfer objects (DTOs) or from DTOs to domain objects.
- FluentValidation is a library to validate objects and ensures that they conform to a set of rules.
■ Common
We can use the Common folder to store “mixin” content such as helpers, exceptions, behaviours, etc.
// BadRequestException class
namespace CleanArchitecture.Application.Common.Exceptions;
public class BadRequestException : Exception
{
public BadRequestException(string message) : base(message)
{
}
public BadRequestException(string[] errors) : base("Multiple errors occurred. See error details.")
{
Errors = errors;
}
public string[] Errors { get; set; }
}
// NotFoundException class
namespace CleanArchitecture.Application.Common.Exceptions;
public class NotFoundException : Exception
{
public NotFoundException(string message) : base(message)
{
}
}
// MediatR ValidationBehavior class
using CleanArchitecture.Application.Common.Exceptions;
using FluentValidation;
using MediatR;
namespace CleanArchitecture.Application.Common.Behaviors;
public sealed class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
if (!_validators.Any()) return await next();
var context = new ValidationContext<TRequest>(request);
var errors = _validators
.Select(x => x.Validate(context))
.SelectMany(x => x.Errors)
.Where(x => x != null)
.Select(x => x.ErrorMessage)
.Distinct()
.ToArray();
if (errors.Any())
throw new BadRequestException(errors);
return await next();
}
}
■ ServiceExtensions
The ServiceExtensions class contains an extensions method to configure the dependency injection for the application layer:
using System.Reflection;
using CleanArchitecture.Application.Common.Behaviors;
using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
namespace CleanArchitecture.Application;
public static class ServiceExtensions
{
public static void ConfigureApplication(this IServiceCollection services)
{
services.AddAutoMapper(Assembly.GetExecutingAssembly());
services.AddMediatR(Assembly.GetExecutingAssembly());
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
}
}
Infrastructure Layer
The infrastructure layer is a component of Clean Architecture that implements the technical details of an application, such as data access, logging, email, and other communication mechanisms. The infrastructure layer interacts with external systems and technologies, such as databases, APIs, or cloud services.
This layer should contain the implementation details of the application’s infrastructure, such as the implementation of data access repositories or the logging system. The infrastructure layer should not contain any business logic or domain knowledge and should only interact with the domain layer through the application layer.
The goal of the infrastructure layer is to encapsulate the technical details of the application so that they can be easily changed or replaced without affecting the rest of the application. This layer should use abstractions and interfaces to interact with the application layer, making it possible to change the implementation of a specific component without affecting the rest of the application.
Inside the Infrastructure folder, there will be a class library named Persistence that contains the implementation of repository interfaces. The Persistence project should reference the application layer.
Infrastructure Layer Structure
The Persistence project contains the Context, Repositories, and Migrations folder.
■ Context
The Context folder contains Entity Framework (or other ORM) context class.
// Entity Framework DataContext class
using CleanArchitecture.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace CleanArchitecture.Persistence.Context;
public class DataContext : DbContext
{
public DataContext(DbContextOptions<DataContext> options) : base(options)
{
}
public DbSet<User> Users { get; set; }
}
■ Repositories
The Repositories folder contains the implementation of the repository and unit of work interfaces.
// BaseRepository class
using CleanArchitecture.Application.Repositories;
using CleanArchitecture.Domain.Common;
using CleanArchitecture.Persistence.Context;
using Microsoft.EntityFrameworkCore;
namespace CleanArchitecture.Persistence.Repositories;
public class BaseRepository<T> : IBaseRepository<T> where T : BaseEntity
{
protected readonly DataContext Context;
public BaseRepository(DataContext context)
{
Context = context;
}
public void Create(T entity)
{
Context.Add(entity);
}
public void Update(T entity)
{
Context.Update(entity);
}
public void Delete(T entity)
{
entity.DateCreated = DateTimeOffset.UtcNow;
Context.Update(entity);
}
public Task<T> Get(Guid id, CancellationToken cancellationToken)
{
return Context.Set<T>().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
}
public Task<List<T>> GetAll(CancellationToken cancellationToken)
{
return Context.Set<T>().ToListAsync(cancellationToken);
}
}
// UserRepository class
using CleanArchitecture.Application.Repositories;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Persistence.Context;
using Microsoft.EntityFrameworkCore;
namespace CleanArchitecture.Persistence.Repositories;
public class UserRepository : BaseRepository<User>, IUserRepository
{
public UserRepository(DataContext context) : base(context)
{
}
public Task<User> GetByEmail(string email, CancellationToken cancellationToken)
{
return Context.Users.FirstOrDefaultAsync(x => x.Email == email, cancellationToken);
}
}
// UnitOfWork class
using CleanArchitecture.Application.Repositories;
using CleanArchitecture.Persistence.Context;
namespace CleanArchitecture.Persistence.Repositories;
public class UnitOfWork : IUnitOfWork
{
private readonly DataContext _context;
public UnitOfWork(DataContext context)
{
_context = context;
}
public Task Save(CancellationToken cancellationToken)
{
return _context.SaveChangesAsync(cancellationToken);
}
}
■ ServiceExtensions
The ServiceExtensions class contains an extensions method to configure the dependency injection for the infrastructure layer:
using CleanArchitecture.Application.Repositories;
using CleanArchitecture.Persistence.Context;
using CleanArchitecture.Persistence.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace CleanArchitecture.Persistence;
public static class ServiceExtensions
{
public static void ConfigurePersistence(this IServiceCollection services, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString("Sqlite");
services.AddDbContext<DataContext>(opt => opt.UseSqlite(connectionString));
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddScoped<IUserRepository, UserRepository>();
}
}
Presentation Layer
The presentation layer is a component of Clean Architecture responsible for sending responses and receiving user requests. It is the outermost layer of an application, and it is the layer that interacts directly with the end user.
The presentation layer can be implemented using various technologies, such as a Web API, gRPC, or Web Application.
The presentation layer should not contain business logic or domain knowledge and should only interact with the rest of the application through the application layer. The goal of this layer is to present the application’s functionality to the user and receive user input without being tied to any specific implementation details or technologies.
The Presentation layer contains the WebAPI project that provides a way to create and expose RESTful web services. The WebAPI project should reference the application and infrastructure layer.
Presentation Layer Structure
■ Controllers
In a Web API project, a controller is a class that handles incoming HTTP requests and returns the appropriate HTTP response. The controller acts as an intermediary between the client and the server-side logic, handling the incoming requests, processing them, and returning the proper response.
using CleanArchitecture.Application.Features.UserFeatures.CreateUser;
using CleanArchitecture.Application.Features.UserFeatures.GetAllUser;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace CleanArchitecture.WebAPI.Controllers;
[ApiController]
[Route("user")]
public class UserController : ControllerBase
{
private readonly IMediator _mediator;
public UserController(IMediator mediator)
{
_mediator = mediator;
}
[HttpGet]
public async Task<ActionResult<List<GetAllUserResponse>>> GetAll(CancellationToken cancellationToken)
{
var response = await _mediator.Send(new GetAllUserRequest(), cancellationToken);
return Ok(response);
}
[HttpPost]
public async Task<ActionResult<CreateUserResponse>> Create(CreateUserRequest request,
CancellationToken cancellationToken)
{
var response = await _mediator.Send(request, cancellationToken);
return Ok(response);
}
}
■ Extensions
The Extensions folder contains the extensions method class and configurations for the Web API project, such as the global error handler, CORS policy, behaviours, etc.
// API Behavior configuration
using Microsoft.AspNetCore.Mvc;
namespace CleanArchitecture.WebAPI.Extensions;
public static class ApiBehaviorExtensions
{
public static void ConfigureApiBehavior(this IServiceCollection services)
{
services.Configure<ApiBehaviorOptions>(options =>
{
options.SuppressModelStateInvalidFilter = true;
});
}
}
// CORS policy configuration
namespace CleanArchitecture.WebAPI.Extensions;
public static class CorsPolicyExtensions
{
public static void ConfigureCorsPolicy(this IServiceCollection services)
{
services.AddCors(opt =>
{
opt.AddDefaultPolicy(builder => builder
.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader());
});
}
}
// Global error handler configuration
using System.Net;
using System.Text.Json;
using CleanArchitecture.Application.Common.Exceptions;
using Microsoft.AspNetCore.Diagnostics;
namespace CleanArchitecture.WebAPI.Extensions;
public static class ErrorHandlerExtensions
{
public static void UseErrorHandler(this IApplicationBuilder app)
{
app.UseExceptionHandler(appError =>
{
appError.Run(async context =>
{
var contextFeature = context.Features.Get<IExceptionHandlerFeature>();
if (contextFeature == null) return;
context.Response.Headers.Add("Access-Control-Allow-Origin", "*");
context.Response.ContentType = "application/json";
context.Response.StatusCode = contextFeature.Error switch
{
BadRequestException => (int) HttpStatusCode.BadRequest,
OperationCanceledException => (int) HttpStatusCode.ServiceUnavailable,
NotFoundException => (int) HttpStatusCode.NotFound,
_ => (int) HttpStatusCode.InternalServerError
};
var errorResponse = new
{
statusCode = context.Response.StatusCode,
message = contextFeature.Error.GetBaseException().Message
};
await context.Response.WriteAsync(JsonSerializer.Serialize(errorResponse));
});
});
}
}
■ Program.cs
The Program.cs file is the entry point of the application. The Program.cs is where services required by the application are configured, and the application request handling pipeline is defined.
// Program.cs
using CleanArchitecture.Application;
using CleanArchitecture.Persistence;
using CleanArchitecture.Persistence.Context;
using CleanArchitecture.WebAPI.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigurePersistence(builder.Configuration);
builder.Services.ConfigureApplication();
builder.Services.ConfigureApiBehavior();
builder.Services.ConfigureCorsPolicy();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
var serviceScope = app.Services.CreateScope();
var dataContext = serviceScope.ServiceProvider.GetService<DataContext>();
dataContext?.Database.EnsureCreated();
app.UseSwagger();
app.UseSwaggerUI();
app.UseErrorHandler();
app.UseCors();
app.MapControllers();
app.Run();
■ appsettings.json
The appsettings.json stores the application configuration settings, such as database connection strings, service endpoints, application scope global variables, and other configurations.
{
"ConnectionStrings": {
"Sqlite": "Data Source=users.db"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
• • •
Clean Architecture is a software design pattern that prioritizes the separation of concerns and separates an application into distinct layers. By applying Clean Architecture to ASP.NET Core Web API, you can build scalable, maintainable, and testable Web API that is easier to manage and evolve.
The source code of this article can be found here: https://github.com/juldhais/CleanArchitecture