Clean Architecture

In this article I’ll walk you through how to structure a project around Clean Architecture principles, with an eye on the SOLID principles we covered in the previous article (if you missed it, I’d suggest reading that one first before diving in here).

Clean Architecture was introduced by Robert C. Martin, also known as Uncle Bob, and it’s a structural approach to software design. The core idea is to separate responsibilities and keep the different parts of a system decoupled from each other, so the codebase stays easier to maintain, extend, and test.

Before jumping into the practical side, let’s take a moment to clarify what Clean Architecture actually is and why it’s worth using.

What is Clean Architecture?

Clean Architecture is built on a set of principles that help you design a modular and scalable system. The structure is usually pictured as a series of concentric circles, where each ring represents a layer of the application. The key rule is that dependencies only flow in one direction: from the outside in.

Let’s break down the diagram:

  • Entities: Contain the pure business logic and the core rules of the application. In this article, this is represented by the domain project.
  • Use Cases: Define the specific logic for each use case, orchestrating operations to fulfill a business requirement. In this article, this will be the core project.
  • Controllers, Presenters, Gateways: Implement the interface between use cases and external systems (e.g. databases, APIs, UI). In this article, we’ll call this Infrastructure.
  • DB, UI, Web, Devices, External interfaces: Include implementation details like databases, web frameworks, and other external dependencies. In this article, this will be the Api project.

Why use Clean Architecture?

As I’ve mentioned in other articles, every time I start a new project I face the same intellectual challenge: how do I organize classes, interfaces, and everything else in an efficient way? I always draw on my own experience, thinking about what worked and what caused problems, especially in those projects that started small and grew massively over time. Clean Architecture is one path you can take. My job is to show you its strengths, yours is to actually try it. In my experience, this approach has consistently helped me build scalable and maintainable services. Here are the main advantages:

  • Maintainability: Thanks to clear separation and modularity, the code is easier to maintain and extend.
  • Separation of concerns: Each layer has a well-defined purpose, so you always know where to put things.
  • Testability: The core of the application is independent from any framework, which makes testing much simpler. In an ideal world, applications should have solid test coverage, at least for the core parts.
  • Technology independence: The system doesn’t depend on specific frameworks or technologies, so future changes won’t have a big impact. You can start building the application without even thinking about what database you’ll use, or write the code in a way that its behavior doesn’t change regardless of the database type.

If what I’ve described so far makes sense and resonates with your developer instincts, let’s keep going. If you’re still not convinced, let me tell you a quick story.

Massimo is a colleague of mine, and while I’m writing this article he’s working on a software project for a client. Massimo is trying out Clean Architecture for the first time, and the client keeps changing their mind at every meeting, forcing Massimo to revisit the application each time (😭).

Out of 9 meetings, the application had to be revised 9 times, but despite that the code stayed clean, organized, and well-structured, all thanks to this architecture.

This approach doesn’t just help you organize your solution, it also helps you handle changes and new requirements efficiently.

How to structure an application

For this article we’ll build the foundation of a software project based on the following goal:

Goal: create a REST API application that exposes an endpoint to generate a PDF report on the case histories managed by a user. The application should support two different layouts and themes (light and dark). Report data will be retrieved from a local database, while the PDF conversion will be handled by an external service.

Let’s start from the innermost circle by creating a project called ThinkAsADevReport.Domain (the Entities circle). This project will hold our domain entities, so let’s create a directory called Entities and inside it define a record for a Report and one for a Case History.

Note: starting from C# 12 (.NET 8+), it’s good practice to prefer records over classes for immutable entities. Records provide immutability by default, automatic structural equality, and a more concise syntax, all of which make them a natural fit for domain entities.

C#
namespace ThinkAsADevReport.Domain.Entities;

public record ReportModel(
    string Title,
    string Description,
    string Author,
    DateTime GenerationDate
);
C#
namespace ThinkAsADevReport.Domain.Entities;

public record UserCaseHistoryModel(
    string Username,
    string Action,
    DateTime ActionDate
);

Note: use DateTime.UtcNow instead of DateTime.Now to avoid depending on the server’s local time. This is a best practice when working with dates.

Now let’s create a new project called ThinkAsADevReport.Core, which will contain the business logic (this corresponds to the Use Cases circle).

Inside this project we’ll create 3 folders:

  • Interfaces: this is where we’ll put the service interfaces used for dependency injection.
  • Services: this will contain all the classes that implement those interfaces with the actual business logic.
  • Settings: this is where we’ll put configuration classes for the PDF printing provider.

Error handling with Result

Before moving on to the interfaces, it’s worth a quick detour. In a real application, things don’t always go as planned: an external service might be down, the database might return no data, or the input might be invalid. Throwing exceptions everywhere isn’t the best approach since exceptions are costly and make the execution flow hard to follow.

An elegant and increasingly common solution in the .NET world is the Result Pattern, which means explicitly returning both the expected result and any potential error from a method, without using exceptions for business-level failures. Let’s create a simple Result<T> in the ThinkAsADevReport.Core project:

C#
namespace ThinkAsADevReport.Core.Common;

public class Result<T>
{
    public T? Value { get; }
    public string? ErrorMessage { get; }
    public bool IsSuccess { get; }

    private Result(T value)
    {
        Value = value;
        IsSuccess = true;
    }

    private Result(string errorMessage)
    {
        ErrorMessage = errorMessage;
        IsSuccess = false;
    }

    public static Result<T> Success(T value) => new(value);
    public static Result<T> Failure(string errorMessage) => new(errorMessage);
}

From now on, all our interfaces will return a Result<T> instead of a direct type, making it explicit to the caller that the operation might fail.

Let’s start with the interfaces. We’ll definitely need one for fetching report data:

C#
namespace ThinkAsADevReport.Core.Interfaces;

public interface IReportGenerator
{
    Task<Result<ReportModel>> GenerateReport(string username);
}

We’ll also need one for fetching the user’s historical data from the database:

C#
namespace ThinkAsADevReport.Core.Interfaces;

public interface IUserCaseHistoryRepository
{
    Task<Result<IEnumerable<UserCaseHistoryModel>>> GetUserCaseHistory(string username);
}

Now, how do we handle the report theme? Should we manage it inside the class we just created, maybe passing the theme as a parameter?

Before answering, let’s think back to the SOLID principles, specifically SRP: every class should have a single responsibility, meaning only one reason to change. My suggestion was obviously a trap. It would be wrong because we’d be handling both report generation and its visual theme in the same class, which violates SRP.

So let’s create a separate interface for this:

C#
namespace ThinkAsADevReport.Core.Interfaces;

public interface IReportSaver
{
    Task<Result<byte[]>> ExportReport(ReportModel report, string color, string fileFormat);
}

Now let’s move to the Services folder (still inside ThinkAsADevReport.Core) and implement IReportGenerator.

Note: starting from C# 12 (.NET 8+), you can use primary constructors to declare dependencies directly in the class signature, eliminating the boilerplate of explicit constructors and private fields. The compiler will make the parameters available throughout the entire class body.

C#
namespace ThinkAsADevReport.Core.Services;

public class ReportGeneratorService(
    IUserCaseHistoryRepository caseHistoryRepository,
    ILogger<ReportGeneratorService> logger
) : IReportGenerator
{
    public async Task<Result<ReportModel>> GenerateReport(string username)
    {
        if (string.IsNullOrWhiteSpace(username))
            return Result<ReportModel>.Failure("Il parametro username non può essere vuoto.");

        var caseHistoryResult = await caseHistoryRepository.GetUserCaseHistory(username);
        if (!caseHistoryResult.IsSuccess)
        {
            logger.LogError("Errore nel recupero dei case history per l'utente {Username}: {Error}",
                username, caseHistoryResult.ErrorMessage);
            return Result<ReportModel>.Failure(caseHistoryResult.ErrorMessage!);
        }

        // Business logic
        var report = new ReportModel(
            Title: $"Report di {username}",
            Description: "Case history dell'utente",
            Author: username,
            GenerationDate: DateTime.UtcNow
        );

        return Result<ReportModel>.Success(report);
    }
}

We’re not implementing the full logic here since that’s not the point of this example, but notice how Result<T> lets us handle error cases in a clean and readable way, without throwing exceptions.

Now another trick question: should the implementation of IReportSaver go in this project? The answer is: it depends!

If we were writing a class to generate a PDF ourselves, then yes. But in this article we’re using an external service that returns a PDF. That means the class implementing IReportSaver will be calling an external service (which I’ll call JunglePdfConverter), so it belongs in ThinkAsADevReport.Infrastructure.

Time to add a new project to our solution: ThinkAsADevReport.Infrastructure. This project will contain all the classes that connect to external systems, like databases or PDF generation services. Let’s create two directories inside it:

  • Repositories: implementations of the interfaces responsible for fetching data from the application’s database.
  • Services: all the connectors to external services, such as the PDF generator, email sender, blob storage, etc.

Let’s add the corresponding classes:

C#
namespace ThinkAsADevReport.Infrastructure.Repositories;

public class UserCaseHistoryRepository(ILogger<UserCaseHistoryRepository> logger)
    : IUserCaseHistoryRepository
{
    public async Task<Result<IEnumerable<UserCaseHistoryModel>>> GetUserCaseHistory(string username)
    {
        // Fetch from database
        var caseHistories = new List<UserCaseHistoryModel>();
        return Result<IEnumerable<UserCaseHistoryModel>>.Success(caseHistories);
    }
}
C#
namespace ThinkAsADevReport.Infrastructure.Services;

public class JunglePdfConverterService(ILogger<JunglePdfConverterService> logger)
    : IReportSaver
{
    public async Task<Result<byte[]>> ExportReport(ReportModel report, string color, string fileFormat)
    {
        // send report to online service
        // get byte array
        var documentByte = Array.Empty<byte>();
        return Result<byte[]>.Success(documentByte);
    }
}

UserCaseHistoryRepository takes care of fetching data from the database, while JunglePdfConverterService calls the JunglePdfConverter API (totally made-up name!) and gets back a byte[].

A note on the CQRS pattern

Before moving on to the Api project, it’s worth briefly mentioning a pattern that pairs really well with Clean Architecture: CQRS (Command Query Responsibility Segregation). The idea is straightforward: separate read operations (queries) from write operations (commands), each with its own model and logic. This leads to more focused and maintainable code, where every use case is encapsulated in a dedicated object. We won’t implement it in this article to keep the focus on Clean Architecture, but it’s definitely worth exploring. Libraries like MediatR make adopting CQRS in .NET very straightforward and integrate seamlessly with the structure we’re building.

The Api project with Minimal APIs

What’s left? The API project! In .NET 10+ the recommended approach for APIs is Minimal APIs: compared to traditional controllers they have less boilerplate, better performance, and a structure that fits naturally with Clean Architecture, since the business logic stays in the lower layers and the Api layer is only responsible for exposing endpoints.

Let’s add an API project to our solution called ThinkAsADevReport.Api. Instead of a controller, we’ll create an Endpoints directory with a class dedicated to registering the report endpoints:

C#
namespace ThinkAsADevReport.Api.Endpoints;

public static class ReportEndpoints
{
    public static void MapReportEndpoints(this WebApplication app)
    {
        app.MapGet("api/reports/{username}/user-case-histories",
            async (
                string username,
                IReportGenerator reportGenerator,
                IReportSaver reportSaver
            ) =>
            {
                var reportResult = await reportGenerator.GenerateReport(username);
                if (!reportResult.IsSuccess)
                    return Results.BadRequest(reportResult.ErrorMessage);

                var pdfResult = await reportSaver.ExportReport(reportResult.Value!, "dark", "pdf");
                if (!pdfResult.IsSuccess)
                    return Results.Problem(pdfResult.ErrorMessage);

                return Results.File(pdfResult.Value!, "application/pdf", "report.pdf");
            });
    }
}

Thanks to Result<T>, we can now handle error cases cleanly right inside the endpoint, returning the appropriate response to the client without relying on unhandled exceptions.

Finally, we need to configure dependency injection and register the endpoints. Let’s move to Program.cs in the ThinkAsADevReport.Api project:

C#
var builder = WebApplication.CreateBuilder(args);

// Registrazione dei servizi
// Utilizziamo AddScoped per tutti i servizi: ogni richiesta HTTP otterrà
// la propria istanza, che verrà rilasciata al termine della richiesta stessa.
// È la scelta corretta per servizi che accedono a risorse come database
// o connessioni HTTP che non devono essere condivise tra richieste diverse.
builder.Services.AddScoped<IUserCaseHistoryRepository, UserCaseHistoryRepository>();
builder.Services.AddScoped<IReportGenerator, ReportGeneratorService>();
builder.Services.AddScoped<IReportSaver, JunglePdfConverterService>();

var app = builder.Build();

// Registrazione degli endpoint
app.MapReportEndpoints();

app.Run();

Before wrapping up, let’s reflect on the SOLID principles we applied in this project. We split classes so that each one handles exactly one thing (SRP), and we organized services with focused, specific interfaces (ISP). The code is also structured so that every element depends on interfaces rather than concrete implementations (DIP).

If the database technology changes tomorrow, we only need to rewrite UserCaseHistoryRepository and nothing else changes. If we need to switch the PDF generation service, we simply rewrite the implementation of IReportSaver. And if we ever need two separate web projects, we just add a new one and reuse everything that’s already there.

Conclusions

In this article we saw how Clean Architecture provides a robust and modular approach to building scalable, maintainable applications. By separating business logic from implementation details, every component becomes easier to test, modify, and extend over time. Adopting modern patterns like Result<T>, primary constructors, and Minimal APIs lets us write cleaner and more expressive code, without ever losing sight of the main goal: an architecture that can handle change.

Share this article
Shareable URL
Prev Post

Local AI: LM Studio + VS Code setup guide

Next Post

Minimal api VS controller

Read next

C# 14

At .NET conf 2025, the new version of C# was unveiled. In this article, we’ll explore the new features and…

Ocelot API Gateway

In modern architectures, especially those based on microservices, the API Gateway represents a fundamental…
Api gateway header