Series from spring-validation-lab. Index: Proving Spring Internals With Tests.
Previous: ThreadLocals and @Async
This post: lifecycle, circular dependencies, repository mini-transactions
Next: HikariCP, connection hogging, optimistic locking
Phase 1 is not only proxies and ThreadLocals. Three related traps show up in every senior Spring loop: startup seeding that “has @Transactional but doesn’t,” circular constructor graphs that refuse to boot, and services that throw after two save() calls only to discover both rows persisted because there was never an outer umbrella.
We proved all three with H2.
Trap 1 — @PostConstruct before the proxy
Theory (lifecycle order, simplified)
- Instantiation (
new) - Injection (constructors / setters / fields)
- Initialization (
@PostConstruct,InitializingBean, …) - AOP proxy creation / publish bean to the context for clients
So when @PostConstruct runs, you are still on the raw target. A @Transactional sticky note on that method is unread. No Spring transaction umbrella opens from that annotation.
People then observe: “but my seed rows are in the DB!” Correct — Spring Data repository methods are themselves @Transactional(REQUIRED). With no outer TX, each save() opens a mini-transaction, commits, and returns. Your PostConstruct “transaction” failed; the repository’s did not.
Broken-ish design we measured
PostConstructSeeder:
@PostConstruct @Transactional // ignored — no proxy yet public void seedOnStartup() { observedAsProxyDuringPostConstruct = AopUtils.isAopProxy(this); transactionActiveDuringPostConstruct = TransactionSynchronizationManager.isActualTransactionActive(); repository.save(new SeedConfig("MAX_USERS", "5000")); repository.save(new SeedConfig("THEME", "DARK")); }How we proved it
Test: PostConstructBeforeProxyTest
./gradlew test --tests '*PostConstructBeforeProxyTest'Assert during PostConstruct observations:
| Observation | Expected |
|---|---|
postConstructRan | true |
isAopProxy(this) | false |
isActualTransactionActive() | false |
rows for MAX_USERS / THEME | both present (mini-TXs) |
Second test in the same class: after full context build, the injected PostConstructSeeder is a proxy (AopUtils.isAopProxy(postConstructSeeder) == true). An ApplicationRunner (AfterContextReadySeeder) with @Transactional sees txActive=true and can seed under a real umbrella.
What to do instead
For transactional startup work, wait until the context is ready:
ApplicationRunner/CommandLineRunnerContextRefreshedEventlistener- explicit
@Transactionalservice called from those hooks (so the call crosses a proxy)
Do not put critical multi-step transactional seeding on @PostConstruct.
Trap 2 — constructor circular dependencies
Theory
Constructor injection A ↔ B means neither object can finish construction without the other. There is no half-built instance to stash in the third-level cache the way field/setter injection historically allowed.
Spring Boot disables circular references by default since 2.6. Field injection cycles also fail unless you re-enable the escape hatch. The principled fix is almost always a third orchestrator or @Lazy on one side — not “turn circular refs back on and move on.”
How we proved the crash
ConstructorCircularDependencyTest uses ApplicationContextRunner scanning profile-gated circular beans.
- Constructor cycle (
circular-constructorprofile) → context hasFailed, cause mentions circular /BeanCurrentlyInCreationException. - Field cycle (
circular-field) → also fails by default on modern Boot. - Runner escape hatch:
.withAllowCircularReferences(true)boots the field cycle. - Principal fix:
@Lazyon one constructor parameter boots cleanly without enabling circular refs globally.
./gradlew test --tests '*ConstructorCircularDependencyTest'Ground truth: the property people mis-cite
Folklore: set spring.main.allow-circular-references=true and every test harness suddenly allows cycles.
Lab: that property applies to SpringApplication. A raw ApplicationContextRunner ignores it. Use:
runner.withAllowCircularReferences(true)or fix the graph with @Lazy / redesign.
If an interviewer asks “how do you allow circular references in tests?”, distinguishing SpringApplication vs ApplicationContextRunner is a strong senior signal.
Trap 3 — repository mini-umbrellas
Theory
SimpleJpaRepository methods are @Transactional with Propagation.REQUIRED:
- Outer TX present → join it
- No outer TX → open a short-lived transaction, commit on method exit
So “my service method is not transactional” does not mean “nothing commits until I say so.” It means each repository call may commit immediately.
That is catastrophic for multi-step writes that must be atomic: save A, save B, throw → both A and B already durable.
Broken design (lab)
NonTransactionalNoteService — no class/method @Transactional, two save()s, then throw.
Fixed / contrasting design
TransactionalNoteService — outer @Transactional, same two saves, then throw → both roll back.
How we proved it
Test: RepositoryMiniUmbrellaTest
./gradlew test --tests '*RepositoryMiniUmbrellaTest'| Path | After exception |
|---|---|
| No outer TX | note-a and note-b both count = 1 |
Outer @Transactional | note-c and note-d both count = 0 |
Same throw. Different atomicity. The difference is the umbrella.
How the three traps connect
All three are about when Spring’s transactional interceptors exist and who opens the transaction:
| Moment / call | Who reads @Transactional? |
|---|---|
@PostConstruct on your bean | Nobody (no proxy yet) |
repository.save with no outer TX | Repository proxy → mini-TX |
repository.save under your @Transactional service | Your proxy opened umbrella; repo joins |
| Constructor A↔B | Context never finishes — annotations irrelevant |
Once you see that map, “transactional seeding on PostConstruct” and “partial commits without outer TX” stop feeling like unrelated trivia.
Interview answers that hold
Q: Can I use @Transactional on @PostConstruct?
A: The annotation on that method is ignored; proxy does not exist yet. Rows may still persist via repository mini-transactions. Use a runner after context refresh.
Q: Why did half my writes survive an exception?
A: No outer transaction. Each Spring Data save likely committed independently. Wrap the unit of work in @Transactional on a proxied service method.
Q: Constructor circular dependency — enable the flag?
A: Prefer redesign or @Lazy. Know Boot defaults to deny cycles. Know spring.main.allow-circular-references is a SpringApplication concern; runners need their own API.
How to run Phase 1 lifecycle proofs
./gradlew test --tests '*PostConstructBeforeProxyTest' ./gradlew test --tests '*ConstructorCircularDependencyTest' ./gradlew test --tests '*RepositoryMiniUmbrellaTest'Docs: docs/03-bean-lifecycle.md
One-liner
Know the lifecycle order. Startup transactional work needs a fully built context. And “no outer transaction” does not mean “no transaction” — repositories will invent mini ones.
Next (Phase 2): HikariCP exhaustion, connection hogging, and optimistic locking.