Clean Architecture

In questo articolo vi parlerò di come andare ad organizzare un progetto secondo i principi della Clean Architecture, tenendo in considerazione anche i principi SOLID visti nell’articolo precedente (se non lo avessi fatto ti consiglio di leggere prima l’articolo Principi di programmazione SOLID).

La Clean Architecture, ideata da Robert C. Martin (conosciuto anche come Uncle Bob), rappresenta un approccio strutturale per la progettazione del software. L’obiettivo è separare le responsabilità e mantenere l’indipendenza tra le diverse componenti del sistema, facilitando la manutenzione, l’estendibilità ed i test.

Prima di vedere come nel concreto organizzare un progetto secondo questi principi vediamo di spiegare cosa è e quali vantaggi ci sono nell’utilizzarla.

Cos’è la Clean Architecture?

La Clean Architecture si basa su un insieme di principi che aiutano a definire un sistema software modulare e scalabile. La struttura è generalmente rappresentata come una serie di cerchi concentrici, ciascuno dei quali rappresenta un livello del sistema. Le dipendenze tra i livelli seguono una direzione unidirezionale: dall’esterno verso l’interno.

Vediamo di fare chiarezza riguardo al disegno:

  • Entities: Contengono la logica di business pura e le regole fondamentali dell’applicazione. In questo articolo è rappresentato dal progetto Domain.
  • Use Cases: Definiscono la logica specifica del caso d’uso, orchestrando le operazioni per soddisfare un requisito di business. In questo articolo sarà il progetto Core.
  • Controllers, Presenters, Gateways: Implementano l’interfaccia tra i casi d’uso e i sistemi esterni (es. database, API, UI). In questo articolo lo chiameremo Infrastructure.
  • DB, UI, Web, Devices, External interfaces: Include i dettagli implementativi come database, framework web, e altre dipendenze esterne. In questo articolo sarà il progetto Api.

Perché usare la Clean Architecture?

Come ho già detto in altri articoli ogni volta che mi trovo a dover scrivere un nuovo progetto mi ritrovo sempre nella sfida intellettuale di dover organizzare in modo efficiente le classi, le interfacce e tutti gli altri elementi di progetto. Ogni volta faccio appello alla mia esperienza, cosa è andato bene e cosa mi ha provocato problemi, soprattutto in quei progetti nati “piccoli” e cresciuti a dismisura nel corso del tempo. La Clean Architecture è una strada che potremmo percorrere, sarà compito mio trasmettervi i suoi pregi ma sarà compito vostro provare ad utilizzarla. Nella mia esperienza, questo approccio mi ha sempre aiutato nella costruzione di servizi scalabili e mantenibili. Vediamo quali sono i suoi vantaggi:

  • Manutenibilità: Grazie alla chiara separazione e modularità, il codice è più semplice da mantenere ed estendere.
  • Separazione delle responsabilità: Ogni livello ha uno scopo ben definito e questo ci permette di sapere sempre dove andare a scrivere gli elementi del nostro progetto.
  • Testabilità: Il cuore dell’applicazione è indipendente dai framework, rendendo più semplice il testing (in un mondo perfetto le applicazioni dovrebbero avere una buona copertura di test, almeno per quanto riguarda le parti core )
  • Indipendenza tecnologica: Il sistema non dipende da framework o tecnologie specifiche, permettendo modifiche future senza impatti significativi. Possiamo partire a scrivere l’applicazione ignorando il fatto che ci serva un database o scrivendo il software in modo tale che il suo comportamento non cambi in relazione alla tipologia di database.

Se quanto vi ho descritto fino adesso vi è sembrato sensato e ha smosso qualcosa nel vostro cuore da sviluppatore allora proseguiamo. Se ancora non siete convinti vi racconto una breve storia:

Massimo è un mio collega e mentre scrivo questo articolo si sta occupando di sviluppare un software per conto di un cliente. Massimo sta “provando” a strutturare il progetto con la clean architecture per la prima volta e il cliente è molto indeciso su quello che vuole: ad ogni incontro periodico cambia quanto detto nell’incontro precedente e Massimo è costretto a “rivedere” la sua applicazione (😭).

Su 9 incontri è stato necessario rivedere l’applicazione 9 volte, ma nonostante questo il codice è rimasto pulito, ordinato e strutturato, tutto questo grazie a questa architettura.

Questa metodologia non ci permette solo di organizzare la nostra solution, ma ci permette anche di gestire in modo efficiente le modifiche e le evolutive del software.

Come strutturare un’applicazione

Per questo articolo scriveremo la base di un software partendo dall’obiettivo qui sotto:

Obiettivo: creare un’applicazione API REST che esponga un metodo per poter generare un report PDF sui case history gestiti da un utente. L’applicazione deve prevedere due possibili impaginazioni e grafiche del report (tema chiaro e tema scuro). I dati dei report dovranno essere recuperati da un database locale, per quanto riguarda la trasformazione del report in PDF ci affideremo ad un servizio esterno.

Partiamo dalla nocciolina centrale creando un progetto chiamato ThinkAsADevReport.Domain (cerchio Entities). In questo progetto inseriremo le entità del nostro progetto, creiamo quindi una directory chiamata Entities, al suo interno creiamo un record che rappresenti un Report e uno che rappresenti un Case History.

Nota: a partire da C# 12 (.NET 8+) è buona norma preferire i record alle class per le entità immutabili. I record offrono immutabilità per default, uguaglianza strutturale automatica e una sintassi più concisa, caratteristiche particolarmente adatte per le entità del dominio.

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
);

Nota: utilizziamo DateTime.UtcNow al posto di DateTime.Now per evitare dipendenze dall’ora locale del server, una best practice quando si lavora con le date.

Ora andiamo a creare un nuovo progetto chiamato ThinkAsADevReport.Core che conterrà la logica di business (corrisponde al cerchio Use Cases).

In questo progetto andiamo a creare 3 cartelle:

  • Interfaces: qui inseriremo le interfacce dei servizi dell’applicazione che ci serviranno per gestire la dependency injection
  • Services: conterrà tutte le classi che implementano le interfacce con le logiche di business dell’applicazione
  • Settings: inseriremo le classi per andare a gestire alcune configurazioni del provider di stampa PDF

Gestione degli errori con Result<T>

Prima di passare alle interfacce vale la pena fare una considerazione. In un’applicazione reale le cose non vanno sempre per il verso giusto: il servizio esterno potrebbe non rispondere, il database potrebbe non trovare i dati, l’input potrebbe essere non valido. Gestire questi scenari lanciando eccezioni ovunque non è la scelta migliore: le eccezioni hanno un costo e rendono il flusso di esecuzione difficile da seguire.

Una soluzione elegante e sempre più diffusa nel mondo .NET è il Result Pattern, che consiste nel restituire esplicitamente dal metodo sia il risultato atteso che l’eventuale errore, senza ricorrere alle eccezioni per i casi di business. Creiamo quindi un semplice Result<T> nel progetto ThinkAsADevReport.Core:

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);
}

Adesso tutte le nostre interfacce restituiranno un Result<T> al posto del tipo diretto, rendendo esplicito al chiamante che l’operazione potrebbe non andare a buon fine.

Partiamo dalle interfacce: sicuramente ci servirà un’interfaccia per andare a gestire il recupero dei dati del report:

C#
namespace ThinkAsADevReport.Core.Interfaces;

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

Ci servirà anche un’interfaccia per il recupero dei dati storici dell’utente dal database:

C#
namespace ThinkAsADevReport.Core.Interfaces;

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

Come facciamo a gestire la grafica del report? Gestiamo il tutto all’interno della classe appena creata, magari passando la grafica come parametro della funzione?

Prima di rispondere ripensiamo ai principi SOLID, nello specifico il principio SRP: ogni classe deve avere una sola responsabilità, cioè un solo motivo per cambiare. La mia proposta era ovviamente una provocazione e non sarebbe corretto perché in quel caso andremmo a gestire sia la generazione del report che la sua grafica all’interno della stessa classe, violando il principio appena menzionato.

Creiamo quindi una nuova interfaccia per gestire questo aspetto:

C#
namespace ThinkAsADevReport.Core.Interfaces;

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

Spostiamoci quindi nella cartellina Services (sempre del progetto ThinkAsADevReport.Core) e creiamo l’implementazione di IReportGenerator.

Nota: a partire da C# 12 (.NET 8+) è possibile utilizzare i primary constructors per dichiarare le dipendenze direttamente nella firma della classe, eliminando il boilerplate del costruttore esplicito e dei campi privati. Il compilatore si occuperà di rendere disponibili i parametri nell’intero corpo della classe.

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);
    }
}

Ovviamente non andiamo ad implementare tutta la logica poiché non servirebbe ai fini di questo esempio, ma è importante notare come il Result<T> ci permetta di gestire i casi di errore in modo esplicito e leggibile, senza dover ricorrere alle eccezioni.

Vi pongo ora un’altra domanda trabocchetto: l’implementazione di IReportSaver andrà inserita in questo progetto? La risposta è: dipende!

Se decidessimo di scrivere una classe per andare a realizzare un PDF allora la inseriremmo qui. In questo articolo abbiamo ipotizzato di utilizzare un servizio esterno che ci ritornerà un PDF. In questo caso, la classe che implementerà l’interfaccia IReportSaver si occuperà di andare a contattare un servizio esterno (che inventerò e si chiamerà JunglePdfConverter) e quindi la classe andrà creata nel progetto ThinkAsADevReport.Infrastructure.

È quindi giunto il momento di andare ad aggiungere un nuovo progetto alla nostra solution, chiamato ThinkAsADevReport.Infrastructure. In questo progetto andremo ad inserire le classi che si occupano di collegarsi a servizi esterni, come il database o i servizi per la generazione dei PDF. Creiamo al suo interno due directory per organizzare al meglio i sorgenti:

  • Repositories: qui inseriremo le implementazioni delle interfacce che si occuperanno di recuperare le informazioni dalla base dati della nostra applicazione.
  • Services: qui invece inseriremo tutti i servizi di collegamento con i servizi esterni, per esempio quello che si occupa di creare il PDF, di inviare le email, di salvare dati in un blob storage, ecc.

Andiamo a completare aggiungendo le relative classi:

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);
    }
}

La classe UserCaseHistoryRepository si occuperà di recuperare dal database i dati richiesti mentre la classe JunglePdfConverterService si occuperà di richiamare le API del servizio “JunglePdfConverter” (nome inventato!) il quale ritornerà un byte[].

Un cenno al pattern CQRS

Prima di passare al progetto Api vale la pena spendere due parole su un pattern che si abbina molto bene alla Clean Architecture: il CQRS (Command Query Responsibility Segregation). L’idea di fondo è semplice: separare le operazioni di lettura (query) da quelle di scrittura (command), ognuna con il proprio modello e la propria logica. Questo si traduce in un codice ancora più focalizzato e manutenibile, dove ogni caso d’uso è incapsulato in un oggetto dedicato. In questo articolo non lo implementeremo per mantenere il focus sulla Clean Architecture, ma è una strada che vi consiglio di esplorare: librerie come MediatR rendono l’adozione di CQRS in .NET estremamente semplice e si integrano perfettamente con la struttura che stiamo costruendo.

Il progetto Api con Minimal API

Cosa ci manca? Il progetto API! In .NET 10+ la strada consigliata per le API è quella delle Minimal API: rispetto ai controller tradizionali offrono meno boilerplate, performance migliori e una struttura più vicina ai principi di Clean Architecture, poiché la logica rimane nei layer sottostanti e il layer Api si occupa solo di esporre gli endpoint.

Aggiungiamo alla nostra solution un progetto API che chiameremo ThinkAsADevReport.Api. Invece di un controller, creiamo una directory Endpoints con una classe dedicata alla registrazione degli endpoint del report:

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");
            });
    }
}

Grazie al Result<T> possiamo ora gestire i casi di errore in modo pulito direttamente nell’endpoint, restituendo al client una risposta appropriata senza dover ricorrere a eccezioni non gestite.

In ultimo bisogna configurare la dependency injection dei vari servizi e registrare gli endpoint. Spostiamoci nel file Program.cs (del progetto ThinkAsADevReport.Api):

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();

Prima di concludere facciamo qualche considerazione sui principi SOLID usati in questo progetto. Abbiamo separato le classi, in modo tale che ognuna si occupasse di una cosa specifica (principio SRP) e abbiamo organizzato i servizi con le relative interfacce specifiche (principio ISP). Infine il codice è organizzato in modo tale che ogni elemento dipenda dalle interfacce e non dalle implementazioni di esse (principio DIP).

Se un domani cambiasse la tecnologia database dovremmo riscrivere solamente la classe UserCaseHistoryRepository e non cambierebbe altro! Se un domani dovessimo utilizzare un altro servizio per la generazione del PDF dovremmo semplicemente riscrivere l’implementazione dell’interfaccia IReportSaver. Se domani dovesse nascere l’esigenza di avere due progetti web distinti dovremmo solamente aggiungere un nuovo progetto e sfruttare tutti gli altri che già ci sono!

Conclusioni

In questo articolo abbiamo visto come la Clean Architecture fornisce un approccio robusto e modulare per sviluppare applicazioni scalabili e di facile manutenzione. Separando la logica di business dai dettagli implementativi, ogni componente del sistema risulta più semplice da testare, modificare ed estendere nel tempo. L’adozione di pattern moderni come Result<T>, i primary constructors e le Minimal API ci permette di scrivere un codice ancora più pulito ed espressivo, senza mai perdere di vista l’obiettivo principale: un’architettura che sappia resistere al cambiamento.

Condividi questo articolo
Shareable URL
Post precedente

AI Locale in VS Code con continue

Prosimo post

Minimal api VS controller

Leggi il prossimo articolo