Modularità in .NET: lo stesso modulo dentro un sistema più grande o come microservizio
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
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 WebLe 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.
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
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:
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.
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
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.
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.
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.
Dietro le quinte, un worker
La firma vera la fa un BackgroundService, che ogni cinque secondi cerca le
richieste in attesa:
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.
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.
Il caso d’uso sta in Application, e non sa niente di EF Core:
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:
var firma = builder.Configuration.GetValue<bool>("Moduli:FirmaDigitale");
if (firma)
builder.AddFirmaDigitale();
var app = builder.Build();
if (firma)
app.MapFirmaDigitale();
{
"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.
5. Da solo, come microservizio
Il progetto Api è l’host che usa il modulo da solo:
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.
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:
[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:
[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
- Un progetto per livello: i riferimenti tengono la direzione delle dipendenze.
- Le porte in Application, gli adapter in Infrastructure.
- Il progetto Module collega tutto ed espone solo
AddeMap. AddsuIHostApplicationBuilder, per avere configurazione e ambiente e funzionare anche senza HTTP.- Il modulo ha i suoi dati: gli altri li ricevono come messaggi.
- I worker del modulo non devono mai far cadere l’host.
- L’host decide se montarlo e come autenticare; il progetto Api lo fa diventare un microservizio.
- 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