As software projects grow from simple prototypes into enterprise-grade systems, maintaining code quality becomes a monumental challenge. Traditional ASP.NET Core templates often encourage a "fat controller" anti-pattern, where business logic, database queries, and web concerns bleed together into a tangled web that is difficult to test, refactor, and scale.

To combat this, modern software engineering relies on architectural patterns like Clean Architecture (popularized by Robert C. Martin / Uncle Bob) and Onion Architecture (introduced by Jeffrey Palermo). Both share the same foundational objective: separation of concerns through strict dependency rules.

In this comprehensive guide, we will explore how to design, structure, and implement a production-ready ASP.NET Core solution using Clean Architecture.

The Core Principle: The Dependency Rule

At the heart of both Clean and Onion Architecture is a single, non-negotiable principle: Source code dependencies must point inward.

Plaintext

External Concerns (UI, DB, Frameworks) 
  --> Infrastructure 
    --> Application (Use Cases) 
      --> Domain (Entities & Rules)
  • The Center (Domain): Contains your core business entities and rules. It knows nothing about databases, web frameworks, or external APIs.

  • The Outer Layers: Contain implementation details (like Entity Framework Core, SQL Server, controllers, and Swagger). They depend on the inner layers, but the inner layers never know the outer layers exist.

Solution Folder Structure

A well-organized enterprise solution separates concerns across four distinct class libraries plus testing projects:

Plaintext

YourSolution.sln
│
├── src/
│   ├── YourSolution.Domain/          # Layer 1: Core Entities & Interfaces
│   ├── YourSolution.Application/     # Layer 2: Business Logic & Use Cases
│   ├── YourSolution.Infrastructure/  # Layer 3: Database & External Services
│   └── YourSolution.API/             # Layer 4: Presentation (Controllers / API)
│
└── tests/
    ├── YourSolution.UnitTests/
    └── YourSolution.IntegrationTests/

Step 1: Create the Solution and Projects

You can set up the entire architecture cleanly using the .NET CLI. Run the following commands in your terminal:

Bash

# Create solution file
dotnet new sln -n YourSolution

# Create the core layers and API project
dotnet new classlib -n YourSolution.Domain
dotnet new classlib -n YourSolution.Application
dotnet new classlib -n YourSolution.Infrastructure
dotnet new webapi -n YourSolution.API

# Add projects to the solution
dotnet sln YourSolution.sln add src/YourSolution.Domain/YourSolution.Domain.csproj
dotnet sln YourSolution.sln add src/YourSolution.Application/YourSolution.Application.csproj
dotnet sln YourSolution.sln add src/YourSolution.Infrastructure/YourSolution.Infrastructure.csproj
dotnet sln YourSolution.sln add src/YourSolution.API/YourSolution.API.csproj

Step 2: Enforce the Inward Dependency Rule

Next, wire up the project references so dependencies flow strictly inward:

  1. Application references Domain:

    Bash

    dotnet add src/YourSolution.Application/YourSolution.Application.csproj reference src/YourSolution.Domain/YourSolution.Domain.csproj
    
  2. Infrastructure references Application (granting transit access to Domain):

    Bash

    dotnet add src/YourSolution.Infrastructure/YourSolution.Infrastructure.csproj reference src/YourSolution.Application/YourSolution.Application.csproj
    
  3. API references both Application and Infrastructure:

    Bash

    dotnet add src/YourSolution.API/YourSolution.API.csproj reference src/YourSolution.Application/YourSolution.Application.csproj
    dotnet add src/YourSolution.API/YourSolution.API.csproj reference src/YourSolution.Infrastructure/YourSolution.Infrastructure.csproj
    

Step 3: Breakdown of the Layers

1. Domain Layer (YourSolution.Domain)

This layer holds enterprise logic. It contains entities, value objects, domain exceptions, and repository interfaces (IProductRepository). It has zero external package dependencies.

C#

namespace YourSolution.Domain.Entities
{
    public class Product
    {
        public Guid Id { get; private set; }
        public string Name { get; private set; } = string.Empty;
        public decimal Price { get; private set; }

        public Product(string name, decimal price)
        {
            Id = Guid.NewGuid();
            Name = name;
            Price = price;
        }
    }
}

2. Application Layer (YourSolution.Application)

This layer handles use cases, commands, queries (often using CQRS patterns with MediatR), Data Transfer Objects (DTOs), and validation rules. It depends only on the Domain layer.

3. Infrastructure Layer (YourSolution.Infrastructure)

This layer implements the interfaces defined in the inner layers. It houses your Entity Framework Core DbContext, database migrations, concrete repository implementations, and external integrations (such as email clients or payment gateways).

4. Presentation / API Layer (YourSolution.API)

The outermost layer. It contains your ASP.NET Core controllers, middleware, authentication setups, Swagger configurations, and the Program.cs entry point.

Step 4: Clean Dependency Injection

To prevent Program.cs from becoming a cluttered mess of service registrations, encapsulate layer-specific registrations inside extension methods.

Example: Infrastructure Registration Extension

Inside your YourSolution.Infrastructure project, create a configuration extension class:

C#

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace YourSolution.Infrastructure
{
    public static class DependencyInjection
    {
        public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
        {
            services.AddDbContext<AppDbContext>(options =>
                options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));

            services.AddScoped<IProductRepository, ProductRepository>();

            return services;
        }
    }
}

Clean Program.cs in the API Layer

Now, your API entry point remains remarkably clean and readable:

C#

var builder = WebApplication.CreateBuilder(args);

// Register layers cleanly via extension methods
builder.Services.AddApplication(); 
builder.Services.AddInfrastructure(builder.Configuration);

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

Summary of Benefits

By adopting Clean or Onion Architecture in your ASP.NET Core projects, you gain:

  • Testability: Business logic can be unit tested instantly without spinning up a database or web server.

  • Maintainability: Clear boundaries ensure that changes in your UI or database framework do not break your core business rules.

  • Flexibility: Swapping out data stores (e.g., moving from SQL Server to PostgreSQL) or UI frontends becomes straightforward.