The previous lesson gathered CicloUrbana's practices written in the positive: what you should do and why. This one walks the reverse side, which is how almost all of us genuinely learn. Every practice in 10-01 is the scar tissue of a concrete mistake somebody made before, and knowing the mistake — its exact symptom, its log message, the shape it takes — is worth more than knowing the rule, because the day it turns up you will recognise it instead of investigating for three hours.
The catalogue is ordered by where they show up, not by severity, because that is how you meet them: first the ones that stop startup, then the ones that start but do nothing, and finally the ones that work until the volume arrives. All follow the same scheme — symptom → cause → diagnosis → fix — and all carry the failing code next to the corrected version. And there is a dedicated section for the trap we have stepped on three times over the course, which finally deserves a single treatment: the proxy trap.
Contents
- How to read this catalogue
- Startup and context
- The proxy trap
- Configuration
- JPA and persistence
- REST and the contract
- Security
- Concurrency
- Performance
- Testing
- Quick diagnosis table
- Common Mistakes and Tips
- Exercises
- How to read this catalogue
The mistakes in this catalogue fall into three families that demand different attitudes:
| Family | How it shows up | Real cost |
|---|---|---|
| Noisy | The application does not start, or throws a clear exception | Low: they cost you an afternoon and get fixed |
| Silent | Everything works, but it does not do what you think | High: discovered in production, weeks later |
| Deferred | They work today and fail with volume or concurrency | Very high: they turn up at the worst possible moment |
The list is deliberately biased towards the last two. A NoSuchBeanDefinitionException costs twenty minutes; a @Transactional silently ignored costs a data reconciliation.
- Startup and context
2.1. The main class outside the root package
Symptom. NoSuchBeanDefinitionException for beans that exist and are correctly annotated. Or worse: the application starts, but no endpoint responds and there is no error at all.
Cause. @SpringBootApplication includes @ComponentScan with no arguments, which scans the package of the annotated class and all its subpackages. If CicloUrbanaApplication lives in com.ciclourbana.startup, the packages com.ciclourbana.stations and com.ciclourbana.rentals fall outside the scan.
Diagnosis. Compare the main class's package with that of the missing beans, and confirm with /actuator/beans, which does not list them.
Fix. Move CicloUrbanaApplication to com/ciclourbana/, the root of every business package. Adding @ComponentScan("com.ciclourbana") works, but it is a patch that masks a badly placed structure.
2.2. NoSuchBeanDefinitionException with the class right there
Symptom. Parameter 0 of constructor in com.ciclourbana.rentals.RentalService required a bean of type 'FareCalculator' that could not be found.
Possible causes, in order of frequency. The class has no stereotype (@Component, @Service, @Repository); it is outside the scanned package (2.1); it is conditioned by a @Profile or @ConditionalOnProperty that is not met; or it is defined with @Bean in a class that is not @Configuration.
Diagnosis. Starting with --debug prints the autoconfiguration report, which lists the positive and negative evaluated conditions with their reason. It is the most underused tool in Spring Boot.
2.3. Circular dependencies
Symptom. The ┌─────┐ ... └─────┘ box with the list of beans in the cycle, and APPLICATION FAILED TO START.
Cause. A needs B and B needs A. With constructor injection it is logically impossible.
Diagnosis. The message itself tells you which beans form the cycle and in which file they are defined.
Fix. The three from Dependency Injection: extract the shared responsibility into a third component, invert the direction with an event, or rethink who should be calling whom.
What is not a fix. Neither spring.main.allow-circular-references=true nor @Lazy on one of the two points: they make the message disappear without fixing anything, and the cycle still produces an unpredictable initialisation order.
2.4. Two candidates with no @Primary
Symptom. required a single bean, but 2 were found: standardFare, studentFare.
Fix. @Primary on the dominant implementation when there is one, @Qualifier at the injection point when you need a specific one, or — the most robust choice in large projects — a custom qualifier such as @University, which the compiler verifies.
The associated, more dangerous mistake. Relying on name matching: calling the parameter studentFare so that Spring picks that bean. It works, and it breaks silently the day an IDE renames the parameter.
- The proxy trap
This section unifies what we have so far seen three times separately, in Transactions, in Method-Level Security and in Scheduled Tasks and Asynchrony. It is the most frequent mistake in the whole course and the most silent.
3.1. Why it happens
None of these annotations modifies your code. Spring creates a proxy — a subclass generated by CGLIB — that wraps your bean, and what is injected into the rest of the application is not your object: it is the proxy.
flowchart LR
C["RentalController"] -->|"external call:<br/>DOES go through the proxy"| P["Proxy of RentalService"]
P -->|"opens a transaction,<br/>checks permissions,<br/>queries the cache"| S["RentalService (real object)"]
S -->|"this.method():<br/>does NOT go through the proxy"| S
Hence the single rule that explains all four cases: only calls coming in from outside pass through the proxy. A call from one method of the class to another of the same class goes via this and the annotation is ignored completely and without any warning.
3.2. The four cases, with their characteristic symptom
| Annotation | What stops happening | Real symptom |
|---|---|---|
@Transactional |
There is no transaction | Every operation auto-commits; a half-done failure leaves inconsistent data with no error |
@Async |
There is no asynchrony | The method runs synchronously; latency does not drop however many threads you add |
@Cacheable |
There is no cache | The hit rate is 0% and nobody understands why |
@PreAuthorize |
There is no permission check | None. And that is exactly the problem |
The last one is the most serious in the course: a security rule that does not run and does not warn.
// ❌ The three internal annotations are ignored
@Service
public class RentalService {
@Transactional
public void finishBatch(List<Long> ids) {
for (Long id : ids) {
finish(id, request); // this.finish(...): no proxy
}
}
@PreAuthorize("@rentalSecurity.isOwner(#rentalId, principal)")
@Transactional
public RentalResponse finish(Long rentalId, FinishRentalRequest r) { ... }
}3.3. The other two triggers: visibility and final
With CGLIB proxies, Spring generates a subclass that overrides the methods. Hence:
| Situation | Result |
|---|---|
private method |
Cannot be overridden: the annotation is silently ignored |
protected or package-private method |
Not reliably intercepted |
final method |
Cannot be overridden: ignored |
final class |
The proxy cannot be created: startup failure or an un-intercepted bean |
Object created with new |
It is not a bean: there is no proxy and no annotation works |
Call from the constructor or @PostConstruct |
The proxy is not wired up yet |
Since Spring Framework 6.0, startup logs a warning when it detects @Transactional on a non-public method. That is a help, not a guarantee.
3.4. The three ways to solve it
A. Extract into another bean (the recommended one). As well as working, it almost always improves the design: orchestrating a batch and executing the individual operation are different responsibilities.
// ✅ The call leaves one bean and enters another: it goes through the proxy
@Service
public class ReturnProcessor {
private final RentalService rentalService; // the PROXY, not the real object
public void processBatch(List<Long> ids) {
ids.forEach(id -> rentalService.finish(id, request));
}
}B. Self-injecting your own proxy. It works and it is ugly: a @Lazy private final RentalService self field and calling self.finish(...); the @Lazy breaks the cycle. Use it when extracting is not worth it, and do comment on why. C. Programmatic management. For @Transactional, TransactionTemplate does not depend on proxies and gives exact control over the scope; it is the way out when you need one transaction per loop iteration.
How to confirm the proxy is doing its job. For transactions, logging.level.org.springframework.transaction: DEBUG should print Creating new transaction where you expect it. For caching, /actuator/metrics/cache.gets. For tasks, /actuator/scheduledtasks. If nothing appears, it is this trap.
- Configuration
4.1. The profile that was never activated
Symptom. In production: the data vanishes on restart, Swagger is reachable from the internet, the log grows without limit and /actuator/env returns 200 with the password in plain sight.
Cause. A single failure: java -jar ciclourbana.jar without --spring.profiles.active and without SPRING_PROFILES_ACTIVE in the environment. application-prod.yml is inside the JAR but is never read.
Diagnosis. The startup log contains the literal proof:
Fix. Set the profile in the container's or the service's environment, and — this is what stops it happening again — a startup check that fails if the active profile is empty in any non-local environment.
4.2. The misspelled property that is silently ignored
Symptom. You change a value in the YAML and nothing happens.
Cause. Spring Boot does not validate that the properties in a file correspond to anything. ciclourbana.fares.price-per-minut: 0.15 simply binds to nothing, and the default value remains in force.
Diagnosis. /actuator/configprops shows the effective values of each @ConfigurationProperties; /actuator/env shows which source each property comes from. If the effective value is not the one you wrote, the key is wrong.
Fix. Two, complementary. The metadata processor (spring-boot-configuration-processor), which makes the IDE autocomplete and underline in red anything that does not exist. And @ConfigurationProperties instead of @Value, because binding a record with @Validated fails at startup if a mandatory field ends up null.
4.3. Badly indented YAML and unexpected precedence
Symptom A. A whole branch of the configuration is ignored. Cause: one indentation level too many or too few makes hikari a child of the wrong key. YAML is whitespace-sensitive and does not accept tabs.
Symptom B. The file's value does not win. Cause: precedence, from highest to lowest: command-line arguments → environment variables → external files → files inside the JAR. A SPRING_DATASOURCE_URL inherited from the environment always beats the file, and it is a classic source of bewilderment in containers.
Symptom C. A list has fewer elements than expected. Cause: when profiles are combined, lists and maps are replaced wholesale, not merged.
4.4. Versioned secrets
Symptom. None. That is the problem.
Cause. A "temporary" password in an application-prod.yml, an .env with no .gitignore, or a COPY . . in the Dockerfile with no .dockerignore, which puts the entire .git directory inside the image.
Diagnosis and fix. A secret-scanning tool over the full history (--log-opts="--all"), not over the working copy. And afterwards, rotate the secret: deleting it from the code does not deactivate it, because it is still in the Git history, in every clone and in every image built since.
- JPA and persistence
5.1. LazyInitializationException
Symptom. failed to lazily initialize a collection of role: ... could not initialize proxy - no Session.
Cause. A lazy relationship is accessed outside the transaction, typically during JSON serialisation, because open-in-view: false closes the persistence context when the service method finishes.
Fix. It is not to load more things: it is to map to a DTO inside the transaction. The entity must not leave the service.
// ❌ The entity leaves with its relationships unloaded
@Transactional(readOnly = true)
public Station getById(Long id) { return stationRepository.findById(id).orElseThrow(); }
// ✅ The mapping happens with the transaction open
@Transactional(readOnly = true)
public StationDetailResponse getDetail(Long id) {
Station station = stationRepository.findWithBikes(id)
.orElseThrow(() -> new ResourceNotFoundException("Station", id));
return mapper.toDetail(station);
}What is not a fix. Neither open-in-view: true nor marking the relationship EAGER. The first hides the problem behind serialisation; the second turns it into 5.2.
5.2. The silent N+1 and the default EAGER
Symptom. The endpoint works and latency grows in proportion to the number of results. With 20 stations it takes 200 ms; with 200, two seconds.
Cause. One query for the list and one more for each element. Plus a structural cause many people do not know about: @ManyToOne and @OneToOne are EAGER by default in JPA, so a relationship nobody declared lazy fires one query per row without appearing anywhere in the code.
Diagnosis.
201 statements for 200 stations needs no further analysis. And in the traces from 09-06 you see it directly: twenty identical, consecutive select spans.
Fix. Declare every association FetchType.LAZY, and load what you need with @EntityGraph, JOIN FETCH or — best of all when it is only going to be displayed — a projection that makes the database do the counting.
How to stop it coming back. A @DataJpaTest test that asserts the number of queries: assertThat(counter.getTotal()).isLessThanOrEqualTo(2). Without that test, the N+1 returns within three months.
5.3. equals and hashCode badly implemented in entities
Symptom. An entity added to a HashSet cannot be found after saving it, or a Set with apparent duplicates.
Cause. The IDE-generated equals includes the id, which is null before persisting and stops being so afterwards: the hashCode changes while the object is inside the HashSet, and the element becomes unreachable in the wrong bucket.
Fix. An equals based on the id, tolerant of proxies, and a hashCode that is constant per class:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Station other)) return false;
return id != null && id.equals(other.getId()); // without an id, equal only to itself
}
@Override
public int hashCode() { return getClass().hashCode(); } // always stableAnd the practical rule that avoids the whole problem: do not put unpersisted entities in a HashSet.
5.4. The unnecessary save() and cascades that delete too much
Symptom A. Nothing visible; just noise. Cause: calling save() on an already managed entity. Dirty checking generates the UPDATE at commit time without anyone asking for it; the save() is redundant and makes the reader believe that without it nothing would be saved.
Symptom B. Deleting a station makes its bikes disappear, and the history with them. Cause: cascade = CascadeType.ALL with orphanRemoval = true added "to make saving work", without noticing that ALL includes REMOVE.
// ❌ Deleting the station deletes its fleet
@OneToMany(mappedBy = "station", cascade = CascadeType.ALL, orphanRemoval = true)
// ✅ Only what makes sense: a bike outlives its station
@OneToMany(mappedBy = "station", cascade = {CascadeType.PERSIST, CascadeType.MERGE})The criterion: a delete cascade is only correct when the child makes no sense without the parent — the lines of an invoice — and a Ribalta bike exists independently of where it happens to be docked.
5.5. The transaction that does not roll back
Symptom. The rental is created even though the payment failed. Or else, UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only.
Cause. The exception was caught and not rethrown. With no exception reaching the proxy, the proxy commits. And if the exception was thrown in an inner method that is also transactional, that method has already marked the transaction rollback-only, so the final commit blows up.
// ❌ The rental is recorded without being charged
try { paymentGateway.charge(amount); }
catch (PaymentDeclinedException e) { log.error("Payment declined", e); }
// ✅ Rethrow as a domain exception
catch (PaymentDeclinedException e) {
log.error("Payment declined for rental {}", rental.getId(), e);
throw new BusinessRuleException("PAYMENT_DECLINED", "The payment has been declined");
}The sibling variant: expecting a rollback from a checked exception. By default Spring only rolls back on RuntimeException and Error. In CicloUrbana this causes no trouble because the whole CicloUrbanaException hierarchy extends RuntimeException; where that is not the case, you need rollbackFor = Exception.class. And the subtlest one: @Transactional on a void method whose body catches and logs every failure: it never throws, the transaction always commits and the caller has no way of knowing that nothing was done.
5.6. The large OFFSET
Symptom. Page 1 flies; page 10,000 takes seconds. Cause: LIMIT 20 OFFSET 200000 forces PostgreSQL to produce and discard 200,000 rows before returning 20.
Fix. Keyset pagination: the client sends the last row it saw and the query positions itself in the index instead of counting. And for listings where the total is not needed, Slice instead of Page, because the count(*) behind every Page is usually more expensive than the data query itself.
- REST and the contract
6.1. Exposing entities and its three consequences
Symptom A: data leak. GET /api/v1/users/1 returns passwordHash and a citizen's national ID number. Nobody decided that: the default mechanism is to publish.
Symptom B: circular references. StackOverflowError or a response several megabytes long when serialising Station → bikes → Bike → station → ....
Symptom C: the mobile app stops showing a field. Somebody renamed a domain property and the JSON changed with it.
Fix. DTOs, without the "just on this endpoint" exception. And for case C, a contract test that asserts exactly which keys the public response returns: if somebody adds an internal field, it fails before the deployment.
6.2. Unvalidated DTOs and mass assignment
Symptom. A null arrives where it should not, or a user registers with {"roles":["ADMIN"]}.
Cause. @Valid is missing on the @RequestBody — the DTO's constraints are there, but nobody evaluates them — or the inbound DTO has fields the client should not be able to set.
The rule that solves the whole thing: what is not in the request DTO cannot be modified. UpdateUserRequest contains name and fareType; no roles, no password, no active, no id.
6.3. Returning 200 for everything and swallowing exceptions
Symptom. The client receives 200 with an empty body or {"error": "something went wrong"}, and the metrics from 09-03 show zero errors while the citizens complain.
Cause. A try { ... } catch (Exception e) { return ResponseEntity.ok().build(); } in the controller, or an @ExceptionHandler that always returns the same code.
Why it matters more than it seems. The status code governs client retries, the behaviour of caches and proxies, and every availability alert. An error returned as 200 is invisible to monitoring.
Fix. A single @RestControllerAdvice that translates each domain exception into its code: 404 for ResourceNotFoundException, 409 for ResourceConflictException, 422 for BusinessRuleException, 400 for validation.
6.4. Serialising Page directly
Symptom. A warning at startup — Serializing PageImpl instances as-is is not supported — and a JSON with pageable, sort.sorted and other Spring Data internals turned, without anyone deciding it, into part of the public contract.
Fix. Your own wrapper, which is what PageResponse<T> does: content, page, size, total elements and total pages, and nothing else. The contract stops depending on a library's internal structure.
- Security
7.1. The order of the rules leaves an endpoint open
Symptom. An endpoint that should require a role responds 200 without authentication.
Cause. authorizeHttpRequests evaluates in order and the first match wins. A general rule placed before a specific one means the specific one is never evaluated.
Diagnosis. A test with spring-security-test that walks a list of routes with no token and asserts 401/403 on all of them. It is the only reliable way: reading the order by eye works poorly as soon as there are more than six rules.
And the structural rule: end with anyRequest().denyAll(). With deny-by-default, forgetting a rule produces a visible 403; with permit-by-default, it produces a hole.
7.2. CSRF switched off without understanding why
Symptom. None immediately. The risk appears if the authentication mechanism changes.
The correct reasoning. CSRF protects against the browser automatically attaching a credential to a request originating from another site, and that happens with session cookies. With a Bearer token that the client attaches explicitly, the attack does not apply and switching it off is correct.
Where the trap lies: the day somebody decides to store the JWT in a cookie — a reasonable decision for other reasons — CSRF becomes necessary again and nobody will remember. That is why the line that switches it off must carry its condition in writing: // no CSRF: the credential travels in Authorization, not in a cookie.
7.3. Unencoded passwords and badly built JWTs
| Mistake | Symptom | Fix |
|---|---|---|
| Storing the password in clear or with MD5/SHA-1 | None, until the breach | BCrypt with a reviewed cost, or Argon2 |
Comparing passwords with equals |
Vulnerable to timing attacks | Always passwordEncoder.matches(...) |
| JWT with no expiry | A stolen token works forever | Short exp (≤15 min) and rotating refresh |
| Sensitive data in the payload | The JWT is readable, it is only signed | Only the identifier and the roles |
| A short signing secret, or one in the repository | Any token can be forged | ≥256 bits, random, via environment variable and rotatable |
The second point in the table deserves emphasis because it is counter-intuitive: a JWT is not encrypted. Anyone can decode its payload in a browser. The signature guarantees it has not been tampered with, not that nobody can read it.
7.4. Leaking the internal error message
Symptom. The response contains ERROR: relation "users" does not exist, a stack trace or a filesystem path. Cause: returning e.getMessage() from an exception of unknown origin, or leaving server.error.include-stacktrace at its default value.
Fix. include-stacktrace: never, include-message: never, and a ProblemDetail with a controlled message plus the trace identifier. The real detail lives in the server log, where the citizen cannot reach and support can.
- Concurrency
8.1. Mutable state in a singleton
Symptom. A counter that loses increments, a piece of data that shows up in another user's response, an irreproducible local failure that only happens under load.
Cause. A mutable field in a singleton bean, shared by every thread: private User currentUser is literally another request's user, and private int rentalsProcessed loses increments.
Fix. Per-request state travels in the arguments or in the MDC; shared state lives in the database, in the cache or in a Micrometer Counter, which is thread-safe.
8.2. The scheduled task that runs N times when you scale out
Symptom. With three replicas, three summary emails to each citizen and three surcharge payments.
Cause. Each instance has its own scheduler. @Scheduled coordinates nothing between processes.
Fix. ShedLock on the PostgreSQL you already have, with @SchedulerLock(name = ..., lockAtLeastFor = ..., lockAtMostFor = ...) and usingDbTime() so that the time reference is the database clock and not each JVM's. And the underlying defence, which works even if the lock fails: make the tasks idempotent. Marking a bike as MAINTENANCE twice changes nothing; adding an amount to a running total twice certainly does.
8.3. The context that does not travel to the async thread
Symptom. A @PreAuthorize that fails inside an asynchronous method for lack of authentication, and background-job log lines with no traceId, impossible to relate to the request that started them.
Cause. The SecurityContextHolder and the MDC live in a ThreadLocal and do not cross on their own to the executor's thread.
Fix. Wrap the executor in a DelegatingSecurityContextAsyncTaskExecutor and register a TaskDecorator that copies the MDC, restoring it in a finally — every bit as important as the MDC.remove() in TraceFilter, because the pool thread is reused and the identifier would stay stuck to it, contaminating the following tasks.
The associated conceptual mistake: putting @Async and @Transactional on the same method. The transaction does not travel either: the executor's thread opens a new, independent one, does not see the caller's uncommitted writes, and a managed entity passed as an argument belongs to another thread's EntityManager. The rule is to pass identifiers, never entities, and to fire the work on AFTER_COMMIT.
- Performance
9.1. Optimising without measuring
Symptom. An afternoon of work and no perceptible improvement, or an improvement nobody can demonstrate.
Cause. Intuition about where the time goes is notoriously bad: almost everyone bets on their own Java code when the real split is dominated by the database. And Amdahl's law finishes the job: making JSON serialisation ten times faster — when it is 5% of the time — improves the whole by 5%.
Fix. A k6 baseline stored in the repository, one change at a time, and measure again. And the SLO on p95 and p99, never on the mean: with 1,000 requests, a mean of 120 ms can hide a p99 of 3 seconds affecting 20 citizens in every thousand.
9.2. The cache that hides a badly written query
Symptom. p95 improves, but the database load stays high and any cache miss produces an enormous spike.
Cause. @Cacheable was added on top of a method that made 43 queries. The cache does not fix the query: it hides it 95% of the time and leaves it untouched for the remaining 5%, which is now the worst case.
The golden rule: first eliminate the unnecessary work, then cache what is left. An endpoint that, after fixing the N+1 and adding an index, goes from 2,410 ms to 91 ms may not need a cache at all.
9.3. The oversized pool and DEBUG in production
Symptom A. You raise maximum-pool-size from 10 to 100 and throughput drops. Cause: an active connection is a CPU core and a database disk doing work; with more connections than real capacity, the server spends its time context-switching and competing for locks.
Diagnosis. hikaricp.connections.pending distinguishes two situations that always get confused: if there is waiting and the database has capacity to spare, the concurrency is real and the pool is undersized; if there is waiting and the database's CPU is quiet, something is holding connections — an HTTP call inside a transaction is suspect number one — and raising the pool only prolongs the agony.
Symptom B. The disk fills up and the cost of log aggregation goes through the roof. Cause: logging.level.com.ciclourbana: DEBUG or org.hibernate.SQL: DEBUG inherited from the development profile. Fix: INFO as the base level and, when you need detail, raise it at runtime with /actuator/loggers and lower it when you are done. With a security warning: some HTTP clients log complete headers at DEBUG, including Authorization.
- Testing
10.1. @SpringBootTest for everything
Symptom. The suite takes twenty-five minutes and nobody runs it locally.
Cause. The ice-cream cone: standing up the whole context to test a fare formula. And a secondary cause that surprises people: Spring caches contexts between test classes, but each different combination of properties, profiles or @MockitoBean creates a new one, so a suite with fifteen variants starts fifteen contexts.
Fix. Unify the integration tests' configuration in a common base class — IntegrationTestBase — and push everything that does not need the context back down the pyramid.
10.2. Flaky tests
| Cause | Symptom | Fix |
|---|---|---|
LocalDateTime.now() in the code under test |
Fails at midnight or at the clock change | An injected Clock and Clock.fixed in the test |
| Dependence on execution order | Fails when you add a new test | Each test sets up its own data and leaves no trace |
| Data shared between tests | Fails when run in parallel | @Transactional on the test, or explicit cleanup |
Thread.sleep waiting for something asynchronous |
Fails on a slower machine | Awaitility with a condition and a maximum wait |
| Dependence on a real external service | Fails when the service is down | Testcontainers or a test double |
And the worst consequence, which is cultural: a single flaky test trains the team to retry instead of investigate, and that habit ends up ignoring the real failures too.
10.3. Coverage as a target
Symptom. 85% coverage and production bugs in the covered code.
Cause. Coverage measures which code was executed, not which behaviour was verified. A test without a single assert gives 100% coverage.
// Full coverage, zero verification
@Test
void verifiesAbsolutelyNothing() {
new StandardFare().calculate(Duration.ofMinutes(30));
}Fix. Read coverage as a map of the red — an entire uncovered business if is a legitimate question — and not as a score. Plus a mechanical review rule: every test has at least one assertThat or one assertThatThrownBy.
- Quick diagnosis table
| Symptom | Where to look first | Lesson |
|---|---|---|
NoSuchBeanDefinitionException |
The main class's package, the stereotype, @Profile; start with --debug |
02-01 |
| Dependency box at startup | The message itself: it says which beans form the cycle | 02-02 |
| "This annotation does nothing" | The proxy trap: self-invocation, private/final method, new |
Section 3 |
| The transaction does not roll back | An exception caught without rethrowing, or a checked one | 04-07 |
UnexpectedRollbackException |
An inner method marked rollback and the outer one caught | 04-07 |
LazyInitializationException |
An entity outside the service; map to a DTO inside the transaction | 04-07 |
| Latency proportional to the number of results | N+1: generate_statistics, a query counter, the span cascade |
09-01 |
| Good median and terrible p99 | Resource waiting: hikaricp.connections.pending, locks, GC |
09-01 |
| The YAML value is ignored | /actuator/configprops and /actuator/env; indentation and precedence |
02-04 |
| Production behaves like development | The profile was not activated: look for No active profile set in the log |
07-02 |
| An endpoint responds without authentication | Rule ordering; denyAll() missing at the end |
05-02 |
| A user reaches another user's data | Missing per-datum @PreAuthorize, or it sits on an unintercepted method |
05-05 |
| The response leaks SQL or stack traces | include-stacktrace, include-message, someone else's e.getMessage() |
03-06 |
| A task "stopped running" | An exception that escaped to the scheduler; /actuator/scheduledtasks |
07-03 |
| Duplicated work when scaling out | No ShedLock; and non-idempotent tasks | 07-03 |
Async logs with no traceId |
The TaskDecorator that copies the MDC is missing |
07-03 |
| Cache hit rate at 0% | Self-invocation, or a key that changes on every call | 09-02 |
| Metrics that bring Prometheus down | Cardinality: a tag with many distinct values | 09-03 |
OOMKilled (exit code 137) with no exception |
MaxRAMPercentage too high or a leak; -Xlog:gc |
09-01 |
| The suite takes twenty minutes | @SpringBootTest everywhere and contexts not reused |
06-04 |
Common Mistakes and Tips
Looking for the cause where the symptom is. A single-threaded scheduler means a slow task blocks another one, and the symptom shows up on the wrong task. A pool held by HTTP calls produces a high p99 on endpoints that have nothing to do with it. Before looking at the code of the symptom, ask yourself what resource it shares with everything else.
Fixing the symptom instead of the cause. @Lazy for a cycle, open-in-view: true for a LazyInitializationException, raising the pool for connection waiting, flyway:repair for a checksum that does not match. All four make the message disappear and leave the problem untouched.
Changing several things at once while diagnosing. If you touch the index, the pool and the cache in the same deployment and things improve, you do not know which one helped or which one is making something else worse.
Ignoring the startup warnings. Spring Boot warns about @Transactional on non-public methods, about PageImpl serialisation and about open-in-view being active. A startup full of warnings that "have always been there" is a place where the new warning goes unnoticed.
Tip: when something "does nothing", suspect the proxy before anything else. Four different annotations, one single mechanism, one single diagnosis: does the call come from outside the bean? Is the method public and not final? Did Spring create the object?
Tip: turn every production bug into a test. Before fixing it, write the test that reproduces it and watch it fail. That way you know you fixed what you thought you were fixing, and you guarantee it does not come back.
Tip: learn to read the three reports Spring Boot already gives you. The autoconfiguration one with --debug, /actuator/env with the source of each property and /actuator/configprops with the effective values. Between the three they explain most cases of "but I did set that".
Exercises
Exercise 1: six faults in an incident service
Find the six mistakes in this class, state for each one the symptom it will produce and when it will appear, and rewrite it.
@Service
public class IncidentService {
@Autowired private IncidentRepository repository;
private final Map<Long, Incident> lastSeen = new HashMap<>();
@Transactional
public void processPending() {
for (Incident i : repository.findAll()) {
closeIfDue(i);
}
}
@Async
@Transactional
private void closeIfDue(Incident incident) {
try {
incident.setStatus(IncidentStatus.CLOSED);
lastSeen.put(incident.getId(), incident);
notifier.notifyWorkshop(incident.getBike().getPlate());
} catch (Exception e) {
log.error("Error", e);
}
}
}Exercise 2: from symptom to cause
For each of these five production reports, formulate the most likely hypothesis, say what you would check to confirm it and what you would not do.
- "Since Tuesday's deployment, the p99 of
POST /api/v1/rentalswent from 300 ms to 4 s. The median is still at 45 ms. Application CPU at 20%, database CPU at 25%. NoFull GC." - "Operator Ramón says he marked a bike as broken and it still shows as available. There is no error in the log."
- "Citizens have been getting three monthly summary emails since the council asked for more capacity."
- "
/api/v1/stationstook 200 ms with 20 stations. Now that there are 200, it takes 2 s." - "We changed
ciclourbana.fares.price-per-minuteto 0.15 inapplication-prod.yml, deployed, and the invoices are still being calculated at 0.12."
Exercise 3: the dangerous migration
The team is about to deploy CicloUrbana version 2.5.0 with a rolling deployment — instances of the previous version live alongside the new ones for a few minutes — and it includes this migration:
-- V10__rental_adjustments.sql
ALTER TABLE rentals DROP COLUMN origin_station_id;
ALTER TABLE rentals ADD COLUMN start_station_id BIGINT NOT NULL;
ALTER TABLE rentals ADD CONSTRAINT fk_rental_start_station
FOREIGN KEY (start_station_id) REFERENCES stations(id);
CREATE INDEX idx_rentals_start_station ON rentals (start_station_id);List every problem it will cause and rewrite the complete migration plan.
Solutions
Solution 1
Mistake 1 — @Autowired on a field. It does not allow final, it forces reflection to test the class and it hides the growth of dependencies. Appears: when you write the first unit test.
Mistake 2 — a mutable HashMap in a singleton. State shared between threads with no synchronisation: a HashMap can be corrupted by concurrent writes — even producing infinite loops in its internal structure — and it also grows without limit, so it is a memory leak too. Appears: under load, irreproducibly.
Mistake 3 — self-invocation. closeIfDue(i) is called on this: neither @Async nor @Transactional has any effect. Everything runs synchronously inside the outer transaction. Appears: never as an error; the asynchrony simply does not exist.
Mistake 4 — annotations on a private method. Even if the self-invocation were fixed, a private method cannot be intercepted. Appears: never; it is silently ignored.
Mistake 5 — an exception caught and not rethrown. If something fails, it is logged and the loop carries on; the outer transaction commits and incidents are taken as closed when they are not. And if the failure marked the transaction rollback-only, the final commit gives an UnexpectedRollbackException. Appears: on the day of the first partial failure.
Mistake 6 — an external call inside the transaction. notifier.notifyWorkshop(...) holds a pool connection for the entire network call. With findAll() over thousands of incidents, the transaction can last minutes with a connection blocked. Appears: as the volume grows, as connection waiting and a high p99.
And a seventh design problem the brief does not number: findAll() with no filter and no pagination, discarding most of the rows in memory.
@Service
public class IncidentService { // constructor omitted: repository and closer, both final
/** No @Transactional: it only orchestrates. Each incident gets its own transaction. */
public int processPending() {
List<Long> ids = repository.findIdsPendingClosure(); // filters in the database
int closed = 0;
for (Long id : ids) {
try {
closer.close(id); // another bean: the call goes through the proxy
closed++;
} catch (DataAccessException e) {
log.error("Could not close incident {}", id, e);
}
}
return closed;
}
}
@Service
public class IncidentCloser {
@Transactional(timeout = 5) // public, in another bean: it is intercepted
public void close(Long incidentId) {
Incident incident = repository.findById(incidentId)
.orElseThrow(() -> new ResourceNotFoundException("Incident", incidentId));
incident.setStatus(IncidentStatus.CLOSED); // dirty checking, no save()
events.publishEvent(new IncidentClosed(incidentId,
incident.getBike().getPlate()));
}
}
@Component
public class IncidentNotifier {
/** Outside the transaction and outside the orchestrating thread. */
@Async("mailExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onClosed(IncidentClosed event) {
notifier.notifyWorkshop(event.plate());
}
}Note that fixing mistake 3 also fixes mistake 6: by moving the notification out to AFTER_COMMIT, the connection is already back in the pool when the network call begins. And the mutable state of mistake 2 simply disappears: it was never needed.
Solution 2
1. Connection pool waiting. The signals fit one by one: the median is untouched (the fast path did not change), the p99 has shot up with low CPU on both sides (it is not compute saturation), no Full GC (they are not memory pauses). I would check: hikaricp.connections.pending and hikaricp.connections.usage; /actuator/threaddump during a spike, looking for threads that already hold a connection and are blocked on a socket; and leak-detection-threshold: 20000, whose stack trace points at the guilty method. The most likely cause is an HTTP call inside a transaction introduced on Tuesday. What I would not do: raise maximum-pool-size or the Tomcat threads; with the database at 25% there is no shortage of capacity, and more connections held by remote calls only prolongs the problem.
2. The proxy trap on @Transactional. "There is no error in the log" is the signature. I would check: whether markAsBroken is invoked from another method of the same class, whether it is public, and whether logging.level.org.springframework.transaction: DEBUG prints Creating new transaction on that call. Second hypothesis, if the transaction does exist: the save() is missing and the entity is not managed because it was built outside the context. What I would not do: add save() blindly; if the problem is the proxy, the save() will not run in a transaction either.
3. A scheduled task multiplied by scaling out. "Since the council asked for more capacity" means more replicas, and each one has its own scheduler. I would check: the number of replicas and whether the task carries @SchedulerLock. What I would not do: move the task to a profile active on a single instance as the definitive fix — that creates a single point of failure —; ShedLock is the answer, and it is also worth reviewing whether the task is idempotent.
4. N+1. Latency grows in proportion to the number of results: that is the definition. I would check: generate_statistics in pre-production to see the statement count, or the span cascade directly, looking for repeated selects. What I would not do: add a cache. It would hide the problem 95% of the time and leave the remaining 5% worse than before.
5. A property that does not bind. Three hypotheses in order: the key is misspelled; an environment variable CICLOURBANA_FARES_PRICE_PER_MINUTE beats the file by precedence; or the prod profile was not activated and the file is not read. I would check: /actuator/configprops for the effective value of FareProperties and /actuator/env to find out which source it comes from. Both answer in thirty seconds. What I would not do: recompile "just in case", or change the value in application.yml without knowing which of the three causes it is.
Solution 3
The problems, in order of severity.
1. Data is lost and is unrecoverable. DROP COLUMN origin_station_id erases the origin station of every historical rental. There is no UPDATE to backfill them afterwards: the record of where each rental in the network began is gone.
2. ADD COLUMN ... NOT NULL with no default value fails. If the table has rows, PostgreSQL cannot fill the new column and the migration aborts halfway. And even if it did not fail, there would be nowhere to get the value from: it was deleted on the previous line.
3. It breaks the instances of the previous version. During the rolling deployment, the 2.4.0 instances are still running SELECT ... origin_station_id .... As soon as the migration is applied, every one of them starts failing with column does not exist, and since the migration runs before the new instances start, the service is down for every citizen in Ribalta.
4. CREATE INDEX without CONCURRENTLY blocks writes on the rentals table for the whole index build. With millions of rows, that is minutes with no rentals able to start or finish.
5. It is a rename in disguise. The real change is origin_station_id → start_station_id, and renaming a column is never backward compatible. What is more, the benefit is cosmetic: it is worth asking whether it is worth the risk.
The correct plan: expand/contract across three deployments.
-- V10__expand_start_station.sql (deployment 1, with version 2.5.0)
ALTER TABLE rentals ADD COLUMN start_station_id BIGINT; -- nullable
UPDATE rentals SET start_station_id = origin_station_id; -- in batches if it is large
ALTER TABLE rentals ADD CONSTRAINT fk_rental_start_station
FOREIGN KEY (start_station_id) REFERENCES stations(id);
CREATE INDEX CONCURRENTLY idx_rentals_start_station ON rentals (start_station_id);
-- V11__harden_start_station.sql (deployment 2, with version 2.6.0)
UPDATE rentals SET start_station_id = origin_station_id WHERE start_station_id IS NULL;
ALTER TABLE rentals ALTER COLUMN start_station_id SET NOT NULL;
-- V12__contract_drop_origin_station.sql (deployment 3, with version 2.7.0)
DROP INDEX IF EXISTS idx_rentals_origin_station;
ALTER TABLE rentals DROP COLUMN origin_station_id;In 2.5.0 the entity maps the new column and writes to both, so that the 2.4.0 instances still alive keep seeing correct data; the new column is nullable because for a few minutes there are old instances inserting rows without filling it. In 2.6.0 the entity stops writing to the old one, and only then — in 2.7.0 — is it dropped.
The four rules this exercise sums up. A migration must work with the version running now and with the one about to be deployed. Adding is compatible; renaming and dropping never are. A NOT NULL is reached in three steps, never in one. And CONCURRENTLY is mandatory for any index on a production table.
And one practical check that costs little and catches nearly all of this: run the migration against a recent copy of production with the previous version of the application running on top. If the old application still works after migrating, the rolling deployment is safe.
Conclusion
You now have the reverse side of the previous catalogue: the mistakes the practices in 10-01 prevent, each with its real symptom, its cause, how it is diagnosed and the corrected code. And with a classification that directs your attention: the noisy ones cost an afternoon, the silent ones cost a data reconciliation and the deferred ones turn up exactly when it hurts most.
You can recognise the startup failures — the main class outside the root package, the bean that does not exist, the cycle with its box, the two candidates with no tie-breaker — and use the three tools Spring Boot already gives you to diagnose them: the --debug report, /actuator/env and /actuator/configprops. You finally have the proxy trap treated in a single place, with its mechanism — only calls coming in from outside go through the proxy —, its four faces (@Transactional, @Async, @Cacheable and the most dangerous, @PreAuthorize), its visibility and final triggers, and its three fixes, with extraction into another bean as the one that also improves the design.
You recognise the profile that was never activated and its literal proof in the log, the misspelled property that is ignored without complaint, the lists that are replaced rather than merged and the versioned secret that is not fixed by deleting it but by rotating it. In JPA you have the LazyInitializationException with its one correct fix, the N+1 and its EAGER hidden inside @ManyToOne, the equals that breaks HashSets, the cascades that delete too much, the transaction that commits because somebody caught the exception, and the large OFFSET. In REST, the three consequences of exposing entities, mass assignment, the 200 that makes errors invisible to monitoring and the Page that turns a library's internal structure into a public contract. In security, the order of the rules, CSRF switched off with no written reason, the JWT that is not encrypted and the error that gives away internal information. In concurrency, shared mutable state, the task that multiplies when you scale out and the context that does not travel to the async thread. In performance, optimising without measuring, the cache that hides a badly written query and the oversized pool. And in testing, the ice-cream cone, the five causes of flakiness and the coverage that rises without verifying anything. Closing the catalogue is the quick diagnosis table: twenty-one symptoms with the place to start and the lesson where the full answer lives.
There is a third dimension to the question "is it well made?". We have seen what to do and what to avoid, but neither says anything about how the code reads. A service can pass all thirty-eight checks and contain not one mistake from this catalogue, and still be a two-hundred-line method with four boolean flags, names that say nothing, comments that repeat the code and a domain model that is a bag of setters. That breaks nothing today: it breaks the team's ability to change it tomorrow. The next lesson, Tips for Writing Clean Code, deals with that dimension: names that reveal intent, functions with a single level of abstraction, SOLID with real examples from Ribalta, comments that explain the why, immutability and Optional used well, the anaemic domain model versus Rental.finish(...), dependency rules verified automatically with ArchUnit, the style that is not argued about because a tool applies it, and how technical debt is recognised, recorded and repaid.
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
