You have a well-written business rule, and one day you need to change the way you store users, or add a cache in front of the reads. If that rule imports Hibernate or Redis classes, the change touches it too. The infrastructure layer exists to prevent this: it holds everything that talks to the outside world (database, cache, other services) and hides it behind interfaces that belong to the domain. Here you implement them, test them without a database, and check with an automatic tool that nobody, tomorrow, breaks the direction of the dependencies.
Port and adapter: the interface belongs to the domain, the code to the infrastructure
A port is an interface that describes what the domain needs, in its own language: "find me a user by email". An adapter is the class that implements it with a specific technology. The dependency arrow goes from the adapter to the port, never the other way round: this is the Dependency Rule described by Robert C. Martin, according to which source code can only point inward. The underlying idea, ports and adapters, was formulated by Alistair Cockburn under the name of hexagonal architecture.
To see the shape in action, here is the minimal domain of a user and its port. They are pure Java types: immutable records that validate themselves in the constructor.
public record UserId(String value) {
public UserId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("the user id cannot be empty");
}
}
}
public record Email(String value) {
public Email {
if (value == null || !value.contains("@")) {
throw new IllegalArgumentException("invalid email: " + 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);
}
The port returns Optional and accepts UserId and Email, not strings, so no caller can pass an empty id or an email without an at sign: the record's constructor prevents it upstream. The interface does not know what a table is, nor a Spring annotation.
JPA and the mapper: two models, one boundary
The persistence adapter needs a class that Hibernate knows how to manage: mutable, with an empty constructor, with mapping annotations. This is the JPA entity and it is not the domain. Keeping them apart costs a mapper, that is two conversion methods, and in exchange the domain stays immutable and free of Hibernate. The schema below shows the entity with its annotations and the Spring Data interface (not executed: it requires Jakarta Persistence and a database).
@Entity
@Table(name = "users")
public class UserJpaEntity {
@Id
private String id; // assigned by the domain, not by the database
@Column(nullable = false, unique = true)
private String email;
@Column(name = "password_hash", nullable = false)
private String passwordHash;
@Column(nullable = false)
private String role; // the domain enum, stored as text
// getters and setters: Hibernate uses them, the domain never sees them
}
// package-private: no other layer should be able to use it
interface JpaUserRepository extends JpaRepository<UserJpaEntity, String> {
Optional<UserJpaEntity> findByEmail(String email);
}
The identifier is a string assigned by the domain, not generated by the database. This has a consequence that the Spring Data JPA documentation describes precisely: to tell whether an entity is new, Spring Data first looks at a version property, if there is one, and without it checks the identifier, and if it is not null it considers the entity not new. In that case save calls merge instead of persist, and the insert is preceded by a read. It is not a bug, but it is a cost to know about when identifiers are born in the domain; you avoid it with a @Version property or by implementing Persistable.
To test the mapper and the adapter without a database I replaced Spring Data with an in-memory implementation of the same interface (same method names, no framework). The adapter and mapper code is what would go into production. To compile the listings you also need three support types, which I show in full: the plain version of the entity (without annotations), the interface standing in for Spring Data and its in-memory implementation, which also counts the accesses.
/** In the real project it carries @Entity and @Table; here it is a plain type with the same role. */
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 for the Spring Data interface: same method names, no 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; }
}
The reads() counter is what will let us say, further on, how many times the "database" was really queried. With these three files and the domain ones shown above, the listings in this section compile on their own.
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)));
}
}
The adapter does two things and nothing else: it calls the technical repository and converts. It contains no business rules, decides nothing about duplicate emails, and in this example does not open transactions on its own. The rules live in the domain records and in the use case. Let us run it with a program that saves a user, reads it back and then simulates a damaged row in the 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 equal: " + saved.equals(original));
System.out.println("by email: " + users.findByEmail(new Email("giulia@example.com")).map(User::id));
System.out.println("absent: " + users.findById(new UserId("u-2")).isPresent());
try {
jpa.save(corruptedRow());
users.findById(new UserId("u-9"));
} catch (IllegalArgumentException e) {
System.out.println("corrupted row rejected by the domain: " + e.getMessage());
}
}
private static UserJpaEntity corruptedRow() {
UserJpaEntity row = new UserJpaEntity();
row.setId("u-9");
row.setEmail("no-at-sign");
row.setPasswordHash("hash");
row.setRole("USER");
return row;
}
}
// Output:
// round trip equal: true
// by email: Optional[UserId[value=u-1]]
// absent: false
// corrupted row rejected by the domain: invalid email: no-at-sign
The first line is the check that matters: saving and reading back returns a user equal to the original, so the mapper loses no fields. The last one is less obvious. A row with an email lacking an at sign, written directly into the table by another program or by a faulty migration, does not enter the domain: the Email record rejects it at conversion time. The price is that the read fails with an exception instead of returning inconsistent data, and you will have to decide where to handle it.
A cache in front of the repository: decorator or @Cacheable?
A cache reduces repeated reads, but it introduces a question that the shortest examples skip: what happens when the cache does not answer? Spring Cache, with the @Cacheable and @CacheEvict annotations, has a default answer described in its documentation: "any exception thrown during a cache related operation is thrown back at the client". If Redis is unreachable, reading a user fails, even though the database works. To change this behavior you need a custom CacheErrorHandler.
The alternative is to decorate the port: a class that implements the same UserRepository, holds a reference to the real repository and to a cache, and decides on its own what to do when the cache fails. Here the choice is "fail-open": if the cache does not answer, we read from the database and log the error.
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.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 read failed, falling back to the database: " + e.getMessage());
}
Optional<User> found = delegate.findById(id);
found.ifPresent(user -> {
try {
cache.put(id, user);
} catch (RuntimeException e) {
log.accept("cache write failed: " + 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 eviction failed: " + e.getMessage());
}
return saved;
}
}
The decorator's three moves are in findById: first the cache is queried, on a miss it falls through to the underlying repository, then the result is copied back into the cache. save invalidates the entry, because the cached value would be stale. Every access to the cache is inside a try that logs the error and carries on. The decorator does not know Redis: Cache is an interface, and that is why the behavior can be tested with InMemoryCache, a fake cache that can "go down" after a call to goDown().
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 unreachable");
}
@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.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("three reads, database accesses: " + jpa.reads());
users.save(new User(id, new Email("giulia@example.com"), "hash", Role.ADMIN));
System.out.println("after save, role read: " + users.findById(id).map(User::role).orElseThrow()
+ ", database accesses: " + jpa.reads());
cache.goDown();
System.out.println("cache down, role read: " + users.findById(id).map(User::role).orElseThrow()
+ ", database accesses: " + jpa.reads());
System.out.println("log: " + log);
}
}
// Output:
// three reads, database accesses: 1
// after save, role read: ADMIN, database accesses: 2
// cache down, role read: ADMIN, database accesses: 3
// log: [cache read failed, falling back to the database: cache unreachable, cache write failed: cache unreachable]
The numbers tell the story. Three reads of the same key cost a single database access. After the save the entry is invalidated and the next read goes back to the database (accesses: 2) and returns the new role. With the cache down the system keeps answering, at the price of one database access for every read, and two errors remain in the log: one on read and one on write. A system like this keeps the service up but can overload the database exactly when Redis fails, and that is the trade-off to weigh case by case.
The same cache with Spring: configuration and traps
With Spring you can get the same result with annotations, and for an application already built around @Cacheable it is the natural path. The schema below is not executed (it requires Spring Boot and a Redis instance).
@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 separate from the adapter: the call comes from outside and the proxy intercepts it
@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); }
}
The CachedUserLookup class is a bean separate from the adapter for a technical reason: Spring applies the cache through a proxy, and the proxy intercepts only calls coming from outside. A method that calls another one of the same class skips the proxy, and the annotation does nothing. The method returning Optional is a supported case: the documentation says that a present Optional is stored and that an absent one stores null. This clashes with the configuration's disableCachingNullValues(): an absent result cannot be stored, and the case must be handled (for example with unless) and tested, because I did not run it.
Two corrections to what is often found in examples. The Spring Boot property for Redis is spring.data.redis.*, not spring.redis.* (the table of Spring Boot common properties lists it: spring.data.redis.host); moreover spring.cache.redis.time-to-live applies to the cache configured by Boot, and the documentation specifies that by default entries never expire. If you define a CacheManager yourself, as above, the spring.cache.redis.* properties do not concern it.
The cache in this site's backend
The backend uses RedisCacheManager with a bean of its own, not with Boot's properties: a comment in application.yml flags the spring.cache.redis block as "inert". I read RedisCacheConfig and these are the verified facts (2026-11-12): there are 27 caches with an individual TTL (count of the Map.entry( lines), from 60 seconds for ph-membership and ecosystem-fetch up to 7 days for taric-cache; every key has a prefix with the application version (app.cache.version, in the form version:cache-name::key).
The prefix has a precise purpose, explained in the class Javadoc: after a deploy that changes the logic of a cached value, the old entries would keep being served until they expire. By changing the version, the old entries are no longer read (they stay in Redis until their TTL) and the project avoids a FLUSHALL, because the same Redis also holds rate-limit counters and data that must not be deleted. Another detail in the code works as a warning: values are serialized as JSON with type information, and the class comment flags a trap with Java immutable collections (List.of(), Map.of()) in deserialization. The same class documents the separate bean to avoid self-invocation: CachedMembershipLookup was created exactly for this, with a 60-second TTL and an active invalidation when roles change.
Consuming another service: HTTP adapter
An external service is treated like the database: behind a port. The domain asks for "articles similar to this one" and does not know whether the answer comes from HTTP, from a queue or from a table. The schema uses RestClient, Spring's fluent HTTP client; in this site's backend it is used for example by DatasetExploreController and SportIntelligenceController, and a count of the files that mention WebClient gives zero (grep -rl WebClient backend/src/main/java, 2026-11-12). The block is not executed: it requires a server that answers.
// Port in the domain or in the application: no trace of HTTP
public interface SimilarArticles {
List<ArticleId> forArticle(ArticleId id, int limit);
}
// Adapter in the 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(); // the URL comes from configuration
}
@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("similar-articles service: " + res.getStatusCode());
})
.body(SimilarResponse.class);
return response == null ? List.of() : response.toArticleIds();
}
}
There are three choices to note. The URL comes from configuration and is not written in the code. By default RestClient throws an exception for 4xx and 5xx responses; with onStatus you replace it with an exception of your own, so the rest of the program does not depend on Spring's HTTP classes. And a null body is turned into an empty list instead of propagating. Left out of the block is what in production cannot be missing: connection and read timeouts on the client, because a slow dependency without a timeout holds a thread until it answers.
Enforcing the arrow: ArchUnit
A dependency rule that relies on goodwill lasts until the first day of rush. ArchUnit turns it into a test: it reads the .class files, builds the dependency graph and fails the check if a domain class uses an infrastructure one. I built two small identical projects, good and bad, and in the second I made a domain class import the JPA adapter. The program verifies the same rule on both. Each project has three classes: domain.Order (a record with String id and long totalCents), domain.OrderRepository (an interface with Order save(Order order)) and infrastructure.JpaOrderRepository, which implements it by returning the order it receives. In bad there is one more class, which lives in the domain and uses the adapter directly:
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("the domain must not depend on the infrastructure");
for (String root : new String[] {"good", "bad"}) {
JavaClasses classes = new ClassFileImporter().importPackages(root);
try {
rule.check(classes);
System.out.println(root + ": rule respected (" + classes.size() + " classes analyzed)");
} catch (AssertionError e) {
System.out.println(root + ": rule VIOLATED");
e.getMessage().lines().limit(10).forEach(l -> System.out.println(" " + l));
}
}
}
}
// Output:
// good: rule respected (3 classes analyzed)
// bad: rule VIOLATED
// Architecture Violation [Priority: MEDIUM] - Rule 'the domain must not depend on the 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)
The first project passes, with its three classes analyzed. The second is stopped, and the message is readable: it names the rule, the number of violations (three: the object creation in the constructor, the field type and the call to save in the place method) and the file with the line. In a real project the same check runs inside JUnit, and a commit that introduces the wrong import breaks the build.
The architecture test of this site's backend, CleanArchitectureTest, has a choice worth noting: it uses ordinary @Test methods and not the @ArchTest field. The class Javadoc explains that, in that combination of Surefire and JUnit Platform versions, the archunit-junit5 engine was not discovered and reported "Tests run: 0", a false green that protects nothing. The lesson holds beyond ArchUnit: a guard test must be seen failing at least once, otherwise you do not know whether it is active.
What must not leave the infrastructure
Three recurring mistakes can be recognized because they let out of the adapter something that should stay inside.
- The JPA entity returned by a controller, with the database columns and the lazy relations that reach the client.
- The
@Entityand@Columnannotations put on the domain model to "save the mapper". - Hibernate or HTTP exceptions that cross the use case all the way to the controller.
They are all the same violation seen from three sides: a technical detail that crosses the boundary. The cost of avoiding them is the mapper and a domain exception for every relevant technical error; the cost of not avoiding them is paid the day you change the table and discover that the public API has changed too. In this site's backend, for example, UserRepositoryAdapter implements the port, uses UserMapper and declares @Transactional(readOnly = true) on the class, with a @Transactional on save; the infrastructure/persistence/adapter folder contains 40 files (ls | wc -l, 2026-11-12). Your adapter does not have to imitate everything: the underlying idea is that the boundary exists and is verified.
Frequently asked questions
Do I always have to write a mapper by hand or can I use a library?
You can use a library that generates the code at compile time, such as MapStruct, but the value of the mapper lies in the boundary being explicit. For an entity with four fields, a hand-written mapper is read in ten seconds and debugged with a breakpoint. When entities grow, a generator avoids copy errors, but it does not replace a round-trip test like the one in the demo.
If Redis goes down, does the site stop working?
It depends on how you configured the cache. With Spring annotations and without a CacheErrorHandler, the exception reaches the caller. With a decorator like the one shown above, the service carries on by reading from the database. In both cases it is worth having a short timeout on Redis (the backend uses 2000 ms in application.yml), otherwise every read waits for the timeout before giving up.
Where do I put the validation of data coming from the database?
In the domain, as in the example's records: if the row is inconsistent, the conversion fails. Uniqueness and length constraints stay in the database as the last line of defense, because they are the only guarantees that hold even with concurrent requests. How to structure the use case that uses them is in Use Case Pattern in Spring Boot Clean Architecture.
What you can do now
You can separate a JPA entity from the domain model with a mapper and test that conversion without a database. You can decide whether to put the cache as a decorator of the port or with Spring annotations, knowing what happens in each case when Redis does not answer. And you can turn the rule "the domain does not depend on the infrastructure" into a check that really fails. For the overall picture of the layers there is the pillar article of the series, for testing these adapters with real databases Complete Testing in Clean Architecture and Testing Spring Boot with Testcontainers.
Sources
- The Clean Architecture - Robert C. Martin - the Dependency Rule; consulted on 2026-11-12
- Hexagonal architecture - Alistair Cockburn - ports and adapters as the original idea; consulted on 2026-11-12
- Declarative Annotation-based Caching - Spring Framework Reference - handling of
Optionaland the default behavior on cache errors; consulted on 2026-11-12 - Persisting Entities - Spring Data JPA Reference - persist versus merge and detection of a new entity; consulted on 2026-11-12
- Common Application Properties - Spring Boot Reference - the
spring.data.redis.*andspring.cache.redis.*properties; consulted on 2026-11-12 - REST Clients - Spring Framework Reference -
RestClient,retrieveandonStatus; consulted on 2026-11-12 - ArchUnit User Guide - the rules on dependencies between packages; consulted on 2026-11-12
- Measurements and readings on this site's backend:
RedisCacheConfig.java,application.yml,CleanArchitectureTest.javaand the commands reported in the text, 2026-11-12

Commenti
Caricamento commenti...
Sign in to comment