The previous lesson closed with a question unlike any we had asked before: CicloUrbana is built, tested, deployed and observed, but is it well made? Across nine modules we have taken decisions without stopping to justify them as a category: we put @Transactional on the service, we made the DTOs records, we left open-in-view: false, we denied by default in the filter chain. Each one had its reason at the time, scattered across the lesson where it appeared.
This lesson gathers them and puts them in order. Not as a list of commandments — that would be useless and probably harmful — but as a catalogue of justified decisions: for each practice, what problem it solves, how it looks in the Ribalta network's code, where we studied it and — this is what separates a professional from someone copying recipes — the circumstances in which it makes sense not to apply it. By the end you will have a thirty-eight-point checklist you can take to any Spring Boot project and run through the day before a deployment.
Contents
- A practice is not a dogma
- Structure and design
- Configuration
- Injection and beans
- The API
- Data and persistence
- Security
- Testing
- Operations
- The production-ready application checklist
- When to break the rule
- Common Mistakes and Tips
- Exercises
- A practice is not a dogma
Before the catalogue, a warning about how to read it.
A "best practice" is the answer that usually works for a recurring problem in a particular context. All three words matter. Forget the context and the practice turns into superstition: people creating an interface for every service because "that's how it's done", with no second implementation existing or ever likely to. Forget the problem and you lose the ability to tell when the practice has stopped contributing anything.
That is why every section of this lesson always takes the same shape:
| Element | What it answers |
|---|---|
| The practice | What you do |
| The why | What concrete problem it avoids |
| In CicloUrbana | Where you see it in Ribalta's code |
| Where we studied it | The lesson that develops it |
And there is a section 11 devoted exclusively to the opposite: when to break the rule with good judgement. A rule you cannot justify is not a rule you have mastered, it is one you have memorised.
One last general frame before we begin. Almost every practice in this lesson derives from three underlying principles:
flowchart TB
P1["Make the implicit explicit<br/>DTOs, validated properties,<br/>versioned migrations"]
P2["Fail early and loudly<br/>startup, compilation, tests<br/>rather than production"]
P3["Separate what changes<br/>for different reasons<br/>domain / contract / infrastructure"]
P1 --- P2 --- P3
If you ever hesitate over a decision this catalogue does not cover, ask yourself which of the three principles each option respects better. That is usually enough.
- Structure and design
2.1. Organise by feature, not by technical layer
The practice. Top-level packages are business areas (stations, bikes, rentals, users), not technical types (controller, service, repository, model).
The why. A real change is almost never "touch every controller": it is "add a field to stations", and that touches controller, service, repository, DTO and mapper. With packages by layer, that change is scattered across five distant folders; with packages by feature, it fits in one. On top of that, packages by feature let you use Java's package visibility as a real boundary, which packages by layer make impossible: if every service sits together, every service is visible to every other.
❌ By layer ✅ By feature
com.ciclourbana com.ciclourbana
├── controller ├── CicloUrbanaApplication.java
│ ├── StationController ├── stations
│ ├── BikeController │ ├── Station.java
│ └── RentalController │ ├── StationRepository.java
├── service │ ├── StationService.java
│ ├── StationService │ ├── StationController.java
│ └── ... │ ├── StationMapper.java
├── repository │ └── dto/
└── model ├── bikes
├── rentals
├── users
├── security
└── commonIn CicloUrbana. It is the structure we have carried since Understanding the Project Structure: com.ciclourbana.rentals holds Rental, RentalRepository, RentalService, RentalController, RentalMapper, the fares and the dto subpackage. com.ciclourbana.common keeps the cross-cutting pieces — PageResponse, AuditableEntity, the Clock, TraceFilter, GlobalExceptionHandler.
2.2. The modular monolith by default
The practice. A single deployable with strong internal boundaries, until there is a concrete reason to split it.
The why. In Spring Boot and Microservices we saw it spelled out: splitting the system swaps method calls for network calls, local transactions for sagas, one stack trace for an investigation across three teams. Those are all real costs, and they only pay off when they buy something — independent scaling, independent deployment, independent teams — that the project genuinely needs.
In CicloUrbana. The four business packages are modules with their own service as a façade. RentalService does not query StationRepository directly; it goes through StationService or reacts to events. That discipline is what makes extracting billing into its own service, on the day it becomes necessary, a job of days rather than months.
2.3. The dependency rule between layers
The practice. The flow of dependencies is one-directional: Controller → Service → Repository. Never the other way round, and never skipping the middle.
The why. A repository that calls a service creates cycles — the ones that broke startup in Dependency Injection — and makes it impossible to reason about the order of things. A controller that calls the repository skips the transaction, the business rules and the method-level security checks from Method-Level Security, which live precisely in the service.
flowchart LR
C["StationController<br/>HTTP, DTOs, status codes"] --> S["StationService<br/>@Transactional, @PreAuthorize,<br/>business rules"]
S --> R["StationRepository<br/>queries"]
R --> DB[(PostgreSQL)]
C -.->|"❌ never"| R
R -.->|"❌ never"| S
In CicloUrbana. StationController.create calls stationService.create(...) and never stationRepository.save(...). In Tips for Writing Clean Code we will see how to turn this rule into an automatic test with ArchUnit, so that breaking it turns the build red.
2.4. The domain does not depend on the framework
The practice. The classes that express business rules import neither org.springframework.* nor jakarta.servlet.*.
The why. The domain is the part of the code that lives longest and changes least; the framework is the part that changes most. If FareCalculator imported HttpServletRequest, Ribalta's fare rule would be tied to the request being HTTP. And there is an immediate benefit: a class without the framework is tested in a millisecond, which is why the StandardFareTest from Introduction to Testing starts no context at all.
// ✅ Pure domain: instantiated with new and tested without Spring
public interface FareCalculator {
BigDecimal calculate(Duration duration);
default String name() { return getClass().getSimpleName(); }
}The @Component annotation on StandardFare is the tolerated, deliberate exception: it is metadata that does not change the class's behaviour and does not stop you instantiating it with new in a test.
- Configuration
3.1. Validated @ConfigurationProperties, not scattered @Value
The practice. Groups of properties are bound to a validated record; @Value is left for genuinely one-off cases.
The why. Five @Values spread across four classes are five places where a typo produces no compilation error, five unvalidated values and no documentation of what configures the application. A record with a prefix is a contract: it shows up in /actuator/configprops, the IDE autocompletes it and it fails at startup if something does not add up.
// ❌ Not like this
@Service
public class StandardFare {
@Value("${ciclourbana.fares.unlock}") private BigDecimal unlock;
@Value("${ciclourbana.fares.price-per-minute}") private BigDecimal pricePerMinute;
}// ✅ Like this
@Validated
@ConfigurationProperties(prefix = "ciclourbana.fares")
public record FareProperties(
@NotNull @DecimalMin("0.00") BigDecimal unlock,
@NotNull @DecimalMin("0.01") BigDecimal pricePerMinute,
@NotNull @DecimalMin("0.00") BigDecimal studentPricePerMinute) {}Where we studied it. Spring Boot Properties, with FareProperties and NetworkProperties.
3.2. One artefact for every environment
The practice. The same JAR and the same image travel from dev to pre and on to prod. What changes is the environment, never the binary.
The why. Recompiling per environment means deploying an artefact to production that nobody tested. It is the second of the twelve factors and the reason profiles exist.
In CicloUrbana. application.yml with what is common and application-dev/test/pre/prod.yml with the differences, activated by SPRING_PROFILES_ACTIVE (Spring Boot Profiles). The profile is never baked into the Docker image.
3.3. Secrets out of the repository, always
The practice. The PostgreSQL password, the JWT signing secret and the gateway key arrive through an environment variable or a secrets manager. Never through a versioned file, not even "temporarily".
The why. A secret that enters Git history stays there even if the next commit removes it. Deleting it is not enough: you have to rotate it.
# application-prod.yml — versioned, without a single secret value
spring:
datasource:
url: ${DATABASE_URL}
username: ${DATABASE_USER}
password: ${DATABASE_PASSWORD}
ciclourbana:
jwt:
secret: ${JWT_SECRET}3.4. No default value for anything mandatory
The practice. A property that must come from the environment carries no default value. If it is missing, the application does not start.
The why. It is the fail-early principle applied to configuration. A ${JWT_SECRET:changeme} starts perfectly well in production and signs tokens with a public secret; with no default, the deployment fails at startup, the readiness probe does not pass and the deployment rolls itself back.
| Kind of property | Default value? | Example |
|---|---|---|
| Secret | Never | ${JWT_SECRET} |
| Address of an external service | Only the local development one | ${OTLP_ENDPOINT:http://localhost:4318/v1/traces} |
| Business parameter | Yes, Ribalta's value | minimum-capacity: 8 |
| Cadence of a task | Yes, the reasonable interval | ${...expirer.interval:PT10M} |
- Injection and beans
4.1. Constructor injection, final fields, no @Autowired
The practice. Every dependency comes in through the constructor, is stored in a final field, and @Autowired is not written when there is a single constructor.
The why. Of the five arguments in Dependency Injection, two are decisive: the class can be built in a test with a new, without reflection or a context; and a constructor that grows hurts visually, which turns excess dependencies into a visible problem instead of fifteen @Autowireds nobody counts.
// ❌ Not like this: no final, cannot be built in a test, hides the growth
@Service
public class RentalService {
@Autowired private RentalRepository rentalRepository;
@Autowired private BikeRepository bikeRepository;
@Autowired private FareSelector fareSelector;
}// ✅ Like this
@Service
public class RentalService {
private final RentalRepository rentalRepository;
private final BikeRepository bikeRepository;
private final FareSelector fareSelector;
private final Clock clock;
public RentalService(RentalRepository rentalRepository,
BikeRepository bikeRepository,
FareSelector fareSelector,
Clock clock) {
this.rentalRepository = rentalRepository;
this.bikeRepository = bikeRepository;
this.fareSelector = fareSelector;
this.clock = clock;
}
}4.2. Beans without mutable state
The practice. A singleton bean holds no state that changes between requests.
The why. Every thread shares the same instance. A mutable field in a @Service is a race condition waiting its turn, and the failure shows up under load, in production, and is irreproducible locally.
In CicloUrbana. Per-request state lives in the MDC or in the arguments; shared state lives in the database or the cache. FareSelector holds an immutable Map built in the constructor: it is state, but it is not mutable.
4.3. Do not overuse @Profile
The practice. @Profile answers "where am I?". When the real question is "is this capability switched on?", the answer is a property and @ConditionalOnProperty.
The why. With @Profile proliferating, switching a feature on in pre-production forces you to invent combined profiles and the code ends up knowing where it lives, which is exactly the opposite of the twelve factors.
// ❌ The bean knows where it lives
@Bean @Profile({"dev", "test", "pre"})
MailService simulatedMailService() { ... }
// ✅ The bean depends on a capability
@Bean
@ConditionalOnProperty(name = "ciclourbana.mail.mode", havingValue = "simulated",
matchIfMissing = true)
MailService simulatedMailService() { ... }
- The API
5.1. DTOs always, with no "just on this endpoint" exceptions
The practice. No JPA entity crosses the HTTP boundary, inbound or outbound.
The why. The four failures from DTOs and Layer Mapping: silent data leaks — the passwordHash or a Ribalta citizen's national ID number —, invisible coupling that turns a rename into a broken mobile app, circular references when serialising relationships and the impossibility of exposing a computed field such as availableBikes. To which we add the technical one from Transactions: with open-in-view: false, an entity that leaves the service with lazy relationships is a guaranteed LazyInitializationException.
A DTO is an allow list; @JsonIgnore is a deny list, and deny lists fail by omission.
5.2. Version the contract and validate at the edge
The practice. Every route hangs off /api/v1/..., and every input is validated with Bean Validation in the DTO, with @Valid in the controller.
The why. The version prefix costs nothing today and is the only way to introduce a breaking change tomorrow without breaking the app already installed on citizens' phones. And validating at the edge means the service never receives invalid data: the check happens once, in one place, declaratively, instead of scattered in ifs across the logic.
5.3. Uniform errors with ProblemDetail
The practice. Every error comes out in the same RFC 7807 format, produced by a single @RestControllerAdvice.
The why. A client that has to interpret five different shapes of error ends up writing if (response.contains("does not exist")). A single format with its own code and a trace identifier turns support into a lookup: the citizen reads the identifier off their screen and the operator finds the exact request in the logs.
In CicloUrbana. GlobalExceptionHandler translates the CicloUrbanaException hierarchy into ProblemDetail, with the unified traceId from Distributed Tracing.
5.4. Correct status codes and mandatory pagination
The practice. 201 with a Location header on creation, 204 on delete, 404 if it does not exist, 409 on conflict, 422 if a business rule is not met. And no collection is returned unpaginated.
The why. Correct codes let clients, proxies and the metrics from Monitoring with Actuator tell a client error from a server error without reading the body. And a findAll() without Pageable works perfectly with Ribalta's four stations and takes the application down the day there are four hundred thousand rental rows: it is a time bomb that arms itself.
// ❌ Works today, blows up at scale
@GetMapping public List<RentalResponse> list() { ... }
// ✅ The limit is part of the contract
@GetMapping public PageResponse<RentalResponse> list(
@PageableDefault(size = 20) Pageable pageable) { ... }
- Data and persistence
6.1. The transaction lives in the service
The practice. @Transactional(readOnly = true) on the service class, explicit @Transactional on the methods that write. Never in the controller.
The why. The use case is the unit that must be atomic: starting a rental marks the bike and creates the record, or it does neither. A repository method is too small for that; a controller is too large and would keep the connection open during JSON serialisation.
And the class-level readOnly pattern has a cultural virtue: writing becomes a conscious decision. A method that forgets to annotate itself does not write by accident.
6.2. open-in-view: false, LAZY by default, mapping inside the transaction
The practice. The three go together and reinforce one another.
The why. open-in-view: true — Spring Boot's default, which is why you have to switch it off explicitly — keeps the EntityManager open during serialisation, which hides N+1 queries behind a layer where nobody looks for them and holds a pool connection longer than necessary. Switching it off turns "the service returns DTOs" from a best practice into a technical requirement, which is exactly what we want.
6.3. The schema is code: Flyway and ddl-auto: validate
The practice. The schema is defined in versioned migrations; Hibernate only validates that the database matches the entities.
The why. ddl-auto: update does not drop columns, does not rename, does not backfill data and leaves no record of what SQL it ran. It is impossible to review in a pull request and impossible to roll back. With Flyway, every schema change is a file with a name, a number and a checksum, and the history lives in flyway_schema_history.
In CicloUrbana. V1__create_initial_schema.sql through V9__contract_drop_capacity.sql, plus the repeatable R__ ones. That is what we built in Schema Migrations with Flyway.
6.4. Backward-compatible migrations
The practice. A migration must work with the version of the application running now and with the one about to be deployed.
The why. During a zero-downtime deployment two versions coexist. If V9 drops the capacity column and there are still instances of the previous version reading it, those instances fail. The solution is the expand/contract pattern: first you add the new thing and write both columns (V8__expand_total_docks.sql), then you deploy the application that only uses the new one, and afterwards you drop the old one (V9__contract_drop_capacity.sql), in a separate deployment.
| Operation | Compatible? | How to make it safe |
|---|---|---|
| Add a column with a default value | Yes | Straight away |
Add a NOT NULL column with no default |
No | Add with a default, backfill, then tighten |
| Rename a column | No | Expand/contract across two deployments |
| Drop a column | No | Only once no live version uses it |
| Create an index on a large table | Blocks writes | CREATE INDEX CONCURRENTLY |
- Security
7.1. Deny by default
The practice. The filter chain ends with anyRequest().denyAll(), not with permitAll() and not with nothing.
The why. With deny-by-default, forgetting a rule produces a 403 visible in the first test. With permit-by-default, forgetting a rule produces an open endpoint nobody discovers until someone exploits it. The cost of the mistake is asymmetric, so the default must be too.
7.2. Rules go from specific to general
The practice. The order of authorizeHttpRequests matters: the first match wins.
// ❌ The second rule is never evaluated: the first already matched
.requestMatchers("/api/v1/stations/**").permitAll()
.requestMatchers("/api/v1/stations/*/maintenance").hasRole("OPERATOR")
// ✅ Specific first
.requestMatchers("/api/v1/stations/*/maintenance").hasRole("OPERATOR")
.requestMatchers(HttpMethod.GET, "/api/v1/stations/**").permitAll()
.anyRequest().denyAll()7.3. Security at method level too
The practice. URL rules are the perimeter barrier; the ones that depend on the data live in the service, with @PreAuthorize.
The why. "Only your own rentals" is not a property of the route. And a rule in the service applies to every entry point, including those that do not exist yet: the scheduled task, the message consumer or the endpoint someone adds six months from now.
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN') or "
+ "@rentalSecurity.isOwner(#rentalId, principal)")
@Transactional
public RentalResponse finish(Long rentalId, FinishRentalRequest request) { ... }7.4. Least privilege, and never log credentials
The practice. Each role has exactly what it needs, and no log contains a password, a token or an Authorization header, not even at DEBUG.
The why. Logs are copied to aggregation systems, sent to third parties and kept for years. A token in a log is a credential circulating for months before anyone notices.
- Testing
8.1. The pyramid, and the unit test starts no context
The practice. Many fast unit tests, some slice tests, few integration tests, very few end-to-end ones.
The why. A five-second suite runs on every save; a twenty-five-minute one stops being run, and a suite that is not run protects nothing. Using @SpringBootTest to test a fare formula multiplies the time by a thousand and does not catch a single extra bug.
// ❌ A whole context to check a multiplication
@SpringBootTest
class StandardFareTest {
@Autowired StandardFare fare;
@Test void calculates() { ... } // 4 seconds
}
// ✅ Without Spring
class StandardFareTest {
@Test void chargesUnlockPlusTwelveCentsPerMinute() {
assertThat(new StandardFare().calculate(Duration.ofMinutes(30)))
.isEqualByComparingTo("4.10"); // 0.8 ms
}
}8.2. Fast and deterministic
The practice. No test depends on the system clock, on execution order or on data another test left behind.
The why. A flaky test is worse than no test: it trains the team to retry instead of investigate, and that habit ends up ignoring the real failures too.
In CicloUrbana. The Clock injected into RentalService, JwtService and RentalExpirer is not elegance: it is what makes Clock.fixed(Instant.parse("2026-09-01T23:00:00Z"), ZoneOffset.UTC) possible, and lets you assert on an exact instant.
8.3. Testcontainers for whatever depends on the real database
The practice. Tests that verify migrations, indexes, constraints or native SQL start a real, ephemeral PostgreSQL, not H2.
The why. H2 in compatibility mode is not PostgreSQL: it differs in types, in functions, in the behaviour of partial indexes and in that of locks. A test that passes on H2 and fails in production is exactly the failure the tests were meant to prevent.
And the honest trade-off: they are slow. That is why they live in *IT classes run by Failsafe in ./mvnw verify, kept apart from the short cycle of ./mvnw test.
- Operations
9.1. Distinct health probes
The practice. liveness answers "the process is healthy"; readiness answers "I can take traffic". They are not the same thing.
The why. Confusing them produces two opposite failures, both serious. If liveness checks the database, a PostgreSQL outage makes Kubernetes restart every replica of an application that was perfectly healthy, turning a degradation into an outage. If readiness checks nothing, the load balancer sends traffic to an instance that has not finished starting up.
9.2. Structured logs to stdout
The practice. JSON on standard output, with the traceId on every line. No files and no rotation managed by the application.
The why. In a container, writing to a file means writing to an ephemeral disk that disappears with the pod. And a JSON log is queryable: | json | traceId = "..." returns the complete story of one request; a free-text log forces you into brittle regular expressions.
9.3. Business metrics, not just technical ones
The practice. Alongside latency and memory, you measure the facts of the business: rentals started by fare, rentals finished, station occupancy.
The why. Technical metrics say the application works; business metrics say it serves. A deployment that breaks nothing but leaves rentals started at zero is a failure no JVM metric detects.
With the rule that makes them viable: low cardinality in the tags. CicloUrbanaMetrics tags by fare and by station — three and four values — never by userId.
9.4. Graceful shutdown and reversible deployment
The practice. server.shutdown: graceful, executors that wait to finish, and a deployment strategy that lets you go back in minutes.
The why. A SIGKILL in the middle of a request leaves the citizen with an error and, if a transaction was half done, with a state that has to be reconciled. And on the second point: the DORA metric that gets neglected most is time to restore, and the cheapest way to improve it is for rolling back to be a button rather than an investigation.
- The production-ready application checklist
This is the table you can take to any project. You walk through it in full before the first deployment to production, and once a quarter after that.
| # | Area | Check | Lesson |
|---|---|---|---|
| 1 | Structure | Packages by feature, not by layer | 01-04 |
| 2 | Structure | The main class sits in the root package | 01-04 |
| 3 | Structure | No controller reaches a repository | 10-03 |
| 4 | Structure | The domain imports no web framework classes | 02-02 |
| 5 | Configuration | Zero secrets in the repository and in its history | 07-02 |
| 6 | Configuration | One single artefact for every environment | 07-02 |
| 7 | Configuration | Properties grouped into validated @ConfigurationProperties |
02-05 |
| 8 | Configuration | Mandatory settings have no default value | 02-05 |
| 9 | Configuration | The profile is activated by the environment and verified in the startup log | 07-02 |
| 10 | Beans | Constructor injection and final fields throughout the project |
02-02 |
| 11 | Beans | No singleton with mutable state | 02-03 |
| 12 | API | Inbound and outbound DTOs on every endpoint | 03-05 |
| 13 | API | Versioned routes (/api/v1/...) |
03-01 |
| 14 | API | @Valid on every request body |
03-04 |
| 15 | API | Uniform errors with ProblemDetail and no stack traces |
03-06 |
| 16 | API | Correct status codes, with Location on creations |
03-03 |
| 17 | API | No collection is returned unpaginated | 04-05 |
| 18 | API | The OpenAPI documentation is closed in production | 03-07 |
| 19 | Data | @Transactional in the service, readOnly on reads |
04-07 |
| 20 | Data | open-in-view: false |
04-02 |
| 21 | Data | Every association is LAZY |
04-04 |
| 22 | Data | Flyway governs the schema and ddl-auto: validate |
04-08 |
| 23 | Data | Pending migrations are backward compatible | 04-08 |
| 24 | Data | The pool is sized with judgement, not "just in case" | 09-01 |
| 25 | Security | anyRequest().denyAll() closes every chain |
05-02 |
| 26 | Security | Rules ordered specific to general, reviewed one by one | 05-02 |
| 27 | Security | Data-dependent rules with @PreAuthorize on every user resource |
05-05 |
| 28 | Security | Passwords with BCrypt and a short, rotatable JWT with no sensitive data | 05-04 |
| 29 | Security | No log contains tokens, passwords or personal data | 09-05 |
| 30 | Testing | The fast suite finishes in seconds and runs on every change | 06-01 |
| 31 | Testing | Every security rule has its automated test | 06-04 |
| 32 | Testing | Whatever depends on PostgreSQL is tested with Testcontainers | 06-05 |
| 33 | Operations | liveness and readiness probes distinct and wired to the orchestrator |
07-01 |
| 34 | Operations | JSON logs to stdout, with traceId on every line |
09-05 |
| 35 | Operations | Business metrics published, with low-cardinality tags | 09-03 |
| 36 | Operations | Graceful shutdown configured and consistent with the orchestrator's grace period | 07-03 |
| 37 | Operations | Alerts on symptoms — latency, errors — and not on causes | 09-04 |
| 38 | Delivery | Rolling back to the previous version is a command, not an investigation | 08-05 |
- When to break the rule
This section is the one that gives the previous ten their value. Every one of these practices has a context where it stops being the best option, and knowing which is the difference between applying them and understanding them.
| Practice | You can break it when... | What you must do in exchange |
|---|---|---|
| DTOs always | An internal microservice whose only client is you and whose "domain" is literally the contract | Document it; and as soon as there is a second client, introduce the DTO |
| An interface for every service | Almost always: an interface with a single implementation and no module boundary is ceremony | Nothing. It is the rule most often applied without thinking |
| Mandatory pagination | The collection has a size bounded by design (the four statuses of a bike) | Make sure the bound is structural, not "there are few today" |
| Transactions only in the service | An importer that needs one transaction per row | TransactionTemplate, with the reason in a comment |
Never @PostFilter |
A small, bounded, unpaginated collection | Check that it is still all three a year from now |
Everything LAZY |
A @ManyToOne relationship that is always needed and is always one row |
Measure; a targeted @EntityGraph is almost always better |
| The testing pyramid | A layer that is pure wiring, where the integration test is the only useful one | Do not make it the norm: it is where the ice-cream cone comes from |
| The modular monolith | A part with a radically different scaling profile or an isolation requirement | Have distributed tracing before you split |
| One artefact for every environment | Never. This one does not break | — |
| Secrets out of the repository | Never. Nor does this one | — |
The last two rows are deliberate: there are rules with no defensible exception, and it is worth being clear about which ones they are. Everything else is a trade-off, and a trade-off is made with the costs in plain sight.
The honest way to break a rule has three steps: name the rule you are breaking, say what you gain, and say what you lose and how you compensate for it. A three-line comment above the code, or an entry in the project's decision log. What does not count is breaking it without noticing.
Common Mistakes and Tips
Applying a practice without understanding what problem it solves. It is the underlying mistake of this whole lesson. It shows up in interfaces with a single implementation, in @Profile for everything, in tests that exist only to raise coverage and in a mapper layer that maps identical records.
Turning the checklist into a formality. Ticking 38 boxes without verifying any of them is worse than having no list, because it produces the feeling of having reviewed things. Each point needs an objective check: not "I think Swagger is closed", but curl against /swagger-ui.html in production.
Confusing "it works" with "it is right". ddl-auto: update works, exposing entities works, @Autowired on fields works. Every practice in this lesson is about what happens afterwards: in the sixth month, at real volume, with three more people touching the code.
Introducing every practice at once in an existing project. A sweeping change is unreviewable and risky. The useful order in a legacy project: first what prevents irreversible harm (secrets, deny-by-default, migrations), then what provides a safety net (tests), and finally the structural work (packages, DTOs), file by file and riding on changes you already had to make.
Tip: write the why in the repository, not in your head. A docs/decisions/ folder with a short entry per decision — context, options, choice, consequences — is worth more than any generated documentation. A year from now the question will not be "what does this do" but "why was it done this way", and the answer has usually been lost.
Tip: automate everything you can. A practice that depends on someone remembering during a review gets broken sooner or later. unmappedTargetPolicy=ERROR in MapStruct, ddl-auto: validate, denyAll() at the end, failBuildOnCVSS in Dependency-Check and the ArchUnit rules from 10-03 are all the same idea: moving the check out of a person's head and into the build.
Tip: a practice you cannot explain in two sentences is one you have not mastered. Try it with open-in-view: false, with readOnly = true or with the order of the security rules. If the explanation does not come out, go back to the relevant lesson: it pays off better than memorising the rule.
Exercises
Exercise 1: audit a service against the checklist
A team passes you this class from another Spring Boot project for review. Identify every practice from this lesson it breaks, grouped by area, and state for each one what concrete problem it will cause and when.
package com.example.service;
@Service
public class OrderService {
@Autowired private OrderRepository orderRepository;
@Autowired private CustomerRepository customerRepository;
@Value("${gateway.key:test-key}") private String gatewayKey;
private int ordersProcessed = 0;
@GetMapping("/orders")
public List<Order> list() {
return orderRepository.findAll();
}
public Order create(Order order) {
Customer customer = customerRepository.findById(order.getCustomerId()).get();
order.setCustomer(customer);
ordersProcessed++;
log.info("Creating order for {} with key {}", customer.getEmail(), gatewayKey);
return orderRepository.save(order);
}
}Exercise 2: justify an exception
Ribalta council asks for an internal endpoint GET /api/v1/internal/stations/full that returns, for the operators' panel, all the information about a station: its fields, its bikes with the internal dock code, the latest incidents and the audit counters. A colleague proposes returning the Station entity directly because "it's internal and it saves three classes".
Argue whether the exception is defensible. If you think it is not, propose the concrete alternative. If you think it is in some case, say under what conditions and what you would have to do in exchange.
Exercise 3: the checklist applied to Ribalta
Write up the eight checks from the table in section 10 that you would consider most critical for CicloUrbana's first deployment to production, ordered by criticality. For each one state: how it is verified objectively (a command, a query, a test) and what you would do if the check fails the night before the deployment.
Solutions
Solution 1
Structure and layers.
- A
@GetMappinginside a@Service. The class mixes two layers: it is controller and service at once. Consequence: there is nowhere to put the transaction without it covering serialisation, and the logic cannot be tested withoutMockMvc. When it hurts: as soon as there is a second entry point. - The package is
com.example.service, organisation by layer. Consequence: every functional change touches four folders and package visibility stops working as a boundary.
Beans and injection.
@Autowiredon fields. It does not allowfinal, it forces reflection to build the class in a test and it hides the growth of dependencies.private int ordersProcessed: mutable state in a singleton. Consequence: a race condition, the counter loses increments under concurrency and the failure is irreproducible locally. If that number is genuinely wanted, it is a MicrometerCounter.
Configuration and security.
- A loose
@Valuefor a secret. It should be a validated@ConfigurationProperties. - The secret has a default value (
test-key). Consequence: the application starts in production without the real key and fails on the first charge, instead of failing at startup. - The secret is written to the log, and so is the customer's email address. Consequence: a credential and a piece of personal data travelling to log aggregation and kept for years. It is the most serious failure in the class.
API and data.
- It returns the
Orderentity and receives it as a parameter. A data leak on the way out and mass assignment on the way in: the client can set any field, including theidor the status. findAll()with no pagination. Works with 50 orders and takes the JVM down with 500,000.- No transactional annotation.
createperforms two write operations that are committed separately; if the second one fails, an inconsistent state is left behind with no visible error. .get()on anOptional. If the customer does not exist,NoSuchElementExceptionand a500with a stack trace instead of a404withProblemDetail.order.setCustomer(customer)with the entity received from the HTTP client, which is a detached entity mixed with a managed one: unpredictable behaviour when persisting.
The underlying pattern: none of these twelve problems produces an error today. All of them produce an error a few months from now, and several of them silently. That is the operational definition of technical debt.
Solution 2
The exception is not defensible as it is framed, and the main reason is not the one usually given.
The usual argument against exposing entities is the data leak, and here the colleague neutralises it by saying the endpoint is internal. But three reasons still stand:
1. "Internal" does not mean "no clients". The operators' panel is a client, another team maintains it and it is deployed on its own schedule. As soon as somebody renames capacity to totalDocks in the entity — exactly what we did in V8__expand_total_docks.sql — the panel stops showing the capacity, with no compilation error on either side.
2. The technical problem is intractable. With open-in-view: false, returning Station with its bikes, its incidents and its audit fields produces a LazyInitializationException during serialisation, unless every relationship is loaded inside the transaction. And if they are all loaded, we have built an unbounded aggregate: the response grows with the incident history with no cap whatsoever.
3. The entity carries fields that are not data. @Version, the AuditableEntity fields and the bidirectional relationships exist for persistence reasons, not contract reasons. Publishing them invites the client to interpret them.
The concrete alternative, which also costs less than it seems:
// Internal view, in the operations package, with its own route and its own role
public record OperatorStationResponse(
Long id, String name, String address, int capacity,
LocationResponse location,
int freeDocks, boolean full,
List<OperatorBikeSummary> bikes, // with internalDockCode
List<IncidentSummary> openIncidents, // bounded: only the open ones
Instant lastReviewedAt) {}Three decisions inside the alternative: the incidents nested here are only the open ones, which are bounded by design, whereas the full history is queried with pagination at /api/v1/internal/stations/{id}/incidents; it is a different class from the public one, not the same one with conditional fields, because an if (isOperator) inside a mapper fails the day somebody inverts the condition; and the route and the role are different, so protection does not depend on nobody making a mistake while mapping.
Is there any case where it would be defensible? One, and a very narrow one: a diagnostic endpoint under /actuator, protected by the ADMIN role, whose explicit purpose is to dump internal state for debugging, documented as unstable and excluded from the public documentation. That is not an API: it is a tool. And even then, having first checked that it publishes no personal data.
Solution 3
| Order | Check | Objective verification | If it fails the night before |
|---|---|---|---|
| 1 | Zero secrets in the repository and its history (#5) | gitleaks detect --log-opts="--all" over the full history, not just the latest version |
The deployment stops. Rotate every secret found before anything else; deleting them from the code is not enough |
| 2 | The prod profile really is active (#9) |
Start with the real configuration and look in the log for The following 1 profile is active: "prod". If it says falling back to default, everything else in this table is false |
It stops: with no active profile the application runs with the development configuration, including an open Swagger and H2 |
| 3 | anyRequest().denyAll() and reviewed rules (#25, #26) |
An automated test that fires token-less requests at a list of known routes and at invented routes: all must return 401 or 403, none 200 |
It stops. It is the only failure on this list that exposes third-party data immediately |
| 4 | Data-dependent rules with @PreAuthorize (#27) |
An integration test with two real citizens crossing identifiers on rentals and incidents: everything 403 |
It stops if personal data is affected; IDOR is the most exploited API failure there is |
| 5 | Flyway governs the schema and ddl-auto: validate (#22, #23) |
flyway:info against a copy of the production schema, and a review that pending migrations are backward compatible |
The deployment is postponed: an incompatible migration during a rolling deployment breaks the instances of the previous version |
| 6 | Distinct probes, properly wired (#33) | curl to /actuator/health/liveness and /readiness; and check in the manifest that livenessProbe does not query the database |
You can deploy with manual supervision, but fix it immediately: a misconfigured probe turns a degradation into a total outage |
| 7 | Rolling back is a command (#38) | Actually run the rollback in pre-production and time it | You can deploy, but only during quiet hours and with somebody on hand |
| 8 | No log with credentials or personal data (#29) | Run a complete flow in pre-production and search the logs for eyJ, Bearer, password and a known email address: zero results |
Fix it beforehand; it does not block if the logging system does not yet export to third parties, but it gets fixed in the next deployment |
The ordering criterion, which is the point of the exercise: first what compromises the whole system at once (a leaked secret, the development configuration in production), then what compromises third-party data (deny-by-default, cross-account access), then what compromises data integrity (migrations), and finally what compromises your ability to react (probes, rollback, logs). It is the same scale we used in 05-05, applied now to the whole system rather than to security alone.
And one observation worth internalising: of the eight points, six are verified by running something, not by reading code. A checklist whose points are ticked by visual inspection is a list of good intentions.
Conclusion
The decisions we have been taking across nine modules now have a recognisable shape. You know that a best practice is the answer that usually works for a recurring problem in a particular context, and that all three words matter: without the problem, the practice is superstition; without the context, it is a dogma. And you know that almost all of them derive from three underlying principles — make the implicit explicit, fail early and loudly, separate what changes for different reasons — that serve as a compass in the face of decisions no catalogue covers.
You have the complete catalogue grouped by area. Structure: packages by feature, a modular monolith with real boundaries, the controller → service → repository dependency rule that is never inverted, and a domain that does not import the framework because that is what makes it durable and testable in milliseconds. Configuration: validated @ConfigurationProperties rather than scattered @Value, one artefact for every environment, secrets always out of the repository and no default value for anything mandatory. Beans: constructor, final, no @Autowired, no mutable state and no @Profile for what is really a capability. API: DTOs with no exceptions, versioning, validation at the edge, a uniform ProblemDetail, correct status codes and mandatory pagination. Data: the transaction in the service with readOnly by default, open-in-view: false with LAZY and mapping inside the transaction as a single package of decisions, Flyway with validate and backward-compatible migrations. Security: deny by default, specific-to-general ordering, data-dependent rules in the service and never a credential in a log. Testing: the pyramid, unit tests with no context, the determinism of the injected Clock and Testcontainers for what H2 cannot validate. Operations: distinct probes, structured logs to stdout, business metrics with controlled cardinality, graceful shutdown and reversible deployment.
And you have the two pieces that turn the catalogue into a working tool: the thirty-eight-point checklist with its reference lesson, designed to be walked through before a deployment and once a quarter; and the section on when to break the rule, with its table of defensible exceptions, the two that have no exception — one artefact for every environment and secrets out of the repository — and the honest way to break any other: name the rule, say what you gain and say what you lose and how you compensate for it.
This whole catalogue is written in the positive: what you should do. But most of us do not learn that way. We learn when something fails, and the practices in this lesson are really the scar tissue of concrete mistakes somebody made before us: the annotation that did nothing, the endpoint left open by the order of two lines, the caught exception that took the rollback down with it, the scheduled task that ran three times when we scaled out. The next lesson, Common Mistakes and How to Avoid Them, walks the reverse side: a catalogue of real failures with their symptom, their cause, how they are diagnosed and the corrected code — from the main class in the wrong package to the cache that hides a badly written query —, with the proxy trap finally dealt with once and in a single place, and a quick diagnosis table that takes you from the symptom to the lesson where the answer lives.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
