"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

  1. The problem: what happens to a project with no structure
  2. What an application's architecture is
  3. Layered architecture
  4. The dependency rule
  5. Hexagonal architecture: ports and adapters
  6. Comparison: layers versus hexagonal
  7. Package organisation: by layer versus by feature
  8. BiblioTech's package tree, in both options
  9. A justified recommendation
  10. Maven multi-module project applied to BiblioTech
  11. The parent POM and dependencyManagement
  12. How the module graph stops the domain importing Spring
  13. DTOs versus entities
  14. Configuration per environment: application.yml and profiles
  15. Spring Boot's hierarchy of configuration sources
  16. Environment variables and secrets outside the repository
  17. Version control: .gitignore, branches and conventional commits
  18. Formatting and style: .editorconfig and Spotless
  19. A README.md that genuinely helps
  20. Architecture Decision Records (ADR)
  21. Start-up scripts and local dependencies
  22. The complete tree of the restructured BiblioTech
  23. Common Mistakes and Tips
  24. Exercises
  25. Conclusion

  1. 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.

  1. 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:

  1. 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)
  2. 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.

  1. 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.

  1. 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....

  1. 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.

  1. 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.

  1. 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.

com.nexussoftware.bibliotech
├── controller
├── service
├── domain
└── repository

By feature (feature-first, or package by feature): the first level is the business area.

com.nexussoftware.bibliotech
├── catalog
├── loans
├── reservations
└── employees
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.

  1. 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.java

Option 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)

  1. 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:

  1. The project is going to grow. Module 12 adds a CLI, web, security and observability to it. By layer, the service package would end up with twenty unrelated classes.
  2. It lets you hide. FineCalculator is a detail of how loans work; nobody else should be able to call it. Only packaging by feature enforces that.
  3. 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".
  4. Changes are local. Adding "loan renewal" touches loans/ and nothing else.
  5. It makes coupling visible. If reservations needs five classes from loans, the imports 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.

  1. 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}/...

  1. The parent POM and dependencyManagement

The 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

  1. 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 FAILURE

It 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-domain

This 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.

  1. 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.

  1. Configuration per environment: application.yml and profiles

BiblioTech 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: INFO

Development 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: DEBUG

Production 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: INFO

That ${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

  1. 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-day can be set with the environment variable BIBLIOTECH_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/env endpoint (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".

  1. 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 .properties file 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-files

12-07 returns to this topic with the full management story: rotation, encryption at rest and what to do once it has already happened.

  1. Version control: .gitignore, branches and conventional commits

The .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
*.pem

The 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.

  1. Formatting and style: .editorconfig and Spotless

Arguing 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 = 2

Spotless — 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 CI

One 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
git config blame.ignoreRevsFile .git-blame-ignore-revs

  1. A README.md that genuinely helps

Almost 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

Check:

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).

  1. 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.

  1. 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 data

And 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=dev

Docker 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).

  1. 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:test

Not 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: DEBUG

Solutions

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: INFO

application-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 only

application-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: INFO

Additional 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:

  1. Rotate now the database password and the API key. They are compromised.
  2. Create a database user with minimum permissions (SELECT, INSERT, UPDATE, DELETE on the application schema; never DROP or CREATE).
  3. Add gitleaks as a pre-commit hook and as a CI step.
  4. 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.
  5. 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

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved