A backend that starts out as "controller, service, repository" works as long as the business rules are three lines long. When they become thirty, you discover that the rule "a member cannot have more than three loans" lives in a method that, to be tested, needs the database, the Spring context and an HTTP request. Clean Architecture solves this problem with a single rule, which Robert C. Martin calls the Dependency Rule: source code dependencies can only point inwards, and nothing in an inner circle can know anything about an outer circle. This chapter builds a small library-loans API in four layers, runs it in tests and has a test check the rule, not a good intention. It comes after the chapter on Angular, because it is the side that serves that data.
The test project is mine, Maven, with Spring Boot 4.1.2 (the latest stable version in the Maven Central repository on 13 November 2026: the most recent in the list is 4.2.0-RC2, a release candidate), Java 21, Hibernate 7.4.13 and H2 2.4.240 in memory in place of a real database. The documentation says that Spring Boot 4.1.2 requires at least Java 17 and Spring Framework 7.0.10. No port is open: requests go through MockMvc, which invokes the DispatcherServlet inside the test process.
Four folders, one direction
The project has four packages: domain for the rules, application for the use cases, infrastructure for everything that talks to the database or to Spring, presentation for the controllers. The arrows go from the outside in: presentation uses the use cases, the use cases use the domain, and infrastructure implements the interfaces that the application declares. The domain imports nothing. You need the pom.xml file only to see where the libraries come from, and here are the dependencies used (the rest is the standard configuration of the Spring Boot parent).
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>com.tngtech.archunit</groupId><artifactId>archunit-junit5</artifactId><version>1.5.1</version><scope>test</scope></dependency>
The three starters show what the application does: spring-boot-starter-webmvc for the APIs, spring-boot-starter-validation for the constraints on the request body, spring-boot-starter-data-jpa for persistence. The h2 database with runtime scope is here only because the trial does not require an external service; in production you would put your own database's driver in its place. The spring-boot-starter-webmvc-test starter brings MockMvc for the tests, and the ArchUnit library, also with test scope, checks the dependencies between packages.
The domain: a rule you can test without Spring
The domain contains a loan and the rule that limits their number. The Loan record has a static method open that receives the number of loans already active and refuses to open the fourth with a domain exception. No annotation, no import from org.springframework or from jakarta: it is just Java. I chose to pass today as a parameter instead of reading the time inside the method, because this way the test decides the date and the result does not change from one day to the next.
// domain/Loan.java
import java.time.LocalDate;
/** A loan. No dependency on Spring, JPA or the web: just Java. */
public record Loan(Long id, String memberId, String isbn, LocalDate borrowedOn, LocalDate dueOn) {
public static final int MAX_ACTIVE_LOANS = 3;
public static final int LOAN_DAYS = 28;
/** Business rule: a member cannot have more than three open loans. */
public static Loan open(String memberId, String isbn, LocalDate today, int activeLoans) {
if (activeLoans >= MAX_ACTIVE_LOANS) {
throw new LoanLimitExceeded(memberId, MAX_ACTIVE_LOANS);
}
return new Loan(null, memberId, isbn, today, today.plusDays(LOAN_DAYS));
}
}
// domain/LoanLimitExceeded.java
public class LoanLimitExceeded extends RuntimeException {
public LoanLimitExceeded(String memberId, int limit) {
super("Member " + memberId + " already has " + limit + " open loans");
}
}
// LoanTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import it.esempio.biblioteca.domain.Loan;
import it.esempio.biblioteca.domain.LoanLimitExceeded;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
/** No Spring context: it starts in a few milliseconds. */
class LoanTest {
@Test
void expiresAfterTwentyEightDays() {
Loan loan = Loan.open("m1", "9788820399990", LocalDate.of(2026, 11, 13), 0);
assertEquals(LocalDate.of(2026, 12, 11), loan.dueOn());
System.out.println("expiry: " + loan.dueOn());
}
@Test
void theFourthLoanIsARuleViolation() {
LoanLimitExceeded e = assertThrows(LoanLimitExceeded.class,
() -> Loan.open("m1", "9788820399990", LocalDate.of(2026, 11, 13), 3));
System.out.println("rule: " + e.getMessage());
}
}
// Output:
// rule: Member m1 already has 3 open loans
// expiry: 2026-12-11
The LoanTest test starts no context: in my runs the execution took about 0.006-0.009 seconds (machine with 12 cores, repeated runs, the figure reported by Maven for the class). The two output lines come from the test's print statements: the expiry is 28 days after 13 November 2026, and at the fourth loan the rule raises LoanLimitExceeded. A rule that is tested in a few thousandths of a second is tested continuously; one that needs a four-second context, as you will see shortly, is tested less often than it should be.
The application: the use case and the port
The BorrowBook use case coordinates: it asks how many active loans the member has, asks the domain to open one, saves it. To talk to persistence it does not use JPA: it declares an interface, LoanRepository, with only the two methods it needs. This interface is the port; whoever implements it stands further out and depends on it, not the other way round. The clock arrives from the constructor as a Clock: in production it is the system clock, in tests a fixed one.
// application/LoanRepository.java
import it.esempio.biblioteca.domain.Loan;
/** Port: the application says what it needs, the infrastructure decides how. */
public interface LoanRepository {
int countByMember(String memberId);
Loan save(Loan loan);
}
// application/BorrowBook.java
import it.esempio.biblioteca.domain.Loan;
import java.time.Clock;
import java.time.LocalDate;
/** Use case. No Spring annotation: it is built with its constructor. */
public class BorrowBook {
public record Request(String memberId, String isbn) {}
public record Response(long id, String memberId, String isbn, LocalDate dueOn) {}
private final LoanRepository loans;
private final Clock clock;
public BorrowBook(LoanRepository loans, Clock clock) {
this.loans = loans;
this.clock = clock;
}
public Response execute(Request request) {
int active = loans.countByMember(request.memberId());
Loan opened = Loan.open(request.memberId(), request.isbn(), LocalDate.now(clock), active);
Loan saved = loans.save(opened);
return new Response(saved.id(), saved.memberId(), saved.isbn(), saved.dueOn());
}
}
The BorrowBook class has no Spring annotations and is built with its own constructor: new BorrowBook(repository, clock). The price is that someone has to tell Spring how to create it, and an infrastructure file does that, as you will see. The advantage is that the dependency rule "the application does not import Spring" becomes verifiable. The trade-off is real: many projects choose to put @Service on the use case to save that file, and lose a boundary in exchange for less code. If your team is small and the domain simple, it may be the right choice; if you expect to change framework or to reuse the use cases from a job, keep the boundary.
The infrastructure: JPA behind the port
Everything that knows the database lives here. LoanEntity is the JPA entity, separate from the domain record: it costs one more conversion, and in exchange the domain is not forced to have an empty constructor and modifiable fields just because JPA asks for it. JpaLoanRepository implements the port by leaning on a Spring Data interface, and BeanConfig registers BorrowBook and the clock. The classes are visible only inside the package, to stop a controller from using them directly.
// infrastructure/LoanEntity.java
import it.esempio.biblioteca.domain.Loan;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDate;
@Entity
@Table(name = "loan")
class LoanEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String memberId;
private String isbn;
private LocalDate borrowedOn;
private LocalDate dueOn;
protected LoanEntity() {} // required by JPA
static LoanEntity from(Loan loan) {
LoanEntity e = new LoanEntity();
e.id = loan.id();
e.memberId = loan.memberId();
e.isbn = loan.isbn();
e.borrowedOn = loan.borrowedOn();
e.dueOn = loan.dueOn();
return e;
}
Loan toDomain() {
return new Loan(id, memberId, isbn, borrowedOn, dueOn);
}
}
// infrastructure/LoanJpaRepository.java
import org.springframework.data.jpa.repository.JpaRepository;
interface LoanJpaRepository extends JpaRepository<LoanEntity, Long> {
int countByMemberId(String memberId);
}
// infrastructure/JpaLoanRepository.java
import it.esempio.biblioteca.application.LoanRepository;
import it.esempio.biblioteca.domain.Loan;
import org.springframework.stereotype.Repository;
@Repository
class JpaLoanRepository implements LoanRepository {
private final LoanJpaRepository jpa;
JpaLoanRepository(LoanJpaRepository jpa) {
this.jpa = jpa;
}
@Override
public int countByMember(String memberId) {
return jpa.countByMemberId(memberId);
}
@Override
public Loan save(Loan loan) {
return jpa.save(LoanEntity.from(loan)).toDomain();
}
}
// infrastructure/BeanConfig.java
import it.esempio.biblioteca.application.BorrowBook;
import it.esempio.biblioteca.application.LoanRepository;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class BeanConfig {
@Bean
Clock clock() {
return Clock.systemDefaultZone();
}
@Bean
BorrowBook borrowBook(LoanRepository loans, Clock clock) {
return new BorrowBook(loans, clock);
}
}
The countByMemberId method is a derived query: Spring Data works out the query from the method name. The trial's configuration file sets three properties: spring.jpa.open-in-view=false, spring.jpa.hibernate.ddl-auto=create-drop and the URL of the in-memory H2. The Spring Boot documentation states that open-in-view is true if you do not write it, and in that case an EntityManager stays bound to the thread for the whole processing of the request: switched off, every data access must happen in the layer that declares it. For ddl-auto the same page says that the value is create-drop with an embedded database and no schema manager, otherwise none: I wrote it for clarity, but in production you change the schema with migrations, not with Hibernate.
The presentation: validate, respond, explain the errors
The controller translates HTTP into the language of the use case and back, and nothing more. The request body is a record with two Bean Validation constraints: @NotBlank on the member and @Pattern on the 13-digit ISBN. The controller responds 201 with the Location header, which indicates where the newly created resource is. Errors are entrusted to a @RestControllerAdvice class that extends ResponseEntityExceptionHandler.
// presentation/BorrowRequest.java
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
public record BorrowRequest(
@NotBlank(message = "required") String memberId,
@Pattern(regexp = "\\d{13}", message = "the ISBN has 13 digits") String isbn) {}
// presentation/LoanController.java
import it.esempio.biblioteca.application.BorrowBook;
import jakarta.validation.Valid;
import java.net.URI;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/loans")
class LoanController {
private final BorrowBook borrowBook;
LoanController(BorrowBook borrowBook) {
this.borrowBook = borrowBook;
}
@PostMapping
ResponseEntity<BorrowBook.Response> borrow(@Valid @RequestBody BorrowRequest body) {
BorrowBook.Response done = borrowBook.execute(new BorrowBook.Request(body.memberId(), body.isbn()));
return ResponseEntity.created(URI.create("/api/v1/loans/" + done.id())).body(done);
}
}
// presentation/ApiExceptionHandler.java
import it.esempio.biblioteca.domain.LoanLimitExceeded;
import java.util.Map;
import java.util.TreeMap;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(LoanLimitExceeded.class)
ProblemDetail limit(LoanLimitExceeded e) {
ProblemDetail p = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
p.setTitle("Loan limit reached");
return p;
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException e, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
Map<String, String> errors = new TreeMap<>();
for (FieldError f : e.getBindingResult().getFieldErrors()) {
errors.putIfAbsent(f.getField(), String.valueOf(f.getDefaultMessage()));
}
ProblemDetail p = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "The request is not valid");
p.setProperty("errors", errors);
return ResponseEntity.badRequest().body(p);
}
}
Spring's documentation explains that ProblemDetail represents an error according to RFC 9457 and that ResponseEntityExceptionHandler is the base class for handling Spring MVC exceptions in that format. RFC 9457, from July 2023, replaces RFC 7807 and defines the content type application/problem+json. In the handler I made two choices of my own. The violation of the business rule becomes a 409 with an understandable title. For validation I redefined handleMethodArgumentNotValid to add an errors property with one message per field, because the default behaviour returned to me only "Invalid request content." without saying which field was wrong (I saw it in the first run).
The complete path, without opening a port
The following test starts the complete Spring context with in-memory H2 and uses MockMvc to send four valid loans and one wrong request. A fixed clock, defined in a @TestConfiguration with @Primary, makes the expiry date the same on every run. Without it, the first version of the test printed a date that would have changed the next day.
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
@SpringBootTest
@AutoConfigureMockMvc
class LoanApiTest {
@TestConfiguration
static class FixedClock {
@Bean
@Primary
Clock fixedClock() {
return Clock.fixed(Instant.parse("2026-11-13T09:00:00Z"), ZoneOffset.UTC);
}
}
@Autowired
MockMvc mvc;
private void send(String json) throws Exception {
MvcResult r = mvc.perform(post("/api/v1/loans").contentType(MediaType.APPLICATION_JSON).content(json)).andReturn();
var res = r.getResponse();
System.out.println("HTTP " + res.getStatus() + " " + res.getContentType()
+ (res.getHeader("Location") == null ? "" : " Location=" + res.getHeader("Location")));
System.out.println(" " + res.getContentAsString());
}
@Test
void fullPath() throws Exception {
String ok = "{\"memberId\":\"m7\",\"isbn\":\"9788820399990\"}";
for (int i = 0; i < 4; i++) send(ok);
send("{\"memberId\":\"\",\"isbn\":\"123\"}");
}
}
// Output:
// HTTP 201 application/json Location=/api/v1/loans/1
// {"id":1,"memberId":"m7","isbn":"9788820399990","dueOn":"2026-12-11"}
// HTTP 201 application/json Location=/api/v1/loans/2
// {"id":2,"memberId":"m7","isbn":"9788820399990","dueOn":"2026-12-11"}
// HTTP 201 application/json Location=/api/v1/loans/3
// {"id":3,"memberId":"m7","isbn":"9788820399990","dueOn":"2026-12-11"}
// HTTP 409 application/problem+json
// {"detail":"Member m7 already has 3 open loans","instance":"/api/v1/loans","status":409,"title":"Loan limit reached"}
// HTTP 400 application/problem+json
// {"detail":"The request is not valid","instance":"/api/v1/loans","status":400,"title":"Bad Request","errors":{"isbn":"the ISBN has 13 digits","memberId":"required"}}
The output contains three different responses, and each has a meaning for whoever consumes the API:
- 201 with
Location: the loan was created; the body has the identifier and the expiry. - 409 with
application/problem+json: the request was valid but the business rule rejects it, and the client can show the message to the member. - 400 with
application/problem+jsonand theerrorsmap: the request was wrong, and the client knows which fields to correct.
The distinction between 409 and 400 is the reason why it pays to keep validation and the rule separate. BorrowRequest checks the form ("13 digits"), the domain checks the sense ("the fourth loan, no"). If you put the three-loan limit in the validator, the client could not tell a typo from a prohibition, and every other entry channel, for example a nightly job, would have to repeat the rule. This test costs about 4 seconds (4.0-4.4 s in my runs), almost all of it spent starting the context.
Enforcing the arrows with a test
A dependency rule written in a document gets forgotten; written in a test, it fails the build. ArchUnit analyses the compiled classes and lets you write rules such as "no domain class depends on Spring". The listing contains two, one for the domain and one for the application. It uses packages with the ..domain.. notation (any package that contains domain).
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import org.junit.jupiter.api.Test;
class LayerRulesTest {
private final JavaClasses classes = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("it.esempio.biblioteca");
@Test
void theDomainKnowsNothingElse() {
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage(
"org.springframework..", "jakarta..", "..application..", "..infrastructure..", "..presentation..")
.check(classes);
}
@Test
void theApplicationKnowsNeitherInfrastructureNorWeb() {
noClasses().that().resideInAPackage("..application..")
.should().dependOnClassesThat().resideInAnyPackage(
"org.springframework..", "jakarta..", "..infrastructure..", "..presentation..")
.check(classes);
}
}
With clean code the test passes: mvn -B test reports five tests and zero failures, spread over three classes: LayerRulesTest (two rules), LoanTest (two tests on the domain) and LoanApiTest (the HTTP path). To see the net do its work I added, for a single run and then removed, a domain class that carries a Spring annotation, and I re-ran only LayerRulesTest. The build failed with this message, without starting the rest of the application.
package it.esempio.biblioteca.domain;
import org.springframework.stereotype.Component;
@Component
public class BadRule {}
// ArchUnit output (main lines):
// Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in a package '..domain..' should depend on classes that reside in any package ['org.springframework..', 'jakarta..', '..application..', '..infrastructure..', '..presentation..']' was violated (1 times):
// Class <it.esempio.biblioteca.domain.BadRule> is annotated with <org.springframework.stereotype.Component> in (BadRule.java:0)
The message names the class and the annotation that violate the rule: you do not have to look for them. What this test does not catch is how the code is organised inside a package, or whether a business rule has ended up in the controller instead of in the domain: only a code review sees that. Do not use ArchUnit as a substitute for a review, but as a gate for what a machine can count, that is, the imports.
Frequently asked questions
Is it really necessary to separate the JPA entity from the domain model?
It depends on the cost you are willing to pay. With two classes you write a conversion and the domain stays free of JPA constraints, such as the empty constructor; with a single class you write less code and the domain imports jakarta.persistence. In this trial project I separated the two, and the ArchUnit test makes it verifiable.
Why use ProblemDetail instead of an error object of my own?
Because it is a standard format, RFC 9457, with the content type application/problem+json, which clients can already interpret. Spring supports it with ProblemDetail and with ResponseEntityExceptionHandler, and lets you add fields of your own, as I did with errors.
What is open-in-view if I do not write it?
According to the Spring Boot documentation it is true: an EntityManager stays bound to the thread for the whole request. In the trial project I set it to false, so data has to be read inside the layer that manages it.
What you can do now
You can split a Spring Boot backend into four layers and explain why the arrows point towards the domain. You can write a business rule that is tested without a context in a few thousandths of a second, declare a port in the application and implement it with JPA on the outside. You can return errors as application/problem+json, telling a malformed request (400) from a violated rule (409), and you can make the build fail when someone imports Spring into the domain. The next chapter compares two ways of exposing this data, GraphQL and REST; the list of the series is on the dedicated page.
The series closes with Multi-environment CI/CD, which collects the list of all eight articles.
Sources
- Robert C. Martin, The Clean Architecture (2012) - the Dependency Rule ("source code dependencies can only point inwards"), read on 2026-11-13
- RFC 9457, Problem Details for HTTP APIs - format of the problem details, content type
application/problem+json, replaces RFC 7807, read on 2026-11-13 - Spring Framework, Error Responses -
ProblemDetail,ErrorResponseandResponseEntityExceptionHandler, read on 2026-11-13 - Spring Boot, System Requirements - Spring Boot 4.1.2 requires at least Java 17 and Spring Framework 7.0.10, read on 2026-11-13
- Spring Boot, Common Application Properties - default values of
spring.jpa.open-in-view(true) andspring.jpa.hibernate.ddl-auto, page downloaded and read on 2026-11-13 - ArchUnit User Guide - dependency rules between packages with
noClasses(), page downloaded on 2026-11-13 - Maven Central, spring-boot-starter-parent - list of versions (4.1.2 latest stable, 4.2.0-RC2 candidate), queried on 2026-11-13
Commenti
Caricamento commenti...
Sign in to comment