Salta al contenuto principale

La porta nel dominio, l'adattatore in infrastruttura: e il prezzo esatto di una scorciatoia

Inversione della dipendenza con codice reale: interfaccia nel dominio, adattatore JPA in infrastruttura, caso d'uso eseguito contro una porta finta. E la fuga di tipo misurata in sei archivi.

Cosa ne pensi di questo articolo?

La porta nel dominio, l'adattatore in infrastruttura: e il prezzo esatto di una scorciatoia
Clean Architecture Spring Boot Templatefedericocalo.dev

In Breve

Inversione della dipendenza con codice reale: interfaccia nel dominio, adattatore JPA in infrastruttura, caso d'uso eseguito contro una porta finta. E la fuga di tipo misurata in sei archivi.

La porta nel dominio, l'adattatore in infrastruttura: e il prezzo esatto di una scorciatoia

Il dominio non deve conoscere il database. Lo hai letto, ti sembra ragionevole, e poi ti siedi a scriverlo e la domanda diventa imbarazzante: se il dominio non sa che esiste PostgreSQL, chi legge le righe? Da qualche parte quel codice deve esistere, e deve stare in un punto che il caso d'uso può chiamare.

La risposta è l'inversione della dipendenza, e in pratica sono due file. Nel dominio metti un'interfaccia che descrive che cosa ti serve, espressa nei termini del dominio. Nell'infrastruttura metti una classe che la implementa e che sa come ottenerlo, parlando con Hibernate o con qualsiasi altra cosa. Il caso d'uso riceve l'interfaccia e non ha modo di sapere chi sta dall'altra parte.

Questo articolo mostra la coppia reale di questo progetto, la esegue con un'implementazione finta, e poi misura il costo esatto di una scorciatoia che è stata presa dentro quella stessa porta e mai corretta. Il costo si rivela essere sei archivi nel classpath, e si misura con un comando.

La porta: un'interfaccia che descrive un bisogno

Questo è il contratto, in domain/port/QuoteRepository.java. Tre metodi, nessuna implementazione, nessun riferimento a tabelle o colonne.

public interface QuoteRepository {

    Optional<Quote> findRandom(String topic, String author);

    Page<Quote> findFiltered(String author, String theme, int page, int size);

    long count();
}

Il nome contiene la parola «repository» e questo confonde chi arriva da Spring Data, perché lì Repository è un'interfaccia del framework che il framework implementa da sé. Qui è l'opposto: la interfaccia è tua, la implementi tu, e il framework non la conosce. Se vuoi evitare l'ambiguità, chiamala QuotePort; il progetto ha scelto il nome più diffuso e ha accettato l'ambiguità.

La firma di findFiltered è la parte progettata, e vale la pena guardarla parametro per parametro perché ogni scelta ha una conseguenza. author e theme accettano null, e il contratto documentato dice che null significa «qualunque»: è la convenzione che permette di avere un metodo invece di quattro. page e size sono due int nudi, non un oggetto di paginazione del framework, e questa è una scelta deliberata di cui parliamo fra poco. Il tipo di ritorno Optional<Quote> su findRandom dice al chiamante che l'assenza è un esito previsto, non un errore: il controller la traduce in un 404 invece di lasciar passare un null.

Un dettaglio sui due int: accettano qualsiasi valore, compresi i negativi, perchè l'interfaccia non convalida. La convalida è responsabilità del chiamante, e in questo progetto sta nel caso d'uso che li limita prima di passarli. Se preferisci che la porta rifiuti da sé, la firma va cambiata per accettare un value object di paginazione, e in quel caso il controllo vive nel dominio e vale per tutti gli adattatori.

Il metodo count() esiste per un motivo che non si vede dalla firma: serve a mostrare un numero in pagina. Averlo nella porta invece di contare le righe nel caso d'uso significa che l'implementazione può usare un COUNT(*) invece di caricare tutto in memoria. È il tipo di dettaglio che l'interfaccia deve permettere senza imporre.

L'adattatore: dove vive la conoscenza del database

L'implementazione sta in infrastructure/persistence/adapter/QuoteRepositoryAdapter.java e fa tre cose: scegliere la query giusta in base ai filtri presenti, costruire l'oggetto di paginazione, e tradurre le entità JPA in record di dominio.

@Override
public Page<Quote> findFiltered(String author, String theme, int page, int size) {
    final boolean hasAuthor = author != null && !author.isBlank();
    final boolean hasTheme  = theme  != null && !theme.isBlank();

    final PageRequest pageable = PageRequest.of(page, size,
            Sort.by(Sort.Direction.DESC, "createdAt"));

    Page<QuoteEntity> entities;
    if (hasAuthor && hasTheme) {
        entities = jpa.findByAuthorAndTheme(author, theme, pageable);
    } else if (hasAuthor) {
        entities = jpa.findByAuthorContainingIgnoreCase(author, pageable);
    } else if (hasTheme) {
        entities = jpa.findByTheme(theme, pageable);
    } else {
        entities = jpa.findAll(pageable);
    }

    return entities.map(this::toDomain);
}

PageRequest.of(page, size, Sort) viene da org.springframework.data.domain e costruisce la richiesta di pagina che Spring Data traduce in LIMIT e OFFSET. Il terzo argomento è l'ordinamento, e qui è la parte che va capita: Sort.by(Sort.Direction.DESC, "createdAt") ordina per il nome del campo Java dell'entità, non per il nome della colonna. La colonna si chiama created_at; se scrivi quello, Spring Data solleva un errore a runtime e non a compilazione, perché quella stringa non è verificata da nessuno.

Senza il terzo argomento l'ordinamento non è indefinito in modo innocuo: PostgreSQL restituisce le righe nell'ordine che gli conviene, e con OFFSET questo significa che la pagina due può contenere una riga già vista nella pagina uno. È il difetto di paginazione più comune che conosca, non si manifesta in sviluppo con dieci righe, e si manifesta in produzione come «un articolo compare due volte e un altro mai».

La chiamata entities.map(this::toDomain) è l'unico punto in cui le due forme si incontrano. Page.map trasforma il contenuto conservando i metadati di paginazione, quindi il numero totale di elementi e di pagine resta quello calcolato dal database. Se invece estraessi la lista, la trasformassi e ricostruissi una pagina nuova, dovresti riportare a mano il totale, e il modo tipico di sbagliarlo è usare la dimensione della lista corrente.

ClasseStratoConosceNon conosce
QuotedominioUUID, InstantChe esista una tabella
QuoteRepositorydominioIl modello di dominioChi la implementa
QuoteRepositoryAdapterinfrastrutturaPorta, entità JPA, Spring DataChi la chiama
QuoteEntityinfrastrutturaNomi di tabella, colonne, indiciIl modello di dominio

La prova: il caso d'uso gira senza database

Il beneficio promesso dall'inversione è che si può sostituire l'implementazione. Si verifica in un modo solo: sostituendola. Ho scritto una implementazione in memoria della porta, le ho passato tre citazioni inventate, e ho chiamato il caso d'uso vero del progetto.

static final class QuoteRepositoryInMemoria implements QuoteRepository {
    private final List<Quote> dati;
    int ultimaDimensioneRichiesta = -1;

    @Override public Page<Quote> findFiltered(String author, String theme, int page, int size) {
        ultimaDimensioneRichiesta = size;
        List<Quote> filtrate = dati.stream()
                .filter(q -> author == null || q.author().toLowerCase().contains(author.toLowerCase()))
                .filter(q -> theme == null || q.theme().equals(theme))
                .toList();
        return new PageImpl<>(filtrate, PageRequest.of(page, size), filtrate.size());
    }
    // findRandom e count omessi
}

ListQuotesUseCase casoUso = new ListQuotesUseCaseImpl(finta);
Page<QuoteResponse> pagina = casoUso.execute("rossi", null, 0, 20);

La riga che conta è l'ultima: new ListQuotesUseCaseImpl(finta). Il caso d'uso è annotato @Service e @Transactional, ma si costruisce con new come qualsiasi altra classe, perché le annotazioni sono istruzioni per chi lo assembla in produzione e non vincoli sulla costruzione. Nessun contesto Spring, nessun file di configurazione, nessun database. Compilato con OpenJDK 21.0.12.1 ed eseguito il 31 ottobre 2026, ha stampato questo:

filtro autore=rossi  -> elementi 2
   Rossi: La misura viene prima del rimedio
   Rossi: Il dominio non conosce il database
dimensione chiesta 5000 -> passata alla porta 50
dimensione chiesta 0    -> passata alla porta 1
chiamate totali alla porta = 3

Le due righe centrali sono il vero risultato dell'esperimento. L'implementazione finta registra la dimensione che riceve, e quando il chiamante chiede 5.000 elementi la porta ne vede 50; quando ne chiede zero, la porta vede 1. Il limite che il caso d'uso applica è verificato dal di fuori, osservando che cosa arriva alla porta, e questo è un test che non potresti scrivere se il limite vivesse nel controller.

Il prezzo della scorciatoia, in archivi

Nella firma della porta c'è un tipo che non appartiene al dominio: Page viene da org.springframework.data.domain. È una delle sette violazioni che il gate architetturale di questo progetto ha congelato, e non è stata corretta perché il beneficio immediato sembrava nullo. Vediamo quanto costa davvero.

Il programma di prova non ha bisogno di un database, ma ha bisogno dei tipi che compaiono nelle firme. Lanciato con il solo percorso dei bytecode del progetto, muore prima di arrivare alla prima riga utile:

cd backend
java -cp /tmp/out:target/classes DemoPortaFinta
Exception in thread "main" java.lang.NoClassDefFoundError: org/springframework/data/domain/Pageable
	at DemoPortaFinta.main(DemoPortaFinta.java:47)
Caused by: java.lang.ClassNotFoundException: org.springframework.data.domain.Pageable

Per farlo partire servono sei archivi: spring-data-commons 3.4.7, che porta il tipo, e cinque dipendenze che quello si trascina dietro, fra cui spring-core, spring-beans, spring-tx e slf4j-api. Con quei sei il programma gira e stampa l'uscita vista sopra, preceduta da tre avvisi di SLF4J che non trova un'implementazione di logging.

Questo è il prezzo esatto, ed è utile perché smette di essere un'opinione. Chiunque voglia verificare la logica di un caso d'uso di questo progetto deve mettere Spring Data sul banco, anche quando sta testando una funzione che non tocca nessun dato persistente. Non è un disastro, è un attrito, e si paga ogni volta.

Il criterio con cui deciderei di pagare o no è il numero di porte coinvolte. Se la fuga di tipo riguarda due firme su 36, come qui, il rimedio tocca due interfacce e due adattatori e si fa in un'ora: si rinvia perché nessuno ne ha bisogno, non perché sia difficile. Se riguardasse metà delle porte, il rimedio diventerebbe un cantiere, e un cantiere senza una scadenza che lo giustifichi non si apre mai: in quel caso la decisione giusta sarebbe dichiarare il tipo del framework come parte del contratto del dominio e smettere di considerarlo una violazione.

Il rimedio, se si decidesse di pagarlo, è un record di dominio con quattro campi al posto di Page: contenuto, totale, indice di pagina e dimensione. L'adattatore lo costruisce dalla Page di Spring Data, il caso d'uso lo riceve, e il bordo HTTP lo traduce in JSON. In questo progetto esiste già un tipo quasi identico, ApiEnvelope.PagedData, ma vive nello strato applicativo e non nel dominio.

Le due classi gemelle, e perché restano due

La domanda che arriva sempre a questo punto è se valga la pena avere due classi con gli stessi otto campi. Il modello di dominio e l'entità JPA di questo progetto descrivono la stessa citazione, e il mapper fra loro è una assegnazione di campi senza nessuna trasformazione. Sembra lavoro per niente.

Non lo è, e il motivo si vede mettendo le due dichiarazioni una sotto l'altra. Il modello di dominio è questo:

public record Quote(
        UUID id, String text, String author, String podcastSource,
        String episodeUrl, String theme, String language, Instant createdAt
) {}

Un record: tutti i campi assegnati alla costruzione, nessun setter, uguaglianza per valore, e nessun modo di esistere in uno stato incompleto. L'entità JPA, invece, è una classe con @Getter, @Setter e @NoArgsConstructor di Lombok, perché Hibernate pretende un costruttore senza argomenti e la possibilità di assegnare i campi dopo averla creata. Pretende anche che i campi non siano final, il che esclude il record per costruzione.

Dentro l'entità ci sono poi tre cose che nel dominio non avrebbero senso. La dichiarazione @Column(name = "text", nullable = false, length = 2000) descrive un vincolo del database e non una regola del prodotto. Gli indici dichiarati su author, theme, podcast_source e language sono decisioni di prestazione. E il metodo annotato @PrePersist assegna la data di creazione se è assente, cioè un comportamento che si attiva solo quando la riga viene scritta.

  • Il dominio può essere immutabile solo se non è anche l'entità: un record e Hibernate non convivono.
  • Lo schema può cambiare rinominando una colonna senza che una riga di logica se ne accorga.
  • Il campo in più nel database non compare automaticamente nel modello, e quindi non finisce per sbaglio nel JSON pubblico.
  • Il caricamento pigro resta confinato nell'infrastruttura, dove la sessione esiste: un record di dominio non può sollevare l'eccezione di sessione chiusa.

L'ultimo punto è quello che in pratica giustifica da solo la coppia. Un'entità JPA restituita fuori dalla transazione è una bomba a orologeria: appena qualcuno tocca una relazione non caricata, il programma solleva un'eccezione in un punto lontanissimo dalla causa. Un record costruito dentro l'adattatore contiene già tutto quello che contiene, e non ha nessun comportamento differito.

Il mapper di questo progetto è scritto a mano, un metodo privato di nove righe, e non usa MapStruct nonostante la libreria sia fra le dipendenze. La ragione è che un mapper generato nasconde esattamente la cosa che vuoi vedere: quali campi passano e quali no. Nove righe noiose che si leggono in dieci secondi valgono più di un'annotazione che bisogna andare a verificare.

Quando la porta non serve

Non ogni accesso ai dati merita una coppia porta-adattatore, e trattarla come un dogma produce strati di traduzione che non traducono niente. Questi sono i casi in cui, su questo progetto, la coppia non è stata creata.

  • Lettura di sola presentazione. Se il dato esce dal database ed entra in una pagina senza che nessuna regola lo guardi, la porta aggiunge due file e nessuna garanzia.
  • Query di diagnostica o amministrazione. Vivono poco, cambiano spesso, e la loro forma è legata allo schema per definizione.
  • Un adattatore unico per sempre. Se non esisterà mai una seconda implementazione né un test che la sostituisce, l'inversione non viene mai esercitata.
  • Operazioni in blocco. Un aggiornamento di centomila righe si scrive in SQL e si misura in SQL; passarlo per il dominio lo rende più lento e meno chiaro.

Il terzo criterio è quello che uso di più, e si può formulare come domanda sola: esiste già oggi un secondo chiamante, o un test, che userà un'implementazione diversa? Se la risposta è no e non lo sarà nei prossimi mesi, la porta è un costo certo contro un beneficio ipotetico. Le 36 porte di dominio di questo progetto, contro 39 adattatori di persistenza, dicono che il criterio è stato applicato con una certa disciplina: il rapporto è vicino a uno, cioè quasi nessuna porta esiste solo per simmetria.

Chiusura

Adesso sai scrivere la coppia che permette a un dominio di leggere dal database senza sapere che esiste: un'interfaccia nel dominio espressa nei tuoi termini, una classe nell'infrastruttura che la implementa, e un mapper privato che le unisce. Sai anche quali parametri della firma decidono il comportamento, e perché un ordinamento assente produce righe duplicate fra le pagine.

Sai dimostrare il beneficio invece di affermarlo, scrivendo un'implementazione in memoria e osservando dall'esterno che il limite applicato dal caso d'uso arriva alla porta. E sai misurare il costo di una fuga di tipo: sei archivi nel classpath, un messaggio di errore preciso quando mancano, e la scelta consapevole di tenerla.

Il prossimo articolo della serie sale di uno strato e guarda chi chiama la porta: i casi d'uso, 638 interfacce e 333 implementazioni, con i DTO che li separano dal mondo e la domanda di quando un caso d'uso merita un'interfaccia e quando è solo una classe in più.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti