Modularità in .NET: lo stesso modulo dentro un sistema più grande o come microservizio

· 10 min di lettura

Scritto per .NET 10 · ASP.NET Core 10 · EF Core 10

#tech#dotnet#architettura

Nell’articolo sulle trace seguivamo la firma di un documento: lo storage, il provider di firma remoto, il database, l’Outbox, le notifiche. Restava fuori una domanda: dove vive quel codice?

Oggi la firma digitale serve dentro un gestionale documentale più grande. Domani un altro cliente la vuole come servizio a sé, senza tutto il resto. Copiare il codice in due progetti vuol dire correggere ogni bug due volte, e prima o poi dimenticarne uno. In più la firma con un provider remoto richiede secondi: l’utente non può restare ad aspettare la risposta.

La risposta è un modulo: lo stesso codice, che un host monta accanto ad altri moduli o che gira da solo come microservizio. Per non riscriverlo basta organizzare bene i progetti.

Gli esempi usano un documentale inventato, DocumentaleInventato: il nome è volutamente finto.

Un progetto per livello

DocumentaleInventato.FirmaDigitale.*
src/
  *.Domain/          # le regole
  *.Application/     # casi d'uso e porte
  *.Infrastructure/  # database, storage, provider di firma
  *.Module/          # collega tutto
  *.Api/             # l'host per usarlo da solo
tests/
  *.UnitTests/
  *.IntegrationTests/
  *.ArchitectureTests/

L’asterisco sta per DocumentaleInventato.FirmaDigitale. Ogni livello è un progetto separato, e non solo una cartella, per un motivo preciso: i riferimenti tra progetti hanno una direzione, e il compilatore la fa rispettare. Application non ha un riferimento a Infrastructure: una classe di Application che prova a usare FirmaDbContext o il client del provider non compila.

Progetto Riferisce Contiene
Domain nessuno Le entità e le regole: una richiesta di firma e i suoi stati.
Application Domain I casi d’uso e le porte: le interfacce di ciò che serve dall’esterno.
Infrastructure Application Gli adapter: EF Core, lo storage, il client HTTP del provider.
Module Infrastructure (e, attraverso questo, Application) La dependency injection e le rotte.
Api Module Solo host, middleware e configurazione.

È la stessa idea della clean architecture e di ports and adapters: il centro non conosce i dettagli, sono i dettagli a conoscere il centro.

Architetture comuni delle applicazioni WebDocumentazione ufficiale · learn.microsoft.com

Le porte stanno in Application

Il caso d’uso che firma i documenti ha bisogno dello storage, del provider e di un posto dove scrivere i messaggi da pubblicare. Non sa chi glieli darà: dichiara quello che gli serve con delle interfacce.

Firma.cs
public interface IDocumentStorage
{
    Task<Stream> ApriAsync(Guid documentoId, CancellationToken ct);
    Task SalvaFirmatoAsync(Guid documentoId, Stream firmato, CancellationToken ct);
}

public interface IFirmaProvider
{
    Task<Stream> FirmaAsync(Stream documento, CancellationToken ct);
}

public interface IOutbox
{
    void Accoda(string tipo, object payload);
}

Le implementazioni stanno in Infrastructure: BlobStorage, ArubaFirmaProvider con un HttpClient, EfOutbox che aggiunge una riga a una tabella con EF Core. Cambiare provider di firma vuol dire scrivere un altro adapter, e i casi d’uso non cambiano di una riga. E nei test unitari le porte si sostituiscono con dei finti, senza database e senza rete.

Il progetto Module

Domain, Application e Infrastructure non sanno dove gireranno. Qualcuno deve dire quale adapter va dietro a quale porta, registrare il DbContext, avviare i worker. È il compito del progetto Module, l’unico che li collega con la dependency injection.

All’host offre due metodi: AddFirmaDigitale per i servizi e MapFirmaDigitale per le rotte. Un host che vuole la firma digitale chiama quei due, e nient’altro.

1. Add: i servizi

DependencyInjection.cs
public static IHostApplicationBuilder AddFirmaDigitale(
    this IHostApplicationBuilder builder)
{
    var s = builder.Services;

    // casi d'uso
    s.AddScoped<RichiediFirma>();
    s.AddScoped<LeggiRichiesta>();
    s.AddScoped<EseguiFirme>();

    // porte e adapter
    s.AddScoped<IRichiesteFirmaRepository, RichiesteFirmaRepository>();
    s.AddScoped<IDocumentStorage, BlobStorage>();
    s.AddScoped<IOutbox, EfOutbox>();
    s.AddHttpClient<IFirmaProvider, ArubaFirmaProvider>(c => c.BaseAddress =
        new Uri(builder.Configuration["Firma:ProviderUrl"]!));
    // + l'adapter di INotifichePublisher, verso il broker delle notifiche

    s.AddDbContext<FirmaDbContext>(o => o
        .UseSqlServer(builder.Configuration.GetConnectionString("Firma")));

    // lavoro in background
    s.AddHostedService<FirmaWorker>();
    s.AddHostedService<OutboxDispatcher>();

    return builder;
}

Il modulo ha il suo DbContext e la sua stringa di connessione, Firma. Le sue tabelle sono sue: gli altri moduli dello stesso sistema non le leggono e non ci fanno join, e se hanno bisogno di sapere che un documento è stato firmato lo ricevono come messaggio. È la regola che conta di più il giorno in cui il modulo diventa un microservizio: se un altro modulo fa una query sulle sue tabelle, il modulo non si può più spostare senza riscrivere anche quello.

Perché IHostApplicationBuilder

Fino a .NET 7 un metodo come questo si scriveva su IServiceCollection, e tutto il resto andava passato a mano:

Prima di .NET 8
public static IServiceCollection AddFirmaDigitale(
    this IServiceCollection services, IConfiguration configuration)

Da .NET 8 c’è IHostApplicationBuilder, che porta con sé tutto quello che un modulo può voler configurare: Services, Configuration, Environment, Logging e Metrics. L’indirizzo del provider e la stringa di connessione il modulo li legge da solo, dalla configurazione dell’host. Può scegliere un provider finto quando l’ambiente è Development, o aggiungere i suoi filtri di log: la firma del metodo resta la stessa anche quando gli servirà qualcosa in più.

E soprattutto lo implementano due builder: WebApplicationBuilder, quello delle applicazioni ASP.NET Core, e HostApplicationBuilder, quello dei worker senza HTTP. Lo stesso AddFirmaDigitale funziona in entrambi: un host che deve solo eseguire le firme in background può montare il modulo senza esporre nessuna rotta.

IHostApplicationBuilderDocumentazione ufficiale · .NET 10 · learn.microsoft.com

Un dettaglio da sapere: per le rotte il progetto Module usa ASP.NET Core (<FrameworkReference Include="Microsoft.AspNetCore.App" />), e il riferimento passa a chi lo usa. Anche il worker senza HTTP ha bisogno del runtime di ASP.NET Core: in Docker, l’immagine aspnet e non runtime.

2. Map: le rotte

Endpoints.cs
public static IEndpointRouteBuilder MapFirmaDigitale(
    this IEndpointRouteBuilder app)
{
    var g = app.MapGroup("/signatures")
        .RequireAuthorization();

    g.MapPost("/", RequestSignature);
    g.MapGet("/{id:guid}", GetSignature);

    return app;
}

MapGroup raccoglie le rotte del modulo sotto un prefisso comune, e quello che si applica al gruppo vale per tutte: qui RequireAuthorization. Il modulo dice che serve un utente autenticato; come si autentica, con un cookie, un token JWT o altro, lo decide l’host. Così il modulo funziona con lo schema di autenticazione del sistema che lo ospita, qualunque sia.

Gestori di route nelle app per le API minimeDocumentazione ufficiale · ASP.NET Core 10 · learn.microsoft.com

3. La firma è asincrona

Firmare richiede secondi, e non dipende da noi: dipende dal provider remoto. Tenere aperta la richiesta HTTP per tutto quel tempo vuol dire un utente che aspetta e un timeout in agguato: se scade, il client non sa se il documento è stato firmato o no.

Per questo la richiesta di firma non firma: salva la richiesta e risponde subito.

Endpoints.cs
static async Task<IResult> RequestSignature(
    Guid documentoId, RichiediFirma uc, CancellationToken ct)
{
    var r = await uc.ExecuteAsync(documentoId, ct);
    return Results.Accepted($"/signatures/{r.Id}", r);
}

202 Accepted vuol dire “ho preso in carico la richiesta, il risultato arriverà”. L’header Location porta a /signatures/{id}, dove il client può controllare lo stato: InAttesa, poi Firmata o Fallita. Chi non vuole interrogare l’endpoint riceve l’esito come notifica.

Modello di Request-Reply asincronoDocumentazione ufficiale · learn.microsoft.com

Dietro le quinte, un worker

La firma vera la fa un BackgroundService, che ogni cinque secondi cerca le richieste in attesa:

FirmaWorker.cs
public sealed class FirmaWorker(
    IServiceScopeFactory scopes, ILogger<FirmaWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
        while (await timer.WaitForNextTickAsync(ct))
        {
            try
            {
                using var scope = scopes.CreateScope();
                await scope.ServiceProvider
                    .GetRequiredService<EseguiFirme>().ExecuteAsync(ct);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                logger.LogError(ex, "Ciclo di firma non riuscito");
            }
        }
    }
}

Il worker è un singleton e vive quanto l’applicazione, mentre il DbContext è scoped. Non può riceverlo nel costruttore: in Development l’host se ne accorge e non parte, altrove lo stesso DbContext resterebbe in uso per giorni, accumulando entità. Per questo a ogni giro il worker crea uno scope suo, con un DbContext nuovo.

Usare i servizi con ambito all’interno di un servizio BackgroundDocumentazione ufficiale · learn.microsoft.com

Il try/catch intorno al ciclo non è una formalità. Se un’eccezione esce da ExecuteAsync, per default l’host si ferma tutto (BackgroundServiceExceptionBehavior.StopHost). Dentro il gestionale vorrebbe dire che un database della firma irraggiungibile spegne anche tutti gli altri moduli. Con il catch il ciclo fallito finisce nei log, e al giro dopo il worker riprova. OutboxDispatcher va protetto allo stesso modo. Impostare il comportamento a Ignore non è un’alternativa: l’host resterebbe in piedi, ma il worker smetterebbe di girare in silenzio.

BackgroundServiceExceptionBehaviorDocumentazione ufficiale · .NET 10 · learn.microsoft.com

Il caso d’uso sta in Application, e non sa niente di EF Core:

EseguiFirme.cs
foreach (var r in await repo.InAttesaAsync(10, ct))
{
    try
    {
        await using var file = await storage.ApriAsync(r.DocumentoId, ct);
        await using var firmato = await provider.FirmaAsync(file, ct);
        await storage.SalvaFirmatoAsync(r.DocumentoId, firmato, ct);
        r.Stato = "Firmata";
        outbox.Accoda("DocumentoFirmato", new { r.Id, r.DocumentoId });
    }
    catch (Exception ex) when (ex is not OperationCanceledException)
    {
        r.Stato = "Fallita";
        outbox.Accoda("FirmaFallita", new { r.Id, r.DocumentoId });
    }
    await repo.SaveChangesAsync(ct);
}

Il nuovo stato e il messaggio per le notifiche finiscono nello stesso SaveChangesAsync, e quindi nella stessa transazione. Il caso d’uso non lo sa: lo garantisce il progetto Module, che registra il repository e EfOutbox come scoped sopra lo stesso FirmaDbContext. Il filtro when è lo stesso dell’articolo sul CancellationToken: un’applicazione che si ferma non è una firma fallita.

Una semplificazione da tenere presente: con un’istanza sola del worker va bene, ma se il servizio gira su più istanze due worker leggono le stesse richieste in attesa e le firmano entrambi. In quel caso ogni worker deve prima riservarsi le richieste, per esempio portandole a uno stato InCorso con un aggiornamento che riesce a uno solo.

Un secondo worker, OutboxDispatcher, legge l’Outbox e pubblica i messaggi al sistema di notifiche. Perché serve una tabella in mezzo invece di pubblicare direttamente, e come chi riceve scarta i messaggi arrivati due volte, è l’argomento del prossimo articolo, su Inbox e Outbox. Come si porta la trace attraverso l’Outbox è invece nell’articolo sulle trace.

4. Dentro un sistema più grande

Il gestionale documentale ha i suoi moduli, e la firma digitale è uno di quelli. Non tutti i clienti la usano, quindi si accende da configurazione:

Program.cs
var firma = builder.Configuration.GetValue<bool>("Moduli:FirmaDigitale");

if (firma)
    builder.AddFirmaDigitale();

var app = builder.Build();

if (firma)
    app.MapFirmaDigitale();
appsettings.json
{
  "Moduli": {
    "FirmaDigitale": true
  },
  "Firma": {
    "ProviderUrl": "https://…/"
  },
  "ConnectionStrings": {
    "Firma": "…"
  }
}

Spento, il modulo non esiste: /signatures risponde 404, i worker non partono, il DbContext non viene registrato. Senza la chiave Moduli:FirmaDigitale il modulo resta spento: GetValue<bool> restituisce false.

La decisione si prende all’avvio, non a ogni richiesta: per cambiarla si cambia la configurazione e si riavvia. Come ogni chiave di configurazione si può sovrascrivere con una variabile d’ambiente, Moduli__FirmaDigitale=false, senza toccare i file.

Configurazione in ASP.NET CoreDocumentazione ufficiale · ASP.NET Core 10 · learn.microsoft.com

5. Da solo, come microservizio

Il progetto Api è l’host che usa il modulo da solo:

Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
builder.AddFirmaDigitale();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapFirmaDigitale();
app.Run();

Nove righe, e nessuna parla di firma: contiene solo host, middleware e configurazione. Il modulo è lo stesso che gira nel gestionale, identico: un bug corretto lì è corretto anche qui.

Qui tocca all’host scegliere come si autentica chi chiama: un token JWT, con il pacchetto Microsoft.AspNetCore.Authentication.JwtBearer. Essendo l’unico schema registrato, diventa quello predefinito, e l’emittente e l’audience del token si leggono dalla configurazione, nella sezione Authentication:Schemes:Bearer. Uno schema serve davvero: con AddAuthentication() e nessuno schema il progetto compila, ma ogni richiesta alle rotte protette finisce in un errore 500, perché ASP.NET Core non sa come chiedere le credenziali.

Panoramica dell’autenticazione di ASP.NET CoreDocumentazione ufficiale · ASP.NET Core 10 · learn.microsoft.com

Come distribuirlo

Strada Quando conviene
Nella stessa soluzione Un solo sistema che lo usa. È la più semplice.
Pacchetto NuGet Più sistemi che lo montano, ognuno con la versione che vuole.
Microservizio, con il progetto Api Va scalato o rilasciato per conto suo, o lo gestisce un altro team.

Non c’è bisogno di scegliere subito. Si parte dalla prima, e la struttura permette di passare alle altre senza cambiare il modulo: è il vantaggio di un monolite modulare, che si comporta come un sistema unico finché non serve altro.

Le regole nei test

I riferimenti tra progetti tengono la direzione delle dipendenze, ma non tutto. Niente impedisce di aggiungere ad Application il pacchetto di EF Core e usare un DbContext in un caso d’uso: compila, e i livelli si mescolano senza che nessuno se ne accorga. Una regola scritta solo nella documentazione dura fino alla prima scadenza.

Con NetArchTest la regola diventa un test:

DipendenzeTests.cs
[Fact]
public void Application_non_conosce_web_e_database()
{
    var result = Types
        .InAssembly(typeof(RichiediFirma).Assembly)
        .ShouldNot()
        .HaveDependencyOnAny(
            "Microsoft.AspNetCore",
            "Microsoft.EntityFrameworkCore")
        .GetResult();

    Assert.True(result.IsSuccessful);
}

Il test guarda i tipi compilati, non i pacchetti: se un tipo di Application usa EF Core o ASP.NET Core, fallisce, e la pull request si ferma.

C’è anche un buco nel verso opposto. I riferimenti tra progetti sono transitivi: Api riferisce solo Module, ma vede anche Infrastructure e Application, e potrebbe usare FirmaDbContext direttamente, saltando il modulo. Si chiude allo stesso modo, con una regola sui tipi del progetto Api.

Un secondo test controlla la promessa di IHostApplicationBuilder, cioè che il modulo si monti anche in un host senza HTTP:

WorkerHostTests.cs
[Fact]
public void Il_modulo_si_registra_anche_in_un_host_senza_web()
{
    var builder = Host.CreateApplicationBuilder();
    builder.AddFirmaDigitale();

    Assert.Contains(builder.Services,
        d => d.ImplementationType?.Name == "FirmaWorker");
}

In breve

  1. Un progetto per livello: i riferimenti tengono la direzione delle dipendenze.
  2. Le porte in Application, gli adapter in Infrastructure.
  3. Il progetto Module collega tutto ed espone solo Add e Map.
  4. Add su IHostApplicationBuilder, per avere configurazione e ambiente e funzionare anche senza HTTP.
  5. Il modulo ha i suoi dati: gli altri li ricevono come messaggi.
  6. I worker del modulo non devono mai far cadere l’host.
  7. L’host decide se montarlo e come autenticare; il progetto Api lo fa diventare un microservizio.
  8. I test difendono i confini.

Codice semplificato a scopo di esempio: nomi e struttura servono a dare l'idea, non sono da copiare così.

Guarda il post su Instagram @alessandroaiello.dev