Hai una regola di business scritta bene e un giorno devi cambiare il modo in cui salvi gli utenti, o aggiungere una cache davanti alle letture. Se quella regola importa classi di Hibernate o di Redis, il cambiamento tocca anche lei. L'infrastructure layer esiste per evitarlo: contiene tutto ciò che parla con il mondo esterno (database, cache, altri servizi) e lo nasconde dietro interfacce che appartengono al domain. Qui le implementi, le provi senza database e controlli con uno strumento automatico che nessuno, domani, rompa la direzione delle dipendenze.
Port e adapter: l'interfaccia è del domain, il codice è dell'infrastructure
Un port è un'interfaccia che descrive che cosa serve al domain, nel suo linguaggio: «trovami un utente per email». Un adapter è la classe che la implementa con una tecnologia precisa. La freccia delle dipendenze va dall'adapter verso il port, e mai il contrario: è la Dependency Rule descritta da Robert C. Martin, per cui il codice sorgente può puntare solo verso l'interno. L'idea di fondo, i port e gli adapter, la formulò Alistair Cockburn con il nome di architettura esagonale.
Per vedere la forma in azione, ecco il domain minimo di un utente e il suo port. Sono tipi Java puri: record immutabili che validano se stessi nel costruttore.
public record UserId(String value) {
public UserId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("l'id utente non può essere vuoto");
}
}
}
public record Email(String value) {
public Email {
if (value == null || !value.contains("@")) {
throw new IllegalArgumentException("email non valida: " + value);
}
}
}
public enum Role { USER, ADMIN }
public record User(UserId id, Email email, String passwordHash, Role role) { }
import java.util.Optional;
public interface UserRepository {
Optional<User> findById(UserId id);
Optional<User> findByEmail(Email email);
User save(User user);
}
Il port restituisce Optional e accetta UserId e Email, non stringhe, quindi nessun chiamante può passare un id vuoto o un'email senza chiocciola: il costruttore del record lo impedisce a monte. L'interfaccia non sa che cosa sia una tabella, né un'annotazione di Spring.
JPA e il mapper: due modelli, un confine
L'adapter di persistenza ha bisogno di una classe che Hibernate sappia gestire: mutabile, con un costruttore vuoto, con le annotazioni di mappatura. È l'entità JPA e non è il domain. Tenerle separate costa un mapper, cioè due metodi di conversione, e in cambio il domain resta immutabile e libero da Hibernate. Lo schema che segue mostra l'entità con le annotazioni e l'interfaccia Spring Data (non eseguito: richiede Jakarta Persistence e un database).
@Entity
@Table(name = "users")
public class UserJpaEntity {
@Id
private String id; // assegnato dal domain, non dal database
@Column(nullable = false, unique = true)
private String email;
@Column(name = "password_hash", nullable = false)
private String passwordHash;
@Column(nullable = false)
private String role; // l'enum del domain, salvato come testo
// getter e setter: Hibernate li usa, il domain non li vede mai
}
// package-private: nessun altro layer deve poterla usare
interface JpaUserRepository extends JpaRepository<UserJpaEntity, String> {
Optional<UserJpaEntity> findByEmail(String email);
}
L'identificatore è una stringa assegnata dal domain, non generata dal database. Ha una conseguenza che la documentazione di Spring Data JPA descrive con precisione: per capire se un'entità è nuova, Spring Data guarda prima una eventuale proprietà di versione, e senza di essa controlla l'identificatore, e se non è null considera l'entità non nuova. In quel caso save chiama merge invece di persist, e l'inserimento è preceduto da una lettura. Non è un errore, ma è un costo da conoscere quando gli identificatori nascono nel domain; si evita con una proprietà @Version o implementando Persistable.
Per provare il mapper e l'adapter senza database ho sostituito Spring Data con un'implementazione in memoria della stessa interfaccia (stessi nomi di metodo, senza framework). Il codice dell'adapter e del mapper è quello che andrebbe in produzione. Per compilare i listati servono anche tre tipi di supporto, che mostro per intero: la versione semplice dell'entità (senza annotazioni), l'interfaccia che fa le veci di Spring Data e la sua implementazione in memoria, che conta anche gli accessi.
/** Nel progetto reale porta @Entity e @Table; qui è una classe semplice con lo stesso ruolo. */
public class UserJpaEntity {
private String id;
private String email;
private String passwordHash;
private String role;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
}
import java.util.Optional;
/** Stand-in dell'interfaccia Spring Data: stessi nomi di metodo, nessun framework. */
public interface UserJpaRepository {
Optional<UserJpaEntity> findById(String id);
Optional<UserJpaEntity> findByEmail(String email);
UserJpaEntity save(UserJpaEntity entity);
}
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
public class InMemoryUserJpaRepository implements UserJpaRepository {
private final Map<String, UserJpaEntity> rows = new LinkedHashMap<>();
private int reads = 0;
@Override public Optional<UserJpaEntity> findById(String id) {
reads++;
return Optional.ofNullable(rows.get(id));
}
@Override public Optional<UserJpaEntity> findByEmail(String email) {
reads++;
return rows.values().stream().filter(r -> r.getEmail().equals(email)).findFirst();
}
@Override public UserJpaEntity save(UserJpaEntity entity) {
rows.put(entity.getId(), entity);
return entity;
}
public int reads() { return reads; }
}
Il contatore reads() è ciò che permetterà, più avanti, di dire quante volte si è davvero interrogato il «database». Con questi tre file e quelli del dominio mostrati sopra, i listati di questa sezione compilano da soli.
public class UserMapper {
public User toDomain(UserJpaEntity entity) {
return new User(
new UserId(entity.getId()),
new Email(entity.getEmail()),
entity.getPasswordHash(),
Role.valueOf(entity.getRole()));
}
public UserJpaEntity toEntity(User user) {
UserJpaEntity entity = new UserJpaEntity();
entity.setId(user.id().value());
entity.setEmail(user.email().value());
entity.setPasswordHash(user.passwordHash());
entity.setRole(user.role().name());
return entity;
}
}
import java.util.Optional;
public class UserJpaAdapter implements UserRepository {
private final UserJpaRepository jpa;
private final UserMapper mapper;
public UserJpaAdapter(UserJpaRepository jpa, UserMapper mapper) {
this.jpa = jpa;
this.mapper = mapper;
}
@Override public Optional<User> findById(UserId id) {
return jpa.findById(id.value()).map(mapper::toDomain);
}
@Override public Optional<User> findByEmail(Email email) {
return jpa.findByEmail(email.value()).map(mapper::toDomain);
}
@Override public User save(User user) {
return mapper.toDomain(jpa.save(mapper.toEntity(user)));
}
}
L'adapter fa due cose e basta: chiama il repository tecnico e converte. Non contiene regole di business, non decide nulla sulle email duplicate, non apre transazioni per conto suo in questo esempio. Le regole stanno nei record del domain e nel use case. Eseguiamolo con un programma che salva un utente, lo rilegge e poi simula una riga danneggiata nel database.
public class DemoAdapter {
public static void main(String[] args) {
var jpa = new InMemoryUserJpaRepository();
UserRepository users = new UserJpaAdapter(jpa, new UserMapper());
User original = new User(new UserId("u-1"), new Email("giulia@example.com"), "hash", Role.USER);
User saved = users.save(original);
System.out.println("round trip uguale: " + saved.equals(original));
System.out.println("per email: " + users.findByEmail(new Email("giulia@example.com")).map(User::id));
System.out.println("assente: " + users.findById(new UserId("u-2")).isPresent());
try {
jpa.save(rigaCorrotta());
users.findById(new UserId("u-9"));
} catch (IllegalArgumentException e) {
System.out.println("riga corrotta rifiutata dal domain: " + e.getMessage());
}
}
private static UserJpaEntity rigaCorrotta() {
UserJpaEntity row = new UserJpaEntity();
row.setId("u-9");
row.setEmail("senza-chiocciola");
row.setPasswordHash("hash");
row.setRole("USER");
return row;
}
}
// Output:
// round trip uguale: true
// per email: Optional[UserId[value=u-1]]
// assente: false
// riga corrotta rifiutata dal domain: email non valida: senza-chiocciola
La prima riga è il controllo che conta: salvare e rileggere restituisce un utente uguale all'originale, quindi il mapper non perde campi. L'ultima è meno ovvia. Una riga con un'email priva di chiocciola, scritta direttamente nella tabella da un altro programma o da una migration sbagliata, non entra nel domain: il record Email la rifiuta al momento della conversione. Il prezzo è che la lettura fallisce con un'eccezione invece di restituire dati incoerenti, e dovrai decidere dove gestirla.
Cache davanti al repository: decorator o @Cacheable?
Una cache riduce le letture ripetute, ma introduce una domanda che gli esempi più brevi saltano: che cosa succede quando la cache non risponde? Spring Cache, con le annotazioni @Cacheable e @CacheEvict, ha una risposta predefinita descritta nella sua documentazione: «any exception thrown during a cache related operation is thrown back at the client». Se Redis è irraggiungibile, la lettura di un utente fallisce, anche se il database funziona. Per cambiare comportamento serve un CacheErrorHandler personalizzato.
L'alternativa è decorare il port: una classe che implementa lo stesso UserRepository, tiene un riferimento al repository vero e a una cache, e decide da sé che cosa fare quando la cache fallisce. Qui la scelta è «fail-open»: se la cache non risponde, si legge dal database e si registra l'errore.
import java.util.Optional;
public interface Cache<K, V> {
Optional<V> get(K key);
void put(K key, V value);
void evict(K key);
}
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public class InMemoryCache<K, V> implements Cache<K, V> {
private final Map<K, V> entries = new HashMap<>();
private boolean down = false;
public void goDown() { down = true; }
private void check() {
if (down) throw new IllegalStateException("cache non raggiungibile");
}
@Override public Optional<V> get(K key) { check(); return Optional.ofNullable(entries.get(key)); }
@Override public void put(K key, V value) { check(); entries.put(key, value); }
@Override public void evict(K key) { check(); entries.remove(key); }
}
import java.util.Optional;
import java.util.function.Consumer;
public class CachingUserRepository implements UserRepository {
private final UserRepository delegate;
private final Cache<UserId, User> cache;
private final Consumer<String> log;
public CachingUserRepository(UserRepository delegate, Cache<UserId, User> cache,
Consumer<String> log) {
this.delegate = delegate;
this.cache = cache;
this.log = log;
}
@Override public Optional<User> findById(UserId id) {
try {
Optional<User> hit = cache.get(id);
if (hit.isPresent()) {
return hit;
}
} catch (RuntimeException e) {
log.accept("cache in lettura fallita, passo al database: " + e.getMessage());
}
Optional<User> found = delegate.findById(id);
found.ifPresent(user -> {
try {
cache.put(id, user);
} catch (RuntimeException e) {
log.accept("cache in scrittura fallita: " + e.getMessage());
}
});
return found;
}
@Override public Optional<User> findByEmail(Email email) {
return delegate.findByEmail(email);
}
@Override public User save(User user) {
User saved = delegate.save(user);
try {
cache.evict(saved.id());
} catch (RuntimeException e) {
log.accept("cache in invalidazione fallita: " + e.getMessage());
}
return saved;
}
}
Le tre mosse del decoratore sono nella findById: prima si interroga la cache, in caso di mancanza si passa al repository sottostante, poi si ricopia il risultato nella cache. Il save invalida la voce, perché il valore in cache sarebbe obsoleto. Ogni accesso alla cache è dentro un try che registra l'errore e prosegue. Il decoratore non conosce Redis: Cache è un'interfaccia, e per questo il comportamento si prova con InMemoryCache, una cache finta che può «andare giù» dopo la chiamata a goDown().
import java.util.ArrayList;
import java.util.List;
public class DemoCache {
public static void main(String[] args) {
var jpa = new InMemoryUserJpaRepository();
var cache = new InMemoryCache<UserId, User>();
List<String> log = new ArrayList<>();
UserRepository users = new CachingUserRepository(
new UserJpaAdapter(jpa, new UserMapper()), cache, log::add);
UserId id = new UserId("u-1");
users.save(new User(id, new Email("giulia@example.com"), "hash", Role.USER));
users.findById(id);
users.findById(id);
users.findById(id);
System.out.println("tre letture, accessi al database: " + jpa.reads());
users.save(new User(id, new Email("giulia@example.com"), "hash", Role.ADMIN));
System.out.println("dopo save, ruolo letto: " + users.findById(id).map(User::role).orElseThrow()
+ ", accessi al database: " + jpa.reads());
cache.goDown();
System.out.println("cache giù, ruolo letto: " + users.findById(id).map(User::role).orElseThrow()
+ ", accessi al database: " + jpa.reads());
System.out.println("log: " + log);
}
}
// Output:
// tre letture, accessi al database: 1
// dopo save, ruolo letto: ADMIN, accessi al database: 2
// cache giù, ruolo letto: ADMIN, accessi al database: 3
// log: [cache in lettura fallita, passo al database: cache non raggiungibile, cache in scrittura fallita: cache non raggiungibile]
I numeri raccontano la storia. Tre letture della stessa chiave costano un solo accesso al database. Dopo il save la voce è invalidata e la lettura successiva torna al database (accessi: 2) e restituisce il ruolo nuovo. Con la cache giù il sistema continua a rispondere, al prezzo di un accesso al database per ogni lettura, e nel log restano due errori: uno in lettura e uno in scrittura. Un sistema così mantiene il servizio ma può sovraccaricare il database proprio quando Redis cade, e questo è il compromesso da pesare caso per caso.
La stessa cache con Spring: configurazione e trappole
Con Spring si può ottenere lo stesso risultato con le annotazioni, e per un'applicazione già costruita attorno a @Cacheable è la strada naturale. Lo schema seguente non è eseguito (richiede Spring Boot e un'istanza di Redis).
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
CacheManager cacheManager(RedisConnectionFactory factory,
@Value("${app.cache.version:v1}") String version) {
RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(5))
.disableCachingNullValues()
.computePrefixWith(cacheName -> version + ":" + cacheName + "::");
return RedisCacheManager.builder(factory)
.cacheDefaults(defaults)
.withInitialCacheConfigurations(Map.of(
"users", defaults.entryTtl(Duration.ofMinutes(10)),
"entity-stats", defaults.entryTtl(Duration.ofMinutes(1))))
.build();
}
}
// Bean separato dall'adapter: la chiamata arriva dall'esterno e il proxy la intercetta
@Component
class CachedUserLookup {
private final UserRepository users;
CachedUserLookup(UserRepository users) { this.users = users; }
@Cacheable(cacheNames = "users", key = "#id.value()")
public Optional<User> findById(UserId id) { return users.findById(id); }
@CacheEvict(cacheNames = "users", key = "#user.id().value()")
public User save(User user) { return users.save(user); }
}
La classe CachedUserLookup è un bean separato dall'adapter per una ragione tecnica: Spring applica la cache attraverso un proxy, e il proxy intercetta solo le chiamate che arrivano dall'esterno. Un metodo che ne chiama un altro della stessa classe salta il proxy, e l'annotazione non fa nulla. Il metodo con Optional è un caso previsto: la documentazione dice che un Optional presente viene memorizzato e che uno assente memorizza null. Questo si scontra con disableCachingNullValues() della configurazione: un risultato assente non può essere salvato, e il caso va gestito (per esempio con unless) e provato, perché non l'ho eseguito.
Due correzioni a ciò che si trova spesso negli esempi. La proprietà di Spring Boot per Redis è spring.data.redis.*, e non spring.redis.* (lo elenca la tabella delle proprietà comuni di Spring Boot: spring.data.redis.host); inoltre spring.cache.redis.time-to-live vale per la cache configurata da Boot, e la documentazione precisa che per impostazione predefinita le voci non scadono mai. Se definisci tu un CacheManager, come sopra, le proprietà spring.cache.redis.* non lo riguardano.
La cache nel backend di questo sito
Il backend usa RedisCacheManager con un bean proprio, non con le proprietà di Boot: lo dice un commento in application.yml che segnala come «inerte» il blocco spring.cache.redis. Ho letto RedisCacheConfig e questi sono i fatti verificati (2026-11-12): ci sono 27 cache con un TTL individuale (conteggio delle righe Map.entry(), da 60 secondi per ph-membership e ecosystem-fetch fino a 7 giorni per taric-cache; ogni chiave ha un prefisso con la versione dell'applicazione (app.cache.version, nella forma versione:nome-cache::chiave).
Il prefisso ha uno scopo preciso, spiegato nel Javadoc della classe: dopo un deploy che cambia la logica di un valore in cache, le voci vecchie resterebbero servite fino alla scadenza. Cambiando la versione, le vecchie voci non vengono più lette (restano in Redis fino al loro TTL) e il progetto evita un FLUSHALL, perché lo stesso Redis contiene anche contatori di rate limit e dati che non devono essere cancellati. Un altro dettaglio del codice vale come avvertimento: i valori sono serializzati in JSON con informazioni di tipo, e il commento della classe segnala una trappola con le collezioni immutabili di Java (List.of(), Map.of()) nella deserializzazione. La stessa classe documenta il bean separato per evitare l'autoinvocazione: CachedMembershipLookup è nata proprio per questo, con un TTL di 60 secondi e una invalidazione attiva quando cambiano i ruoli.
Consumare un altro servizio: adapter HTTP
Un servizio esterno si tratta come il database: dietro un port. Il domain chiede «articoli simili a questo» e non sa se la risposta viene da HTTP, da una coda o da una tabella. Lo schema usa RestClient, il client HTTP fluente di Spring; nel backend di questo sito lo usano per esempio DatasetExploreController e SportIntelligenceController, e un conteggio dei file che citano WebClient dà zero (grep -rl WebClient backend/src/main/java, 2026-11-12). Il blocco non è eseguito: richiede un server che risponda.
// Port nel domain o nell'application: nessuna traccia di HTTP
public interface SimilarArticles {
List<ArticleId> forArticle(ArticleId id, int limit);
}
// Adapter nell'infrastructure
@Component
class HttpSimilarArticles implements SimilarArticles {
private final RestClient client;
HttpSimilarArticles(RestClient.Builder builder, @Value("${app.similar.base-url}") String baseUrl) {
this.client = builder.baseUrl(baseUrl).build(); // l'URL viene dalla configurazione
}
@Override
public List<ArticleId> forArticle(ArticleId id, int limit) {
SimilarResponse response = client.get()
.uri("/similar/{id}?limit={limit}", id.value(), limit)
.retrieve()
.onStatus(HttpStatusCode::isError, (request, res) -> {
throw new ExternalServiceException("servizio dei simili: " + res.getStatusCode());
})
.body(SimilarResponse.class);
return response == null ? List.of() : response.toArticleIds();
}
}
Ci sono tre scelte da notare. L'URL viene dalla configurazione e non è scritto nel codice. Per impostazione predefinita RestClient lancia un'eccezione per le risposte 4xx e 5xx; con onStatus la sostituisci con un'eccezione tua, così il resto del programma non dipende dalle classi HTTP di Spring. E il valore nullo del corpo si trasforma in lista vuota invece di propagarsi. Resta fuori dal blocco ciò che in produzione non può mancare: timeout di connessione e di lettura sul client, perché una dipendenza lenta senza timeout occupa un thread finché non risponde.
Far rispettare la freccia: ArchUnit
Una regola di dipendenza che si regge sulla buona volontà dura fino al primo giorno di fretta. ArchUnit la trasforma in un test: legge i file .class, costruisce il grafo delle dipendenze e fa fallire il controllo se una classe del domain ne usa una dell'infrastructure. Ho costruito due piccoli progetti identici, good e bad, e nel secondo ho fatto importare a una classe del domain l'adapter JPA. Il programma verifica la stessa regola su entrambi. Ogni progetto ha tre classi: domain.Order (un record con String id e long totalCents), domain.OrderRepository (interfaccia con Order save(Order order)) e infrastructure.JpaOrderRepository, che la implementa restituendo l'ordine ricevuto. In bad c'è in più questa classe, che sta nel domain e usa direttamente l'adapter:
package bad.domain;
import bad.infrastructure.JpaOrderRepository;
public class OrderService {
private final JpaOrderRepository repository = new JpaOrderRepository();
public Object place(Order order) {
return repository.save(order);
}
}
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
public class CheckLayers {
public static void main(String[] args) {
ArchRule rule = noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAPackage("..infrastructure..")
.as("il domain non deve dipendere dall'infrastructure");
for (String root : new String[] {"good", "bad"}) {
JavaClasses classes = new ClassFileImporter().importPackages(root);
try {
rule.check(classes);
System.out.println(root + ": regola rispettata (" + classes.size() + " classi analizzate)");
} catch (AssertionError e) {
System.out.println(root + ": regola VIOLATA");
e.getMessage().lines().limit(10).forEach(l -> System.out.println(" " + l));
}
}
}
}
// Output:
// good: regola rispettata (3 classi analizzate)
// bad: regola VIOLATA
// Architecture Violation [Priority: MEDIUM] - Rule 'il domain non deve dipendere dall'infrastructure' was violated (3 times):
// Constructor <bad.domain.OrderService.<init>()> calls constructor <bad.infrastructure.JpaOrderRepository.<init>()> in (OrderService.java:6)
// Field <bad.domain.OrderService.repository> has type <bad.infrastructure.JpaOrderRepository> in (OrderService.java:0)
// Method <bad.domain.OrderService.place(bad.domain.Order)> calls method <bad.infrastructure.JpaOrderRepository.save(bad.domain.Order)> in (OrderService.java:9)
Il primo progetto passa, con le tre classi analizzate. Il secondo viene fermato, e il messaggio è leggibile: indica la regola, il numero di violazioni (tre: la creazione dell'oggetto nel costruttore, il tipo del campo e la chiamata a save nel metodo place) e il file con la riga. In un progetto reale lo stesso controllo gira dentro JUnit, e un commit che introduce l'import sbagliato rompe la build.
Il test di architettura del backend di questo sito, CleanArchitectureTest, ha una scelta di cui tenere conto: usa metodi @Test ordinari e non il campo @ArchTest. Il Javadoc della classe spiega che, in quella combinazione di versioni di Surefire e JUnit Platform, il motore archunit-junit5 non veniva scoperto e riportava «Tests run: 0», un falso verde che non protegge nulla. La lezione vale oltre ArchUnit: un test di controllo va visto fallire almeno una volta, altrimenti non sai se è attivo.
Cosa non deve uscire dall'infrastructure
Tre errori ricorrenti si riconoscono perché fanno uscire dall'adapter qualcosa che dovrebbe restare dentro.
- L'entità JPA restituita da un controller, con le colonne del database e le relazioni lazy che arrivano al client.
- Le annotazioni
@Entitye@Columnmesse sul modello del domain per «risparmiare il mapper». - Le eccezioni di Hibernate o di HTTP che attraversano il use case fino al controller.
Sono tutti la stessa violazione vista da tre lati: un dettaglio tecnico che attraversa il confine. Il costo di evitarli è il mapper e un'eccezione di dominio per ogni errore tecnico rilevante; il costo di non evitarli si paga il giorno in cui cambi la tabella e scopri che anche l'API pubblica è cambiata. Nel backend di questo sito, per esempio, UserRepositoryAdapter implementa il port, usa UserMapper e dichiara @Transactional(readOnly = true) sulla classe, con un @Transactional su save; la cartella infrastructure/persistence/adapter contiene 40 file (ls | wc -l, 2026-11-12). Il tuo adapter non deve imitare tutto: l'idea di fondo è che il confine esista e sia verificato.
Domande che si fanno spesso
Devo scrivere sempre un mapper a mano o posso usare una libreria?
Puoi usare una libreria che genera il codice a compile time, come MapStruct, ma il valore del mapper sta nel fatto che il confine è esplicito. Per un'entità con quattro campi, un mapper scritto a mano si legge in dieci secondi e si debugga con un breakpoint. Quando le entità crescono, un generatore evita errori di copia, ma non sostituisce un test di round trip come quello della demo.
Se Redis cade, il sito smette di funzionare?
Dipende da come hai configurato la cache. Con le annotazioni di Spring e senza un CacheErrorHandler, l'eccezione arriva al chiamante. Con un decoratore come quello mostrato sopra, il servizio continua leggendo dal database. In entrambi i casi vale la pena avere un limite di timeout breve su Redis (il backend usa 2000 ms in application.yml), altrimenti ogni lettura aspetta il timeout prima di arrendersi.
Dove metto la validazione dei dati che arrivano dal database?
Nel domain, come nei record dell'esempio: se la riga è incoerente, la conversione fallisce. I vincoli di unicità e di lunghezza restano nel database come difesa finale, perché sono le uniche garanzie che reggono anche con richieste concorrenti. Come strutturare il use case che li usa è in Use Case Pattern in Spring Boot Clean Architecture.
Che cosa sai fare adesso
Sai separare un'entità JPA dal modello del domain con un mapper e provare quella conversione senza database. Sai decidere se mettere la cache come decoratore del port o con le annotazioni di Spring, sapendo che cosa succede in ciascun caso quando Redis non risponde. E sai trasformare la regola «il domain non dipende dall'infrastructure» in un controllo che fallisce davvero. Per il quadro d'insieme dei layer c'è il pilastro della serie, per i test di questi adapter con database reali Testing completo in Clean Architecture e Testing Spring Boot con Testcontainers.
Fonti
- The Clean Architecture - Robert C. Martin - la Dependency Rule; consultata il 2026-11-12
- Hexagonal architecture - Alistair Cockburn - port e adapter come idea originale; consultata il 2026-11-12
- Declarative Annotation-based Caching - Spring Framework Reference - gestione di
Optionale comportamento predefinito degli errori di cache; consultata il 2026-11-12 - Persisting Entities - Spring Data JPA Reference - persist contro merge e rilevamento dell'entità nuova; consultata il 2026-11-12
- Common Application Properties - Spring Boot Reference - le proprietà
spring.data.redis.*espring.cache.redis.*; consultata il 2026-11-12 - REST Clients - Spring Framework Reference -
RestClient,retrieveeonStatus; consultata il 2026-11-12 - ArchUnit User Guide - le regole sulle dipendenze fra package; consultata il 2026-11-12
- Misure e letture sul backend di questo sito:
RedisCacheConfig.java,application.yml,CleanArchitectureTest.javae i comandi riportati nel testo, 2026-11-12

Commenti
Caricamento commenti...
Accedi per commentare