"You have learned to use the tools. Now you are going to build something with them."
That is the sentence module 11 ended with, and this lesson is the first one to take it seriously. Because there is an enormous difference between knowing how to use Spring, JPA, JUnit, Maven, Jackson and Logback and having a project. BiblioTech, right now, is the former: a set of excellent pieces that all live together in a single Maven module, in packages that have grown by accretion, where a JPA entity can import HttpClient, a domain service can import org.springframework, and nothing —absolutely nothing— stops someone from dropping a SQL query inside FineCalculator tomorrow.
That works. With eleven course modules on top of it, it works. The problem is not that it fails today: it is that it does not survive growth. A project without boundaries degrades in a predictable way, and the mechanism is always the same: someone is in a hurry, the class they need is one import away, and nothing stands in their way. Repeat that two hundred times and you get what the industry calls, without affection, "the big ball of mud".
This lesson turns BiblioTech into a professional project. It adds not a single feature. It adds structure: an explicit architecture, boundaries the compiler verifies, per-environment configuration, orderly version control, automatic formatting and documentation that genuinely helps.
By the end you will be able to design the structure of a real Java project, you will understand layered and hexagonal architecture and know when to use each, you will organise packages with a criterion, you will turn a project into a Maven multi-module build so that the dependency graph itself physically prevents architectural mistakes, you will separate DTOs from entities, you will configure the application per environment without leaking secrets, and you will leave the repository in a state where somebody else can clone it and get it running in five minutes.
Contents
- The problem: what happens to a project with no structure
- What an application's architecture is
- Layered architecture
- The dependency rule
- Hexagonal architecture: ports and adapters
- Comparison: layers versus hexagonal
- Package organisation: by layer versus by feature
- BiblioTech's package tree, in both options
- A justified recommendation
- Maven multi-module project applied to BiblioTech
- The parent POM and
dependencyManagement - How the module graph stops the domain importing Spring
- DTOs versus entities
- Configuration per environment:
application.ymland profiles - Spring Boot's hierarchy of configuration sources
- Environment variables and secrets outside the repository
- Version control:
.gitignore, branches and conventional commits - Formatting and style:
.editorconfigand Spotless - A
README.mdthat genuinely helps - Architecture Decision Records (ADR)
- Start-up scripts and local dependencies
- The complete tree of the restructured BiblioTech
- Common Mistakes and Tips
- Exercises
- Conclusion
- The problem: what happens to a project with no structure
Before proposing solutions, it is worth seeing the problem precisely. This is a real fragment of today's BiblioTech:
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.model.Loan;
import com.nexussoftware.bibliotech.repository.LoanRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.net.http.HttpClient; // what is this doing here?
@Service
public class LoanManager {
private final LoanRepository repository;
private final HttpClient http; // a business service with an HTTP client inside
// ...
}Every line of this class is defensible on its own. The whole is not:
| Symptom | Medium-term consequence |
|---|---|
The business logic imports org.springframework |
You cannot test it without starting a context; changing framework means rewriting |
The business logic imports java.net.http |
To test the fine calculation you need the network or an HTTP mock |
| Everything lives in one Maven module | Nothing stops the Loan entity calling the repository, or the repository calling the controller |
| JPA entities travel outwards | Changing a column breaks the API's clients |
| No declared dependency direction | Cycles appear: service → web → service |
The final symptom is always the same: the time it takes to make a small change grows. And it grows because any change can break anything, because there is no way of reasoning about one part without knowing the whole.
Architecture is, precisely, the set of decisions that limit what can be done. Good architecture does not give you powers: it takes bad options away from you.
- What an application's architecture is
An operational definition, without mysticism:
An application's architecture is the division of the system into parts, the assignment of responsibilities to each part and the rules about which part may depend on which.
All three matter, but the third is the one people forget and the only one that degrades on its own. Splitting into model, service and web is easy; the hard part is that six months from now model still does not depend on web.
There are two questions every architecture answers:
- Where does the business logic live? (the rules that would exist even if computers did not: a loan lasts 15 days, a fine is €0.50 per day, an employee cannot have more than 3 active loans)
- How is that logic isolated from technology? (Spring, JPA, HTTP, PostgreSQL, JSON… all of that is detail: it changes every few years)
The two architectures we are about to see answer the first the same way and the second differently.
- Layered architecture
This is the classic organisation, and probably the one holding up the most Java applications in the world. The system is divided into horizontal layers, and each layer may only call the one immediately below it.
flowchart TD
P["Presentation<br/>REST, CLI, views"]
A["Application / Service<br/>use cases, transactions"]
D["Domain<br/>entities, business rules"]
I["Infrastructure<br/>JPA, HTTP, files, SMTP"]
P --> A
A --> D
A --> I
I --> D
style D fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
What each layer is responsible for in BiblioTech:
| Layer | What it holds in BiblioTech | What it may NOT hold |
|---|---|---|
| Presentation | LoanController (REST), Picocli commands, output formatters |
Business rules, database queries |
| Application / Service | LoanManager, ReservationProcessor, NoticeService, transactions |
SQL, JSON, HTTP, presentation details |
| Domain | Material, Book, Loan, Employee, FineCalculator, Severity |
Absolutely nothing external |
| Infrastructure | JPA repositories, HttpMetadataGateway (HTTP), mail sending, file reading |
Business rules |
A concrete example of a correct split, using the "return a loan" use case:
// PRESENTATION: translates HTTP into a use-case call. Nothing else.
@PostMapping("/api/loans/{id}/return")
public ResponseEntity<LoanCard> returnItem(@PathVariable Long id) {
return ResponseEntity.ok(loanManager.returnItem(id));
}
// APPLICATION: orchestrates, delimits the transaction, decides no rules.
@Transactional
public LoanCard returnItem(Long id) {
Loan loan = repository.findById(id)
.orElseThrow(() -> new LoanNotFoundException(id));
Money fine = loan.registerReturn(LocalDate.now(clock)); // the rule lives in the domain
notices.notifyReturn(loan);
return LoanCard.from(loan);
}
// DOMAIN: the business rule. No Spring, no visible JPA, no HTTP.
public Money registerReturn(LocalDate date) {
if (this.returnDate.isPresent()) {
throw new LoanAlreadyReturnedException(this.id);
}
this.returnDate = Optional.of(date);
this.status = LoanStatus.RETURNED;
return FineCalculator.calculate(this.dueDate, date);
}Notice where each decision sits. The controller does not know what a fine is. The service does not know how it is calculated. The domain does not know HTTP exists. Each layer knows exactly enough.
- The dependency rule
This is the heart of everything that follows, so it gets its own callout:
The domain depends on nothing. Everything else depends on the domain.
The immediate consequence looks like a problem: if the application service needs to save a Loan in the database, and the database is infrastructure, is the domain not depending on infrastructure?
No, not if the dependency is inverted. This is SOLID's D (dependency inversion, which we will formalise in 12-02) and you already used it in 11-02 without that name:
// IN THE DOMAIN: an interface the domain defines because the domain needs it.
package com.nexussoftware.bibliotech.domain.port;
public interface LoanRepository {
Optional<Loan> findById(Long id);
Loan save(Loan loan);
List<Loan> dueOn(LocalDate date);
}// IN THE INFRASTRUCTURE: the implementation, which does know JPA.
package com.nexussoftware.bibliotech.infrastructure.persistence;
@Repository
class JpaLoanRepository implements LoanRepository {
private final LoanSpringDataRepository delegate; // Spring Data, module 11
JpaLoanRepository(LoanSpringDataRepository delegate) {
this.delegate = delegate;
}
@Override
public Optional<Loan> findById(Long id) {
return delegate.findById(id);
}
// ...
}The compile-time arrow points from infrastructure to domain (JpaLoanRepository imports LoanRepository), even though the runtime call goes from the domain to the infrastructure. The direction of the dependency and the direction of the flow are different things, and that is the single most important idea in this lesson.
flowchart LR
S["LoanManager<br/>(application)"]
Pu["LoanRepository<br/>(interface, domain)"]
Im["JpaLoanRepository<br/>(infrastructure)"]
S -->|"uses"| Pu
Im -.->|"implements"| Pu
style Pu fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
Nobody in the domain or the application ever writes import ...infrastructure....
- Hexagonal architecture: ports and adapters
Hexagonal architecture (Alistair Cockburn, 2005; also called ports and adapters) takes the previous idea all the way. Its thesis:
The application has an inside (domain and use cases) and an outside (everything that touches it). The inside knows nothing about the outside. Communication always goes through ports (interfaces defined by the inside), and each concrete technology is an adapter plugged into a port.
There are two kinds of port:
- Inbound ports (driving): what the application offers. The adapters that use them are the ones driving the application: REST, CLI, a test, a message consumer.
- Outbound ports (driven): what the application needs. The adapters that implement them are driven by the application: JPA, HTTP, SMTP, the file system.
flowchart LR
subgraph EXT_LEFT["Inbound adapters"]
REST["REST<br/>LoanController"]
CLI["CLI<br/>LoanCommand"]
SOCK["Socket<br/>CatalogServer"]
end
subgraph CORE["Application core"]
PE["Inbound ports<br/>ManageLoans"]
DOM["DOMAIN<br/>Loan, Material,<br/>FineCalculator"]
PS["Outbound ports<br/>LoanRepository<br/>MetadataGateway<br/>NoticeSender"]
PE --> DOM
DOM --> PS
end
subgraph EXT_RIGHT["Outbound adapters"]
JPA["JPA / PostgreSQL"]
HTTP["HttpClient"]
MAIL["SMTP"]
end
REST --> PE
CLI --> PE
SOCK --> PE
PS -.-> JPA
PS -.-> HTTP
PS -.-> MAIL
style DOM fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
In BiblioTech almost all the ports already exist, just scattered and unnamed:
| Port | Kind | Current adapter | Another possible adapter |
|---|---|---|---|
ManageLoans |
Inbound | LoanController (12-04) |
The CLI's LoanCommand (12-03) |
QueryCatalog |
Inbound | CatalogServer (module 9) |
REST, CLI |
LoanRepository |
Outbound | JpaLoanRepository |
In memory, for tests |
MetadataGateway |
Outbound | HttpMetadataGateway (HttpClient) |
Local file, WireMock |
NoticeSender |
Outbound | NoticeService over email |
Console, message queue |
The benefit shows up when testing. With ports, a test of the complete use case needs neither a database nor a network:
@Test
void returningLateProducesAFine() {
var repository = new InMemoryLoanRepository(); // test adapter
var sender = new SilentNoticeSender();
var clock = Clock.fixed(Instant.parse("2026-03-20T10:00:00Z"), ZoneId.of("Europe/Madrid"));
var manager = new LoanManager(repository, sender, clock);
repository.save(aLoanOf("978-0000000001", "Marta Ruiz")
.dueOn(LocalDate.of(2026, 3, 10)));
Money fine = manager.returnItem(1L).fine();
assertThat(fine).isEqualTo(Money.euros("5.00")); // 10 days × €0.50
}No @SpringBootTest, no H2, no @DataJpaTest. Milliseconds. That is what hexagonal architecture buys you.
- Comparison: layers versus hexagonal
| Aspect | Layered | Hexagonal (ports and adapters) |
|---|---|---|
| Metaphor | A horizontal stack | A core surrounded by sockets |
| Direction of dependencies | Top down (and infrastructure to domain, if inverted) | Always towards the core |
| Where the persistence interfaces are defined | Often in the infrastructure layer | Always in the core |
| Number of interfaces | Fewer | More (one port per external need) |
| Testing the core without infrastructure | Possible with effort | Natural |
| Changing database or framework | Costly if there were leaks | Write a new adapter |
| Learning curve | Low, everybody knows it | Medium |
| Risk | Layers get skipped | Over-engineering: ports for everything |
| Fits well in | CRUD, small or medium applications | Systems with rich business rules and a long life |
What you should not conclude: that hexagonal is "better". A CRUD with 8 entities done in full hexagonal has three times as many files and zero advantage. And they are compatible: hexagonal is, in practice, layered architecture with the dependency rule applied without exceptions and the interfaces placed on the right side.
BiblioTech is going to use layers with dependency inversion at the outer boundaries, which is lightweight hexagonal: ports where there is external technology (persistence, HTTP, notifications), direct calls where there is not.
- Package organisation: by layer versus by feature
With the architecture decided, what remains is deciding how it translates into packages. There are two schools.
By layer (layer-first): the first level of packages is the layer.
By feature (feature-first, or package by feature): the first level is the business area.
| Criterion | By layer | By feature |
|---|---|---|
| When adding a feature | You touch 4 distant packages | You touch 1 package |
| When reading the project for the first time | You see the technology | You see the business |
| Cohesion | Low: service mixes loans and catalogue |
High |
| Visible coupling | Hidden | Obvious (cross imports jump out at you) |
Use of package visibility (package-private) |
Almost impossible | Very effective: you can hide the feature's internal classes |
| Deleting a feature | Archaeology | Delete a directory |
| Scales to 50 classes | Acceptable | Well |
| Scales to 500 classes | Badly | Well |
The decisive argument is the one about visibility. With packages by feature you can write:
package com.nexussoftware.bibliotech.loans;
// package-private: NOBODY outside the "loans" feature can touch this.
class FineCalculator { ... }With packages by layer, FineCalculator sits in service next to twenty other classes and has to be public so that whoever needs it can use it: that is, public to the entire project. The ability to hide is what stops boundaries eroding.
- BiblioTech's package tree, in both options
Option A — by layer:
src/main/java/com/nexussoftware/bibliotech/
├── BiblioTechApplication.java
├── controller/
│ ├── LoanController.java
│ ├── CatalogController.java
│ └── ReservationController.java
├── service/
│ ├── LoanManager.java
│ ├── ReservationProcessor.java
│ ├── NoticeService.java
│ ├── BiblioTechStatistics.java
│ └── CatalogEnricher.java
├── domain/
│ ├── Material.java
│ ├── Book.java
│ ├── Magazine.java
│ ├── Dvd.java
│ ├── Loan.java
│ ├── Reservation.java
│ ├── Employee.java
│ ├── LoanStatus.java
│ └── Severity.java
├── repository/
│ ├── LoanRepository.java
│ ├── MaterialRepository.java
│ └── ReservationRepository.java
└── dto/
├── MaterialCardDto.java
└── CreateLoanDto.javaOption B — by feature:
src/main/java/com/nexussoftware/bibliotech/
├── BiblioTechApplication.java
├── shared/ ← only what is genuinely cross-cutting
│ ├── Money.java
│ ├── Isbn.java
│ ├── Severity.java
│ └── BiblioTechException.java
├── catalog/
│ ├── Material.java (package-private where possible)
│ ├── Book.java
│ ├── Magazine.java
│ ├── Dvd.java
│ ├── MaterialType.java
│ ├── CatalogService.java ← the feature's public API
│ ├── CatalogController.java
│ ├── MaterialRepository.java
│ └── metadata/
│ ├── MetadataGateway.java (port)
│ └── HttpMetadataGateway.java (HTTP adapter)
├── loans/
│ ├── Loan.java
│ ├── LoanStatus.java
│ ├── FineCalculator.java (package-private)
│ ├── LoanManager.java ← public API
│ ├── LoanController.java
│ └── LoanRepository.java
├── reservations/
│ ├── Reservation.java
│ ├── ReservationProcessor.java
│ └── ReservationRepository.java
├── employees/
│ ├── Employee.java
│ └── EmployeeRepository.java
└── notices/
├── NoticeSender.java (port)
└── NoticeService.java (adapter)
- A justified recommendation
For BiblioTech: by feature at the first level, by layer inside each feature. That is, option B.
The reasons, in order of weight:
- The project is going to grow. Module 12 adds a CLI, web, security and observability to it. By layer, the
servicepackage would end up with twenty unrelated classes. - It lets you hide.
FineCalculatoris a detail of how loans work; nobody else should be able to call it. Only packaging by feature enforces that. - The code reads by business, not by technology. When Nuria Vidal asks "where is the rule about expired reservations?", the answer is
reservations/, not "look in service, then in domain, then in repository". - Changes are local. Adding "loan renewal" touches
loans/and nothing else. - It makes coupling visible. If
reservationsneeds five classes fromloans, theimports shout about it. With packages by layer, that same coupling is invisible.
An honest warning: option B has a weak spot, the shared package. It tends to become a junk drawer. The rule is: something goes into shared only if three or more features use it and it belongs to none of them. Money and Isbn do; FineCalculator does not, even if two features reuse it.
- Maven multi-module project applied to BiblioTech
Packages are a convention: the compiler does not stop domain importing controller. Maven modules do stop it, because a module only sees what it declares as a dependency, and Maven forbids cycles.
This is the structure BiblioTech is going to have:
flowchart BT
DOM["bibliotech-domain<br/>no external dependencies"]
APP["bibliotech-application<br/>use cases"]
INF["bibliotech-infrastructure<br/>JPA, HTTP, Spring"]
CON["bibliotech-console<br/>Picocli"]
WEB["bibliotech-web<br/>Spring MVC"]
APP --> DOM
INF --> DOM
INF --> APP
CON --> APP
CON --> INF
WEB --> APP
WEB --> INF
style DOM fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
Each module's responsibility and permitted dependencies:
| Module | Contains | Depends on | Permitted external dependencies |
|---|---|---|---|
bibliotech-domain |
Entities, value objects, rules, ports, exceptions | Nothing | None (at most, the validation API) |
bibliotech-application |
Use cases, orchestration, internal DTOs | domain | None, or only transaction annotations |
bibliotech-infrastructure |
JPA, HTTP and mail adapters, Spring configuration | domain, application | Spring, Hibernate, Jackson, HttpClient |
bibliotech-console |
Picocli commands, formatters | application, infrastructure | Picocli, Spring Boot |
bibliotech-web |
REST controllers, API DTOs, global error handler | application, infrastructure | Spring Web, validation, springdoc |
The resulting directory structure:
bibliotech/
├── pom.xml ← parent POM (packaging: pom)
├── mvnw / mvnw.cmd / .mvn/
├── bibliotech-domain/
│ ├── pom.xml
│ └── src/{main,test}/java/...
├── bibliotech-application/
│ ├── pom.xml
│ └── src/{main,test}/java/...
├── bibliotech-infrastructure/
│ ├── pom.xml
│ └── src/{main,test}/{java,resources}/...
├── bibliotech-console/
│ ├── pom.xml
│ └── src/{main,test}/{java,resources}/...
└── bibliotech-web/
├── pom.xml
└── src/{main,test}/{java,resources}/...
- The parent POM and
dependencyManagement
dependencyManagementThe parent POM produces no code: it aggregates the modules and centralises the versions. Remember from 11-05 that dependencyManagement declares versions without adding dependencies; the children inherit them and only write groupId and artifactId.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- We inherit from Spring Boot: it gives us the BOM with compatible versions
for over 400 libraries, plus the default plugin configuration. -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
<relativePath/>
</parent>
<groupId>com.nexussoftware</groupId>
<artifactId>bibliotech</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging> <!-- key: aggregator, produces no jar -->
<name>BiblioTech</name>
<description>Management of Nexus Software's internal technical library</description>
<!-- The order here is irrelevant: Maven sorts the modules by their dependencies. -->
<modules>
<module>bibliotech-domain</module>
<module>bibliotech-application</module>
<module>bibliotech-infrastructure</module>
<module>bibliotech-console</module>
<module>bibliotech-web</module>
</modules>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<picocli.version>4.7.6</picocli.version>
<mapstruct.version>1.6.2</mapstruct.version>
<springdoc.version>2.6.0</springdoc.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- Our own modules: the children will use them without repeating the version. -->
<dependency>
<groupId>com.nexussoftware</groupId>
<artifactId>bibliotech-domain</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.nexussoftware</groupId>
<artifactId>bibliotech-application</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.nexussoftware</groupId>
<artifactId>bibliotech-infrastructure</artifactId>
<version>${project.version}</version>
</dependency>
<!-- External ones Spring Boot does not manage -->
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli-spring-boot-starter</artifactId>
<version>${picocli.version}</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<!-- These ones EVERYBODY does inherit, because everybody tests. -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>The domain's POM is where the architecture becomes verifiable by the tooling:
<project ...>
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.nexussoftware</groupId>
<artifactId>bibliotech</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>bibliotech-domain</artifactId>
<name>BiblioTech :: Domain</name>
<!-- Look carefully: there are NO production <dependencies>.
No Spring, no Hibernate, no Jackson, no third-party HTTP client.
Only the Java 21 standard library.
This is not decoration: it is the dependency rule, enforced by Maven. -->
</project>And the infrastructure one, which may indeed bring in technology:
<project ...>
<parent>
<groupId>com.nexussoftware</groupId>
<artifactId>bibliotech</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>bibliotech-infrastructure</artifactId>
<name>BiblioTech :: Infrastructure</name>
<dependencies>
<dependency>
<groupId>com.nexussoftware</groupId>
<artifactId>bibliotech-application</artifactId> <!-- version inherited from the parent -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
</project>Building and checking:
# Builds the five modules in the right order (Maven works it out from the graph)
./mvnw clean install
# Only the domain and what it needs to compile (-am = also make)
./mvnw -pl bibliotech-domain -am test
# The domain's dependency tree: it should be practically empty
./mvnw -pl bibliotech-domain dependency:tree
- How the module graph stops the domain importing Spring
This is the part that turns a recommendation into a guarantee. Suppose Diego Alonso, in a hurry, writes this in the domain module:
package com.nexussoftware.bibliotech.domain.loans;
import org.springframework.stereotype.Service; // inside bibliotech-domain
@Service
public class FineCalculator { ... }Result:
$ ./mvnw -pl bibliotech-domain compile
[ERROR] /.../FineCalculator.java:[3,32] package org.springframework.stereotype does not exist
[ERROR] /.../FineCalculator.java:[5,2] cannot find symbol: class Service
[INFO] BUILD FAILUREIt is not a convention someone has to remember during code review: it is a compilation error. And the same happens in the other direction: if someone tries to make the domain depend on infrastructure to "fix it", Maven detects the cycle:
[ERROR] The projects in the reactor contain a cyclic reference:
bibliotech-domain -> bibliotech-infrastructure -> bibliotech-domainThis is the decisive argument in favour of multi-module, and it deserves stating as a general principle:
Rules that depend on human discipline get broken. Rules that depend on the tooling do not.
The same idea as the lombok.config in 11-07 (turning "do not use @Data on entities" into something the compiler verifies) applied to the entire architecture.
If for some reason you cannot go multi-module, the alternative is ArchUnit (mentioned in 11-07), which expresses the same rules as JUnit tests:
@AnalyzeClasses(packages = "com.nexussoftware.bibliotech")
class ArchitectureRulesTest {
@ArchTest
static final ArchRule domainDoesNotDependOnSpring =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage("org.springframework..");
@ArchTest
static final ArchRule domainDoesNotDependOnJpa =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage("jakarta.persistence..");
@ArchTest
static final ArchRule noCycles =
slices().matching("com.nexussoftware.bibliotech.(*)..").should().beFreeOfCycles();
}It is worse than multi-module (the rule is checked at test time, not at compile time), but it is infinitely better than nothing, and it can be applied today to any project without restructuring it.
- DTOs versus entities
Now a decision that looks minor and ruins projects: can a REST controller return a JPA entity directly?
Technically yes. Jackson serialises Loan without complaining. And it is a mistake, for six concrete reasons:
| Problem | What exactly happens |
|---|---|
| Contract coupled to the schema | You rename column due_dt to due_date and break every client of the API |
| Data leakage | The Employee entity has passwordHash, nationalId, salary. All of it travels in the JSON |
| Lazy loading | Jackson touches loan.getMaterial() outside the transaction: LazyInitializationException, or worse, N+1 (11-03) |
| Infinite cycles | Loan → Employee → List<Loan> → StackOverflowError |
| Dangerous input | With @RequestBody Loan, a client can send {"id": 7, "version": 3, "status": "RETURNED"} and change fields it should not |
| Different formats | The API wants LocalDate in ISO-8601 and a computed daysRemaining field; the entity has no reason to have either |
The solution is a DTO (Data Transfer Object): an object whose only job is to cross the boundary. And in Java 21, a DTO is a record (04-07):
package com.nexussoftware.bibliotech.web.dto;
/**
* What the API RETURNS for a loan. A public, stable contract,
* independent of how the entity is modelled inside.
*/
public record LoanResponse(
Long id,
String materialTitle,
String isbn,
String employeeName,
LocalDate loanDate,
LocalDate dueDate,
LocalDate returnDate, // null if still on loan: JSON has no Optional
String status,
long daysRemaining, // computed field the entity does not have
BigDecimal fine) {
public static LoanResponse from(Loan l, LocalDate today) {
return new LoanResponse(
l.getId(),
l.getMaterial().getTitle(),
l.getMaterial().getIsbn().value(),
l.getEmployee().getName(),
l.getLoanDate(),
l.getDueDate(),
l.getReturnDate().orElse(null),
l.getStatus().name(),
ChronoUnit.DAYS.between(today, l.getDueDate()),
l.accruedFine(today).amount());
}
}/**
* What the API ACCEPTS in order to create a loan. Only the fields
* the client has the right to decide. No id, no version, no status.
*/
public record CreateLoanRequest(
@NotBlank @Isbn String isbn,
@NotNull Long employeeId,
@Positive @Max(30) Integer days) {
}Notice what the asymmetry between the two records achieves: the client cannot send id, version or status, because the object it is deserialised into has no such components. Security does not depend on the server remembering to ignore them.
On mapping: writing from(...) by hand is perfectly acceptable and is what we will do, because it is explicit and adds no magic. When DTOs multiply, MapStruct (mentioned in 11-07) generates those mappers at compile time from an annotated interface, with no reflection and no runtime cost:
@Mapper(componentModel = "spring")
public interface LoanMapper {
@Mapping(target = "materialTitle", source = "material.title")
@Mapping(target = "employeeName", source = "employee.name")
LoanResponse toResponse(Loan loan);
}Practical rule: JPA entity inwards, DTO outwards. The application's boundary (REST, CLI, messaging) never sees an entity.
- Configuration per environment:
application.yml and profiles
application.yml and profilesBiblioTech has had dev and prod profiles since 11-02. Now they need organising properly, with one underlying rule:
The same artefact is deployed to every environment. What changes is the configuration, never the jar.
Base file, bibliotech-web/src/main/resources/application.yml:
spring:
application:
name: bibliotech
jpa:
open-in-view: false # ALWAYS turn it off: avoids queries in the view layer (11-03)
properties:
hibernate:
jdbc.batch_size: 25
threads:
virtual:
enabled: true # Java 21 virtual threads (10-06), explained in 12-04
server:
port: 8080
shutdown: graceful # graceful shutdown, developed in 12-06
bibliotech: # our own properties, typed in BiblioTechProperties
loan:
default-days: 15
max-per-employee: 3
fine:
euros-per-day: 0.50
max: 20.00
metadata:
url: https://api.metadata.example/v1
timeout: 3s
logging:
level:
com.nexussoftware.bibliotech: INFODevelopment profile, application-dev.yml:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/bibliotech
username: bibliotech
password: bibliotech # local, disposable, never reused elsewhere
jpa:
show-sql: true
hibernate:
ddl-auto: validate # never 'update': the schema is governed by Flyway (12-06)
flyway:
enabled: true
logging:
level:
com.nexussoftware.bibliotech: DEBUG
org.hibernate.SQL: DEBUGProduction profile, application-prod.yml:
spring:
datasource:
url: ${BIBLIOTECH_DB_URL} # no default: if it is missing, the app does NOT start
username: ${BIBLIOTECH_DB_USER}
password: ${BIBLIOTECH_DB_PASSWORD}
hikari:
maximum-pool-size: 20
jpa:
show-sql: false
hibernate:
ddl-auto: validate
server:
error:
include-stacktrace: never # never leak stack traces to the client (12-04, 12-07)
include-message: never
logging:
level:
root: WARN
com.nexussoftware.bibliotech: INFOThat ${BIBLIOTECH_DB_URL} has no default value is deliberate: if the variable is not defined, the application fails to start with a clear message. That is infinitely preferable to starting in production while silently pointing at a test database.
Activating the profile:
# In development
./mvnw -pl bibliotech-web spring-boot:run -Dspring-boot.run.profiles=dev
# In production (environment variable, not argument)
SPRING_PROFILES_ACTIVE=prod java -jar bibliotech-web.jar
- Spring Boot's hierarchy of configuration sources
Spring Boot reads configuration from many places and resolves conflicts by precedence. This table, from highest to lowest priority (an abridged version of the official one, with what is actually used), is one of those worth keeping to hand:
| # | Source | Example | Typical use |
|---|---|---|---|
| 1 | Command-line arguments | --server.port=9090 |
One-off tweak, debugging |
| 2 | JVM system properties | -Dserver.port=9090 |
Start-up from scripts |
| 3 | Environment variables | SERVER_PORT=9090 |
Production and containers |
| 4 | External application-{profile}.yml (next to the jar) |
./config/application-prod.yml |
Operator configuration |
| 5 | Packaged application-{profile}.yml |
application-prod.yml |
Per-environment differences |
| 6 | External application.yml |
./application.yml |
Operator override |
| 7 | Packaged application.yml |
application.yml |
Base values |
| 8 | @PropertySource |
— | Legacy cases |
| 9 | Default values in the code | @Value("${x:10}") |
Last resort |
Two practical consequences:
- Relaxed binding:
bibliotech.fine.euros-per-daycan be set with the environment variableBIBLIOTECH_FINE_EUROSPERDAY. Upper case, dots and hyphens to underscores. That correspondence is what makes it possible to configure anything in a container without touching files. - Debugging the configuration: the
/actuator/envendpoint (secured, as we will see in 12-07) shows the effective value of every property and which source it came from. It is the answer to "I could have sworn I set port 9090".
- Environment variables and secrets outside the repository
IMPORTANT WARNING. No credential, API key, certificate, database password, token or signing secret may live in the repository. Ever. Not in
application.yml, not in a.propertiesfile that is "only for testing", not in a comment, not in a test. Git remembers forever: deleting it in a later commit does not remove it from history, and if the repository has been cloned or published, the secret is already compromised and must be rotated, not hidden.
The mechanisms, from least to most serious:
| Mechanism | When | Watch out for |
|---|---|---|
| Environment variables | Almost always; the de facto standard in containers | Visible in /proc and in environment dumps |
A local .env file, ignored by Git |
Development | Make sure it is in .gitignore before you create it |
| An external file mounted at deployment time | Your own servers | Permissions 600 and the right owner |
| A secrets manager (Vault, AWS Secrets Manager, Kubernetes Secrets) | Serious production | Developed in 12-07 |
Early detection, which is what actually prevents the incident:
# Search for secrets in the WHOLE history, not just the current tree
gitleaks detect --source . --verbose
# As a pre-commit hook, so it never even gets in
pre-commit run --all-files12-07 returns to this topic with the full management story: rotation, encryption at rest and what to do once it has already happened.
- Version control:
.gitignore, branches and conventional commits
.gitignore, branches and conventional commitsThe .gitignore, at the root of the multi-module project:
# --- Maven ---
target/
!.mvn/wrapper/maven-wrapper.jar
.mvn/timing.properties
dependency-reduced-pom.xml
# --- Java ---
*.class
*.jar
*.war
hs_err_pid*.log
replay_pid*.log
# --- IDE: IntelliJ ---
.idea/
*.iml
*.iws
# --- IDE: Eclipse ---
.classpath
.project
.settings/
bin/
# --- IDE: VS Code (launch.json is versioned if the team shares it) ---
.vscode/*
!.vscode/launch.json
!.vscode/settings.json
# --- Operating system ---
.DS_Store
Thumbs.db
# --- Local and secrets ---
.env
*.local.yml
application-local.yml
/config/secrets/
*.p12
*.jks
*.pemThe last two sections are the important ones. Everybody has target/; it is the .p12 and .env files that cause grief.
Branching strategy. For a small team like Nexus Software's, trunk-based with short-lived branches:
| Branch | Lives | Purpose |
|---|---|---|
main |
Always | Always deployable. Protected: nobody pushes to it directly |
feature/xxx |
Hours or a few days | One feature. Integrated via Pull Request with CI green |
fix/xxx |
Hours | A fix |
release/x.y |
Only if several versions are supported in parallel | Maintenance of an older version |
Long-lived branches = big integration conflicts. The rule is: if a branch has been open for more than three days, the work was badly sliced.
Conventional commit messages (Conventional Commits). This is not bureaucracy: it lets you generate the changelog and derive the semantic version automatically.
Format: type(scope): description in the imperative
| Type | Meaning | Real BiblioTech example |
|---|---|---|
feat |
New feature | feat(loans): allow a loan to be renewed once |
fix |
Bug fix | fix(fines): do not charge for holiday closure days |
refactor |
Internal change with no behaviour change | refactor(catalog): extract MetadataGateway as a port |
test |
Tests | test(loans): cover the limit of 3 active loans |
docs |
Documentation | docs(readme): add start-up instructions with Docker |
build |
Build and dependencies | build(deps): bump Spring Boot to 3.3.4 |
ci |
Continuous integration | ci: publish the coverage report on the PR |
perf |
Performance | perf(catalog): avoid N+1 when listing materials |
chore |
Miscellaneous tasks | chore: update .gitignore |
A breaking change is marked with ! or with a BREAKING CHANGE: footer, and that is what triggers a major version bump:
feat(api)!: remove the `available` field from MaterialResponse BREAKING CHANGE: clients must use `availableCopies`, which is an integer, instead of the boolean `available`. The old field had been marked deprecated since version 1.4.0.
- Formatting and style:
.editorconfig and Spotless
.editorconfig and SpotlessArguing about braces and indentation in a code review is wasted time. You automate it and forget it.
.editorconfig — almost every editor understands it, no plugins needed:
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
max_line_length = 120
[*.{xml,yml,yaml,json}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false # two trailing spaces = line break in Markdown
[*.{sh,bash}]
indent_size = 2Spotless — it actually formats, and it fails the build if the code is not formatted. In the parent POM:
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.43.0</version>
<configuration>
<java>
<palantirJavaFormat/> <!-- or <googleJavaFormat/> -->
<removeUnusedImports/>
<importOrder>
<order>java,javax,jakarta,org,com,com.nexussoftware,</order>
</importOrder>
<licenseHeader>
<content>/* BiblioTech - Nexus Software */</content>
</licenseHeader>
</java>
<pom>
<sortPom/>
</pom>
</configuration>
<executions>
<execution>
<goals>
<!-- 'check' fails the build; 'apply' fixes it.
In CI we want it to fail. -->
<goal>check</goal>
</goals>
<phase>validate</phase>
</execution>
</executions>
</plugin>
</plugins>
</pluginManagement>
</build>./mvnw spotless:apply # formats the whole project
./mvnw spotless:check # only checks: this is what runs in CIOne piece of process advice: do the initial mass reformat in a commit of its own, containing no functional change at all, and record it in .git-blame-ignore-revs so that it does not pollute git blame:
# .git-blame-ignore-revs # Initial reformat with Spotless (no functional changes) a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
- A
README.md that genuinely helps
README.md that genuinely helpsAlmost every README is useless because it describes what the project is rather than what the reader needs to do. The quality criterion is objective:
Someone who has never seen the project, with nothing but the README, must be able to start it and run the tests in under ten minutes.
# BiblioTech Management system for Nexus Software's internal technical library. It manages the catalogue of materials (books, magazines, DVDs), loans to employees, reservations and late fines. ## Requirements | Tool | Version | Check | |---|---|---| | JDK | 21+ | `java -version` | | Docker | 24+ | `docker --version` | | Maven | Not needed: use `./mvnw` | — | ## Quick start
git clone https://git.nexussoftware.com/bibliotech.git cd bibliotech docker compose up -d # PostgreSQL on localhost:5432 ./mvnw clean install ./mvnw -pl bibliotech-web spring-boot:run -Dspring-boot.run.profiles=dev
curl http://localhost:8080/actuator/health # {"status":"UP"} curl http://localhost:8080/api/materials
API documentation: <http://localhost:8080/swagger-ui.html> ## Common commands | Goal | Command | |---|---| | Build everything | `./mvnw clean install` | | Unit tests only | `./mvnw test` | | Integration tests | `./mvnw verify` | | Coverage report | `./mvnw verify` → `target/site/jacoco/index.html` | | Format the code | `./mvnw spotless:apply` | | CLI | `java -jar bibliotech-console/target/bibliotech-console.jar catalog list` | ## Structure | Module | Responsibility | |---|---| | `bibliotech-domain` | Entities and business rules. No external dependencies | | `bibliotech-application` | Use cases | | `bibliotech-infrastructure` | JPA, HTTP, mail, Spring configuration | | `bibliotech-console` | CLI with Picocli | | `bibliotech-web` | REST API | Architecture decisions: [`docs/adr/`](docs/adr/). ## Configuration All our own properties live under `bibliotech.*` in `application.yml`. In production they are injected through environment variables (see `docs/deployment.md`). Secrets are **never** added to the repository. ## Contributing 1. Branch from `main`: `feature/short-description` 2. Commits following [Conventional Commits](https://www.conventionalcommits.org/) 3. `./mvnw verify` and `./mvnw spotless:check` green 4. Pull Request; requires one approval and CI green
What a README should not carry: the complete class diagram (it goes stale within a week), the project's history, or the API documentation (OpenAPI generates that).
- Architecture Decision Records (ADR)
Note: ADR (Architecture Decision Record). An ADR is a short, numbered, immutable Markdown file that records one architectural decision: the context, the decision and its consequences. It lives in
docs/adr/inside the repository, next to the code it describes. It is not edited when the decision changes: a new ADR is written that supersedes the old one. Its value is not in the present but two years from now, when somebody asks "why on earth is this like this?" and the alternative is guesswork.
A real example, taken from a decision we already made in module 11:
# ADR-004: Dropping `sealed` in the Material hierarchy
- **Status:** Accepted
- **Date:** 2026-06-18
- **Deciders:** Marta Ruiz, Diego Alonso
## Context
`Material` was a `sealed` interface (Java 17, module 10) with `Book`, `Magazine`
and `Dvd` as its only permitted implementations, which enabled exhaustive pattern
matching with no `default`.
On moving to JPA with the `SINGLE_TABLE` strategy, Hibernate needs to create proxies
per subclass, and entities cannot belong to a sealed hierarchy
managed that way.
## Decision
Turn `Material` into a non-sealed abstract class, with `@Inheritance(SINGLE_TABLE)`
and `@DiscriminatorColumn(name = "type")`.
## Consequences
**Positive:** polymorphic persistence with a single query; no JOIN per type.
**Negative:** we lose `switch` exhaustiveness; we have to keep a `default` branch
that throws `IllegalStateException`. Anyone can extend `Material`
without the compiler stopping them.
**Mitigation:** an ArchUnit test checks that the subclasses of `Material`
are exactly three.
## Alternatives rejected
- **`TABLE_PER_CLASS`:** it would preserve the model, but polymorphic queries
turn into `UNION` and the catalogue's performance degrades.
- **A domain model separate from the persistence model:** it keeps `sealed`, at the cost
of duplicating nine classes and their mappings. Disproportionate for this project.Write an ADR when the decision is expensive to reverse: choice of database, of framework, module structure, authentication strategy, API format. Do not write one to pick a variable name.
- Start-up scripts and local dependencies
The goal is that starting the development environment is one command. For the dependencies (the database, and later whatever else is needed), Docker Compose:
# compose.yaml — dependencies only, NOT the application.
# In development the app runs in the IDE, with hot reload and a debugger.
services:
postgres:
image: postgres:16-alpine
container_name: bibliotech-db
environment:
POSTGRES_DB: bibliotech
POSTGRES_USER: bibliotech
POSTGRES_PASSWORD: bibliotech
ports:
- "5432:5432"
volumes:
- bibliotech-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U bibliotech"]
interval: 5s
timeout: 3s
retries: 10
volumes:
bibliotech-data:docker compose up -d # start
docker compose logs -f # view logs
docker compose down # stop (the data survives in the volume)
docker compose down -v # stop and DELETE the dataAnd a convenience script, scripts/dev.sh:
#!/usr/bin/env bash
set -euo pipefail # -e: stop at the first error; -u: an undefined variable is an error;
# -o pipefail: a failure in the middle of a pipe counts
cd "$(dirname "$0")/.." # runnable from any directory
echo "==> Starting dependencies"
docker compose up -d --wait # --wait waits until the healthcheck is green
echo "==> Building"
./mvnw -q clean install -DskipTests
echo "==> Starting BiblioTech (dev profile)"
exec ./mvnw -pl bibliotech-web spring-boot:run -Dspring-boot.run.profiles=devDocker Compose is covered in depth in 12-06, including the containerised application. Here we only need it in order to have PostgreSQL locally and stop using H2 as the development database, which is a source of surprises (12-05 explains why H2 lies).
- The complete tree of the restructured BiblioTech
This is the project's final state at the end of the lesson:
bibliotech/
├── .editorconfig # style shared by every editor
├── .gitignore
├── .git-blame-ignore-revs # ignores the reformat commit
├── .github/
│ └── workflows/
│ └── ci.yml # written in 12-05
├── compose.yaml # local PostgreSQL
├── mvnw, mvnw.cmd, .mvn/ # wrapper: the same Maven version for everyone (11-05)
├── pom.xml # parent POM: modules + dependencyManagement
├── README.md
├── docs/
│ ├── adr/
│ │ ├── 0001-layered-architecture-with-ports.md
│ │ ├── 0002-maven-multi-module-project.md
│ │ ├── 0003-postgresql-instead-of-h2.md
│ │ └── 0004-dropping-sealed-in-material.md
│ └── deployment.md # 12-06
├── scripts/
│ ├── dev.sh
│ └── bibliotech # CLI launcher (12-03)
│
├── bibliotech-domain/ # ── NO EXTERNAL DEPENDENCIES ──
│ ├── pom.xml
│ └── src/main/java/com/nexussoftware/bibliotech/domain/
│ ├── shared/
│ │ ├── Money.java
│ │ ├── Isbn.java
│ │ ├── Severity.java
│ │ └── BiblioTechException.java # the module 6 hierarchy
│ ├── catalog/
│ │ ├── Material.java
│ │ ├── Book.java Magazine.java Dvd.java
│ │ ├── MaterialType.java
│ │ └── port/
│ │ ├── MaterialRepository.java
│ │ └── MetadataGateway.java
│ ├── loans/
│ │ ├── Loan.java
│ │ ├── LoanStatus.java
│ │ ├── FineCalculator.java
│ │ ├── RateRule.java # Strategy, formalised in 12-02
│ │ └── port/LoanRepository.java
│ ├── reservations/
│ │ ├── Reservation.java
│ │ └── port/ReservationRepository.java
│ └── employees/
│ ├── Employee.java
│ └── port/EmployeeRepository.java
│
├── bibliotech-application/ # ── use cases ──
│ ├── pom.xml # depends ONLY on domain
│ └── src/main/java/com/nexussoftware/bibliotech/application/
│ ├── loans/
│ │ ├── ManageLoans.java # inbound port
│ │ └── LoanManager.java # implementation
│ ├── catalog/
│ │ ├── QueryCatalog.java
│ │ ├── CatalogService.java
│ │ └── CatalogEnricher.java
│ ├── reservations/ReservationProcessor.java
│ ├── notices/NoticeService.java
│ └── statistics/BiblioTechStatistics.java
│
├── bibliotech-infrastructure/ # ── outbound adapters ──
│ ├── pom.xml
│ └── src/main/
│ ├── java/com/nexussoftware/bibliotech/infrastructure/
│ │ ├── persistence/
│ │ │ ├── JpaLoanRepository.java
│ │ │ ├── LoanSpringDataRepository.java
│ │ │ ├── JpaMaterialRepository.java
│ │ │ └── converter/IsbnConverter.java
│ │ ├── metadata/HttpMetadataGateway.java # HttpClient (module 9)
│ │ ├── notices/EmailNoticeSender.java
│ │ ├── sockets/CatalogServer.java # the module 9 one, still alive
│ │ └── config/
│ │ ├── BiblioTechProperties.java
│ │ ├── ClockConfiguration.java # injectable Clock (10-05)
│ │ └── TimingAspect.java # AOP (11-02)
│ └── resources/db/migration/ # Flyway (12-06)
│ ├── V1__initial_schema.sql
│ └── V2__catalog_indexes.sql
│
├── bibliotech-console/ # ── inbound adapter: CLI (12-03) ──
│ ├── pom.xml
│ └── src/main/java/com/nexussoftware/bibliotech/console/
│ ├── BiblioTechCli.java
│ └── commands/…
│
└── bibliotech-web/ # ── inbound adapter: REST (12-04) ──
├── pom.xml
└── src/main/
├── java/com/nexussoftware/bibliotech/web/
│ ├── BiblioTechApplication.java
│ ├── loans/LoanController.java
│ ├── catalog/CatalogController.java
│ ├── dto/…
│ └── error/GlobalErrorHandler.java
└── resources/
├── application.yml
├── application-dev.yml
├── application-prod.yml
└── logback-spring.xml # SLF4J + MDC (11-07)Checking that the architecture holds:
$ ./mvnw -pl bibliotech-domain dependency:tree
[INFO] com.nexussoftware:bibliotech-domain:jar:1.0.0-SNAPSHOT
[INFO] +- org.springframework.boot:spring-boot-starter-test:jar:3.3.4:test
[INFO] \- org.assertj:assertj-core:jar:3.25.3:testNot one production dependency. BiblioTech's domain is pure Java 21: it compiles in a second, it is tested in milliseconds and it will outlive Spring.
Common Mistakes and Tips
1. Restructuring everything at once on a two-week branch. It is the most effective way of ensuring the restructuring never reaches main. Do it in steps: first extract the domain, verify, integrate; then the application; then the adapters. Each step with the tests green and a refactor(...) commit.
2. Confusing "packages" with "architecture". Renaming folders changes nothing if domain still imports org.springframework. Architecture is the direction of the dependencies, and until a tool verifies it (Maven modules or ArchUnit), it is only an intention.
3. Hexagonal over-engineering. Not everything needs a port. If FineCalculator is a pure domain class nobody is going to replace, call it directly. Ports are for what crosses the application's boundary: persistence, network, files, the clock, notifications.
4. A commons module that contains everything. It starts with Money and Isbn and ends up with twenty utilities and a Spring dependency that contaminates the entire domain. Be strict: only what three features use and that belongs to none of them gets in.
5. Exposing JPA entities in the API "for now". There is no such thing as "for now". As soon as a client consumes that JSON, the database schema has become a public contract. Create the DTO from the very first endpoint.
6. Putting secrets in the repository and deleting them afterwards. Git does not forget. If it happens: rotate the secret immediately, and only then clean the history. The reverse order is worthless.
7. ddl-auto: update in any environment other than your laptop. It produces different schemas depending on start-up order, it does not drop columns, it versions nothing and it is not reproducible. The schema is governed with Flyway (12-06).
8. open-in-view enabled. It is true by default in Spring Boot, and it keeps the Hibernate session open while the response is rendered. The result is invisible N+1 and queries running in the presentation layer. Set it to false and fix whatever breaks: what breaks was already wrong.
9. Not using the Maven wrapper. Without ./mvnw, "it works on my machine" shows up through Maven version differences. The wrapper is versioned in the repository and always used, in CI too.
10. An out-of-date README. A README that lies is worse than no README. A trick: have CI run the quick-start commands. If the README lies, the build fails.
A final tip: the best proof that the structure works is onboarding time. If a newcomer can clone, start it, run the tests and find where the fine rule lives in under half an hour, the structure is good. If not, no theoretical justification makes up for it.
Exercises
Exercise 1: applying the dependency rule
The following class is in bibliotech-domain and does not compile after the restructuring:
package com.nexussoftware.bibliotech.domain.notices;
import com.nexussoftware.bibliotech.domain.loans.Loan;
import com.nexussoftware.bibliotech.infrastructure.mail.SmtpServer;
import org.springframework.stereotype.Service;
@Service
public class NoticeService {
private final SmtpServer smtp = new SmtpServer("smtp.nexussoftware.com", 587);
public void notifyDueDate(Loan loan) {
String body = "Hi " + loan.getEmployee().getName()
+ ", the loan of \"" + loan.getMaterial().getTitle()
+ "\" is due on " + loan.getDueDate() + ".";
smtp.send(loan.getEmployee().getEmail(), "Due date notice", body);
}
}List all the architectural violations and rewrite the code split across the correct modules.
Exercise 2: designing the modules for a new feature
Nexus Software wants BiblioTech to issue a monthly usage report in PDF, with the most borrowed materials, the employees with the most fines and the percentage of late returns. The report is generated with an external library (openpdf), saved to disk and sent by email. It must be launchable from the CLI, from the REST API and automatically on the 1st of each month.
For each piece you create, state: which Maven module it lives in, which package, whether it is a port or an adapter, and what it depends on. Write the signatures of the key interfaces.
Exercise 3: spotting configuration problems
This application.yml is in BiblioTech's repository. Find at least six problems and write the corrected version, explaining each change.
spring:
datasource:
url: jdbc:postgresql://db-production.nexussoftware.com:5432/bibliotech
username: admin
password: Nexus2026!
jpa:
hibernate:
ddl-auto: update
show-sql: true
server:
port: 8080
error:
include-stacktrace: always
bibliotech:
metadata:
api-key: sk-live-9f3a2b1c8d7e6f5a
logging:
level:
root: DEBUGSolutions
Solution 1
Violations detected:
| # | Violation | Why it is serious |
|---|---|---|
| 1 | The domain imports infrastructure.mail.SmtpServer |
Breaks the dependency rule. With multi-module it does not even compile (and it would be a cycle) |
| 2 | The domain imports org.springframework |
The domain cannot depend on a framework |
| 3 | new SmtpServer(...) inside the class |
Direct instantiation of infrastructure: impossible to test without an SMTP server |
| 4 | Host and port hard-coded | Configuration in the source code; different per environment |
| 5 | The email text is built in the domain | That is presentation formatting, not a business rule |
| 6 | The loan.getEmployee().getEmail() access chain |
Law of Demeter (03-07, formalised in 12-02) |
Rewrite. First, the port, in the domain:
// bibliotech-domain/…/domain/notices/port/NoticeSender.java
package com.nexussoftware.bibliotech.domain.notices.port;
import com.nexussoftware.bibliotech.domain.notices.Notice;
/**
* Outbound port: the domain declares WHAT it needs (to send a notice),
* without saying HOW (email, SMS, console, message queue).
*/
public interface NoticeSender {
void send(Notice notice);
}The message as a domain object, with no presentation formatting:
// bibliotech-domain/…/domain/notices/Notice.java
package com.nexussoftware.bibliotech.domain.notices;
public record Notice(String recipient,
NoticeType type,
String employeeName,
String materialTitle,
LocalDate date) {
public static Notice forDueDate(Loan loan) {
return new Notice(
loan.employeeEmail(), // a method on Loan itself: no getter chain
NoticeType.DUE_DATE,
loan.employeeName(),
loan.materialTitle(),
loan.getDueDate());
}
}The use case, in the application layer:
// bibliotech-application/…/application/notices/NoticeService.java
package com.nexussoftware.bibliotech.application.notices;
public class NoticeService {
private final NoticeSender sender; // the PORT, not the implementation
private final LoanRepository loans;
private final Clock clock; // 10-05: never a bare LocalDate.now()
public NoticeService(NoticeSender sender,
LoanRepository loans,
Clock clock) {
this.sender = sender;
this.loans = loans;
this.clock = clock;
}
public int sendUpcomingDueNotices(int daysAhead) {
LocalDate limit = LocalDate.now(clock).plusDays(daysAhead);
List<Loan> upcoming = loans.dueOn(limit);
upcoming.forEach(l -> sender.send(Notice.forDueDate(l)));
return upcoming.size();
}
}The adapter, in infrastructure, which is the only thing that knows about SMTP, Spring and formatting:
// bibliotech-infrastructure/…/infrastructure/notices/EmailNoticeSender.java
package com.nexussoftware.bibliotech.infrastructure.notices;
@Component
class EmailNoticeSender implements NoticeSender {
private static final Logger log = LoggerFactory.getLogger(EmailNoticeSender.class);
private final JavaMailSender mail;
private final BiblioTechProperties props; // host, port and sender come from here
EmailNoticeSender(JavaMailSender mail, BiblioTechProperties props) {
this.mail = mail;
this.props = props;
}
@Override
public void send(Notice notice) {
var message = new SimpleMailMessage();
message.setFrom(props.notices().sender());
message.setTo(notice.recipient());
message.setSubject("BiblioTech: due date notice");
message.setText("""
Hi %s,
The loan of "%s" is due on %s.
— BiblioTech, Nexus Software
""".formatted(notice.employeeName(), notice.materialTitle(), notice.date()));
try {
mail.send(message);
} catch (MailException e) {
// Error boundary (06-07): translate into the domain exception
log.warn("Could not send the notice to {}", notice.recipient(), e);
throw new NotificationFailedException(notice.recipient(), e);
}
}
}And the wiring, also in infrastructure:
// bibliotech-infrastructure/…/infrastructure/config/NoticeConfiguration.java
@Configuration
class NoticeConfiguration {
@Bean
NoticeService noticeService(NoticeSender sender,
LoanRepository loans,
Clock clock) {
return new NoticeService(sender, loans, clock);
}
}The gain: NoticeService is tested with an in-memory NoticeSender that stores the notices in a list. No email, no Spring, no database, no network. Milliseconds.
Solution 2
Design of the "monthly report" feature.
Split across modules:
| Piece | Module | Package | Role |
|---|---|---|---|
MonthlyReport (record) |
domain | domain.reports |
Domain object: the report's data |
MaterialLine, EmployeeLine |
domain | domain.reports |
Value objects |
ReportGenerator (interface) |
domain | domain.reports.port |
Outbound port: turn data into bytes |
ReportStore (interface) |
domain | domain.reports.port |
Outbound port: store it |
StatisticsRepository (interface) |
domain | domain.reports.port |
Outbound port: query the aggregates |
IssueMonthlyReport (interface) |
application | application.reports |
Inbound port |
MonthlyReportService |
application | application.reports |
Use case: orchestrates the four ports |
PdfReportGenerator |
infrastructure | infrastructure.reports |
Adapter using openpdf |
FileReportStore |
infrastructure | infrastructure.reports |
NIO.2 adapter (module 7) |
JpaStatisticsRepository |
infrastructure | infrastructure.persistence |
Adapter using aggregation JPQL |
ReportScheduler |
infrastructure | infrastructure.scheduling |
Inbound adapter: @Scheduled |
ReportCommand |
console | console.commands |
Inbound adapter: Picocli |
ReportController |
web | web.reports |
Inbound adapter: REST |
The key interfaces:
// DOMAIN: the report's data. No PDF, no files, no server dates.
package com.nexussoftware.bibliotech.domain.reports;
public record MonthlyReport(YearMonth period,
List<MaterialLine> mostBorrowed,
List<EmployeeLine> mostFined,
double lateReturnPercentage) {
public String suggestedName() {
return "bibliotech-%s.pdf".formatted(period); // yyyy-MM
}
}
// PORT: turn the report into bytes. The domain does not know PDF exists.
package com.nexussoftware.bibliotech.domain.reports.port;
public interface ReportGenerator {
byte[] generate(MonthlyReport report);
String mimeType(); // "application/pdf", "text/csv"…
}
// PORT: where it is stored. The domain does not know whether it is disk, S3 or a database.
public interface ReportStore {
URI store(String name, byte[] content, String mimeType);
}
// PORT: where the aggregates come from. The domain does not know what JPQL is.
public interface StatisticsRepository {
List<MaterialLine> mostBorrowedMaterials(YearMonth period, int limit);
List<EmployeeLine> mostFinedEmployees(YearMonth period, int limit);
double latePercentage(YearMonth period);
}The use case, which is the only place where everything comes together:
// APPLICATION
package com.nexussoftware.bibliotech.application.reports;
public interface IssueMonthlyReport { // INBOUND port
ReportResult issue(YearMonth period, boolean sendByEmail);
}
public class MonthlyReportService implements IssueMonthlyReport {
private final StatisticsRepository statistics;
private final ReportGenerator generator;
private final ReportStore store;
private final NoticeSender sender;
private final Clock clock;
// constructor taking the five ports…
@Override
public ReportResult issue(YearMonth period, boolean sendByEmail) {
var report = new MonthlyReport(
period,
statistics.mostBorrowedMaterials(period, 10),
statistics.mostFinedEmployees(period, 10),
statistics.latePercentage(period));
byte[] content = generator.generate(report);
URI location = store.store(report.suggestedName(), content, generator.mimeType());
if (sendByEmail) {
sender.send(Notice.reportAvailable(period, location));
}
return new ReportResult(period, location, content.length);
}
}And the three inbound adapters, which are three ways of calling exactly the same use case:
// infrastructure: automatically on the 1st at 06:00
@Component
class ReportScheduler {
private final IssueMonthlyReport useCase;
@Scheduled(cron = "0 0 6 1 * *")
void monthly() { useCase.issue(YearMonth.now().minusMonths(1), true); }
}
// console (12-03)
@Command(name = "report", description = "Generates the monthly usage report")
class ReportCommand implements Callable<Integer> { … }
// web (12-04)
@PostMapping("/api/reports/{period}")
ResponseEntity<ReportResponse> generate(@PathVariable YearMonth period) { … }The point of the exercise: swapping PDF for CSV means writing a CsvReportGenerator; swapping disk for S3 means writing an S3ReportStore. The use case and the domain are not touched. That is what hexagonal architecture buys you, and why it is worth it in a feature like this one and not in a CRUD.
Solution 3
Problems found (nine):
| # | Problem | Severity | Why |
|---|---|---|---|
| 1 | The password Nexus2026! in the repository |
Critical | A secret in Git; it stays in the history forever |
| 2 | api-key: sk-live-9f3a... in the repository |
Critical | A production key exposed |
| 3 | The production database URL in the base file | Critical | Anyone starting up locally writes to production |
| 4 | The admin user |
High | Violates least privilege (12-07): the app needs CRUD, not DDL |
| 5 | ddl-auto: update |
High | Modifies the production schema in a way that is neither versioned nor reproducible |
| 6 | include-stacktrace: always |
High | Leaks internal structure, versions and paths to any client |
| 7 | logging.level.root: DEBUG |
Medium | Huge volume, cost, and the risk of logging personal data |
| 8 | show-sql: true |
Medium | Duplicates the logging, with no bound parameters, noisy in production |
| 9 | Everything in the base file, with no profiles | Medium | There is no separation between environments |
Corrected version. Base file, application.yml — only what is common and no secrets:
spring:
application:
name: bibliotech
jpa:
open-in-view: false
hibernate:
ddl-auto: validate # the schema is governed by Flyway; validate catches mismatches
flyway:
enabled: true
server:
port: ${SERVER_PORT:8080} # with a default: it is not a secret
shutdown: graceful
error:
include-stacktrace: never
include-message: never
bibliotech:
metadata:
url: https://api.metadata.example/v1
timeout: 3s
# api-key is NOT here: it arrives through an environment variable
logging:
level:
root: INFO
com.nexussoftware.bibliotech: INFOapplication-dev.yml — local, disposable, verbose:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/bibliotech
username: bibliotech
password: bibliotech # acceptable: a local database in an ephemeral container
jpa:
show-sql: true
server:
error:
include-stacktrace: on_param # ?trace=true, handy when debugging
bibliotech:
metadata:
api-key: ${METADATA_API_KEY:development-key}
logging:
level:
com.nexussoftware.bibliotech: DEBUG
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE # see the bound parameters: dev onlyapplication-prod.yml — not a single default value for anything sensitive:
spring:
datasource:
url: ${BIBLIOTECH_DB_URL}
username: ${BIBLIOTECH_DB_USER} # a least-privilege user, NOT admin
password: ${BIBLIOTECH_DB_PASSWORD}
hikari:
maximum-pool-size: 20
jpa:
show-sql: false
bibliotech:
metadata:
api-key: ${METADATA_API_KEY} # no default: if it is missing, it does not start
logging:
level:
root: WARN
com.nexussoftware.bibliotech: INFOAdditional actions, and this is the point of the exercise: fixing the file is not enough. The password and the API key are already in Git's history. The correct procedure is, in this order:
- Rotate now the database password and the API key. They are compromised.
- Create a database user with minimum permissions (
SELECT,INSERT,UPDATE,DELETEon the application schema; neverDROPorCREATE). - Add
gitleaksas a pre-commit hook and as a CI step. - Clean the history (
git filter-repo) only if the repository is private and controlled, knowing that it rewrites every hash and forces the whole team to re-clone. - Write an ADR about secrets management, so the decision is documented.
This is picked up again in 12-07.
Conclusion
BiblioTech has stopped being a pile of excellent classes and become a project.
It has an explicit architecture: layers with the dependency rule applied seriously and ports wherever there is external technology. You can tell layered architecture from hexagonal, you know their real advantages and costs, and —most importantly— you have the judgement not to apply full hexagonal to a CRUD or loose layers to a system with rich business rules. And you have internalised the idea that holds it all up: the direction of the dependency and the direction of the flow are different things, which is why the domain can define the interface the infrastructure implements.
It has a considered package organisation: by feature at the first level, by layer inside, with the decisive reason that only that way can you hide FineCalculator behind package visibility. Packages by layer degrade because they force everything to be public.
It has five Maven modules whose dependency graph turns the architecture into something the compiler verifies. That bibliotech-domain has no production dependencies is not decoration: it is the reason nobody will be able to slip a Spring annotation into a business rule, neither today nor two years from now in a hurry. And if multi-module is not viable, you have ArchUnit as a safety net.
It has DTOs: entities inwards, record outwards, with the six concrete reasons why exposing a JPA entity in an API ends badly, and with the asymmetry of the input DTOs as a structural defence against field tampering.
It has per-environment configuration with profiles, Spring Boot's source precedence table, the relaxed binding that makes it possible to configure everything through environment variables, and the rule that in production nothing sensitive carries a default value: if it is missing, the application does not start. And it has the warning that prevents the most incidents: secrets stay out of the repository, always.
And it has the hygiene that separates a professional project from an amateur one: a .gitignore that covers what matters, short-lived branches, conventional commits that let you generate the changelog, .editorconfig and Spotless so you never argue about braces again, ADRs that answer "why is this like this?" two years from now, a compose.yaml that brings the dependencies up with one command, and a README that meets the only criterion that matters: that a newcomer gets the project running in ten minutes.
One debt this module opened and did not close remains: the patterns. Restructuring has made them surface again, now almost all at once. LoanRepository with its JPA implementation is the Repository pattern and also an Adapter. MetadataGateway is a Port. RateRule is Strategy. LoanCard.from(...) is a Factory Method. Constructor injection is Dependency Inversion. You have spent eleven modules running into these structures, naming them in passing and postponing them.
The postponing is over. The next lesson formalises all of them: what problem each pattern solves, how it is implemented in BiblioTech, and —just as important— when not to use it.
Java Programming Course
Module 1: Introduction to Java
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
