save, saveAndFlush, and REQUIRES_NEW: The Hibernate Questions Hiding in One Spring Service Method
Table of Contents
Last week I was reading a teammate’s PR and stopped on a method that made me realise something: I’d been using save, flush, commit, and REQUIRES_NEW correctly for years — and if a junior had asked me to explain precisely what each one does and when, my answer would have been thinner than my code. Using a tool and being able to teach it are different skills, and the gap only shows when someone asks.
Here’s the method:
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void addAttachmentInNewTransaction(EmailInbox inbox,
EmailAttachment attachment) {
attachment.setEmailInbox(inbox);
attachmentRepository.saveAndFlush(attachment);
}
Eight lines. Two annotations. One method call. And four questions, in the order any developer reading this method should ask them:
- what
saveAndFlushdoes thatsavedoesn’t, - what “flush” even means if you’re already calling
save, - why
REQUIRES_NEWis required for this exact pattern to work, - and who, in the end, actually commits the transaction.
If you’ve ever written repository.save(...) and felt fuzzy about what happens between that line and the data actually being in the database, this one’s for you.
1. What does save actually do?
Here’s the thing nobody tells you on day one of Spring Data JPA: save doesn’t insert. It schedules an insert.
When you call repository.save(entity), Hibernate does not send the INSERT SQL to the database right away. It puts the entity in an in-memory bookkeeping structure called the persistence context (sometimes called the first-level cache or session), and then waits. The actual SQL only goes out at a “flush” — which, by default, happens automatically at one of three moments:
- right before a query that needs to see the pending writes,
- at the end of the transaction (commit time),
- or when you explicitly call
flush()/saveAndFlush().
So save is less “insert this row” and more “note that this row needs to exist; I’ll send it later.”
This is a feature, not a bug. Hibernate buffers because it lets the framework batch multiple writes into one network round-trip, reorder them for foreign-key constraints, and skip work if you change your mind inside the same transaction. It is genuinely faster.
It is also genuinely surprising the first time it bites you — which is what brings us to question 2.
2. So what’s the difference between save and saveAndFlush?
Same final outcome on the happy path. Completely different in when the database gets to weigh in.
save | saveAndFlush | |
|---|---|---|
| Returns immediately | yes | yes |
INSERT actually sent to the DB | later (at commit or auto-flush) | now |
| DB constraints checked | at commit | at this line |
| Constraint violation surfaces | at the commit boundary | as a normal exception, here, in this method |
That last row is the one that matters for this whole pattern.
If you call save and you’ve violated a unique constraint, you will get an exception — eventually. It will be thrown between your method returning and Spring’s AOP committing the transaction, in a no-man’s-land where you can’t easily wrap it in try/catch. Your method has already exited. The exception travels up through AOP machinery you didn’t write. (That’s the usual, transactional case. When no transaction is open around the call, save commits — and therefore flushes — immediately; section 12 comes back to why you still shouldn’t lean on that.)
If you call saveAndFlush, the INSERT is sent immediately, the database checks constraints immediately, and any DataIntegrityViolationException lands on the line you wrote. You can catch it with a normal try/catch. The exception lives where you can reach it.
That’s the whole reason this codebase uses saveAndFlush for the duplicate-attachment guard. The unique index needs to do its job synchronously, here, inside the method, not at some future commit moment. Otherwise the caller has nothing to catch.
3. Then what is “flush”, as a thing?
Flush is the verb for the Hibernate concept above: “stop buffering my pending writes and send the SQL to the database now.”
Walk through the lifecycle:
var attachment = new EmailAttachment(...);
repository.save(attachment);
// ↑ in memory: Hibernate notes "I'll INSERT this... eventually."
// ↑ DB: knows nothing yet. No SQL has been sent.
repository.flush();
// ↑ SQL sent NOW: INSERT INTO email_attachment (...) VALUES (...)
// ↑ DB: row exists IN THIS TRANSACTION. Constraints are checked.
// ↑ Other transactions: still don't see it (we haven't committed).
// ... method returns, Spring's AOP commits ...
// ↑ Now everyone sees it.
Three distinct moments people confuse all the time:
| State | Where the change lives | Visible to other transactions? |
|---|---|---|
After save (no flush yet) | only in Hibernate’s memory | no |
After flush | in the database, inside your transaction | no |
After commit | in the database, permanently | yes |
The “flush” step is the one most developers don’t have a clean mental model for, because they almost never call it explicitly. They write save, the transaction commits, life goes on. But the moment you need the DB to check something — a unique constraint, a foreign-key relationship, a generated column — before the transaction ends, the timing of the flush stops being something you can leave to luck.
4. So flush sends the SQL to the DB — then what does “commit” do that’s different?
The single most important sentence to internalise here:
Flush makes the data exist in the database. Commit makes it visible to everyone else.
Transactions are isolated by design. Your changes — once flushed — exist in the database, but they sit inside an open transaction that no other session can see into. You can still roll them back. They are real but private.
| State | Your transaction sees it? | Other transactions see it? | Can still be rolled back? |
|---|---|---|---|
After save only | yes (via Hibernate cache) | no | yes |
After flush | yes (now in DB tables, in your tx) | no | yes |
After commit | yes | yes | no |
A useful analogy is editing a Google Doc in Suggesting mode versus accepting all suggestions. Flushing is the equivalent of typing the suggestions: they exist in the document, they’re real, but readers see them as drafts. Commit is Accept all. They become part of the document for everyone.
This separation is what makes the unique-constraint trick work: we want the constraint check (which is a flush thing — the DB has to evaluate the index against a real INSERT) to fire before the commit, while we’re still inside our method and able to catch the resulting exception cleanly.
5. Aside — but the UUID is already populated after save. How, if no SQL has been sent?
Good catch, and the answer is one of the underrated reasons UUIDs are so loved in modern distributed systems:
var attachment = EmailAttachment.builder()....build();
// attachment.getId() == null
repository.save(attachment);
// attachment.getId() == "330a1ee2-69dd-4d41-..." ← populated, in memory
// DB: still knows nothing. No INSERT yet.
// ...later, at flush/commit
// INSERT INTO email_attachment (id, ...) VALUES ('330a1ee2-...', ...)
For @GeneratedValue(strategy = GenerationType.UUID), the UUID is generated by Hibernate, in your JVM. No database round-trip is needed to know the ID — it’s available the instant save returns. The actual row arrives in the DB only at flush.
Contrast this with IDENTITY (auto-increment columns like Postgres BIGSERIAL): the ID is generated by the database, on insert, so Hibernate has no choice but to flush immediately on save to get the value back. With IDENTITY, the “lazy persistence” behaviour we just talked about effectively doesn’t apply.
| Strategy | ID known when? | Does save hit the DB immediately? |
|---|---|---|
UUID | as soon as save returns | no — defers to flush |
SEQUENCE | as soon as save returns (separate nextval query) | no — defers to flush |
IDENTITY (auto-increment) | only after the INSERT | yes — forced flush |
This is why event-driven backends prefer UUIDs as primary keys: you can use the ID, set it on related entities, even publish a “thing X created” event — all before the row physically commits. The ID is yours the instant the object is constructed in memory.
6. OK, back to the method — what does @Transactional(propagation = REQUIRES_NEW) actually do?
A @Transactional method runs inside a database transaction. The default propagation (REQUIRED) means “if there’s already a transaction open, join it; if not, start one.” That sounds sensible — and for 90% of methods it is.
REQUIRES_NEW is different. It says “whatever transaction is open, suspend it. Start a brand-new one. When this method ends, commit (or roll back) only the new one, then resume the old one.”
So if a step in a Spring Batch job is already running in transaction T1, and it calls our addAttachmentInNewTransaction(...), the runtime:
- Suspends T1 — pauses it; doesn’t commit, doesn’t roll back.
- Starts a brand-new transaction T2 for our method.
- Runs the method body inside T2.
- On exit, commits (or rolls back) T2 only.
- Resumes T1, untouched.
T1 has no idea any of this happened. From T1’s point of view, a method got called and returned. Whatever T2 did to the DB is now permanent (if T2 committed) or never happened (if T2 rolled back), but in either case T1 is in exactly the state it was in before the call.
This is the foundation that makes the next question’s answer possible.
7. But why can’t I just do saveAndFlush in the outer transaction and catch the violation?
This is the most common wrong answer to “how do I make a unique constraint work as an idempotency guard”, and getting it wrong silently breaks your batch jobs months later. Worth slowing down for.
Here’s what happens if you skip REQUIRES_NEW:
// outer step transaction is active (T1)
try {
attachmentRepository.saveAndFlush(attachment);
// DB rejects → DataIntegrityViolationException thrown
} catch (DataIntegrityViolationException e) {
log.info("duplicate, skipping");
// we caught it, all good?
}
// ...step body continues, maybe touches the DB again...
// ...step ends, Spring tries to commit T1...
// → "Transaction is marked rollback-only" 💥
// step FAILS, even though we caught the exception
Once a constraint violation hits a transaction, Spring and the JTA spec mark the entire transaction as rollback-only. That status is sticky. Catching the exception does not unset it. Any subsequent DB call in the same transaction fails. The commit at the end of the step fails. Spring Batch sees the step as failed — even though, from your code’s point of view, you handled the duplicate gracefully and logged a friendly message.
The result: you “handle” the duplicate, the step still fails, the batch job logs a spurious failure, and a notification fires telling someone that the batch broke. The exact thing you were trying to avoid.
REQUIRES_NEW is what makes the rollback local. Only the inner transaction (T2) is marked rollback-only and dies. The outer transaction (T1) was suspended the whole time the violation was happening; nothing about T1’s state changed. When T1 resumes, it has no idea anything went wrong. Catching the exception in T1 is safe because the violation never touched T1.
The full timeline of one duplicate-attempt:
Caller (in step tx T1)
│
├─ try {
│ inboxService.addAttachmentInNewTransaction(...) ← method call
│ │
│ │ [AOP intercepts because @Transactional(REQUIRES_NEW)]
│ │ - Suspend T1
│ │ - Start new tx T2 ←─────────────────── "the new one"
│ │
│ │ Inside the method body (running in T2):
│ │ ├─ attachment.setEmailInbox(inbox)
│ │ ├─ repository.saveAndFlush(attachment)
│ │ │ ├─ INSERT sent to DB (inside T2)
│ │ │ ├─ DB checks unique index
│ │ │ └─ ❌ violation → throws DataIntegrityViolationException
│ │ │ ↑ happens HERE, inside T2
│ │ │
│ │ [Exception propagates out of method body]
│ │ - AOP rolls back T2
│ │ - Resumes T1
│ │ - Re-throws the exception
│ │
│ } catch (DataIntegrityViolationException dup) {
│ log.info("duplicate, skipping"); ← caller catches it, in T1
│ }
│
└─ Step continues normally. T1 commits at end of step.
The exception is the same exception — born in T2, observed in T1. The reason this works is the symmetry: by the time the catch fires, T2 has rolled back and T1 has resumed. T1 was paused for the entirety of the violation. There is no rollback-only flag on T1, because T1 was suspended while the rollback happened.
That’s the whole point of REQUIRES_NEW for this pattern: it gives the violation a separate transaction to die in.
Embedded takeaway: any time you want a database constraint to act as an idempotency guard with clean conflict handling, the pattern is
@Transactional(REQUIRES_NEW) + saveAndFlush, with the call wrapped intry/catch(DataIntegrityViolationException)by the caller. Both annotations are load-bearing. The propagation isolates the failure; the flush forces it to surface in time to be caught.
See this exact pattern deployed in production — deduping incoming Gmail attachments in a Spring Batch pipeline — in the follow-up war story: The Spring Batch job that kept creating duplicate invoices.
8. One last question — who actually commits the transaction? Spring or the database?
Both, working as a chain. Different layers do different jobs, and most “Spring transaction” confusion is from collapsing them into one box.
your code @Transactional(...)
│
Spring AOP wraps the method;
at method exit, decides
commit (success) or rollback (exception)
│
Spring TransactionManager tells Hibernate "end this transaction"
│
Hibernate (JPA provider) auto-flushes any leftover pending writes,
then calls connection.commit() on JDBC
│
JDBC driver sends the actual "COMMIT" command
│
PostgreSQL writes to WAL, makes the changes
durable and visible to other sessions
So:
- Who decides when to commit? Spring (based on your
@Transactionalboundary — commit on normal method exit, rollback if an exception propagates out). - Who sends the actual COMMIT SQL? The JDBC driver, on instructions from Hibernate.
- Who executes the commit? Postgres (the DB engine — it’s the only one that can actually make data durable on disk).
That’s why @Transactional is such a small annotation that does so much. It’s just Spring offering to handle the commit/rollback orchestration for you by intercepting your method through AOP. Without it, every database-touching method would need its own scaffolding:
TransactionStatus tx = txManager.getTransaction(new DefaultTransactionDefinition());
try {
// your method body
txManager.commit(tx);
} catch (RuntimeException e) {
txManager.rollback(tx);
throw e;
}
Multiply that by a thousand methods, with nested transactions, propagation rules, isolation levels, and rollback-on conditions, and you get why @Transactional exists at all.
Spring’s value here is orchestration plus cross-cutting AOP magic. Postgres’s value is actually committing. Hibernate sits between them, batching and flushing your writes and translating Java method calls into SQL. They each do exactly one thing, and the annotation hides the cooperation.
9. Postscript — should I use this pattern everywhere a unique constraint guards an insert?
I thought so. Then I recommended it on a colleague’s PR, went digging to back myself up, and the codebase talked me halfway out of it. The rest of this article is that correction, in the order it actually clicked — because the boundaries of a pattern matter as much as the pattern.
The setup: a plain create endpoint at a B2B procurement SaaS I worked with. The table has a surrogate UUID @Id plus a three-column unique constraint doing the actual dedup work. My colleague had written the classic pre-check:
if (repository.existsByTenantIdAndExternalIdAndTarget(tenantId, externalId, target)) {
throw new FieldConflictException(externalId, target);
}
repository.save(field);
My review comment was, roughly, “use saveAndFlush + REQUIRES_NEW like the pattern above.” Before the verdict on that comment can make sense (section 11), three fundamentals have to be on the table.
First: the violation fires on any unique index, not just the primary key. Easy to assume, and I refuse to put assumptions in a review comment — so I verified it. Postgres raises a duplicate-key violation (SQLSTATE 23505) for whichever unique index the insert trips — and with a generated surrogate @Id, the PK never collides on insert (section 5 explains why: the UUID is minted fresh in your JVM every time). The real guard is always that separate multi-column constraint. Everything in this article applies to it exactly the same way.
Second: Spring Data repository methods are each transactional on their own. This is the fact that surprises almost everyone the first time. existsBy... and save both carry their own transaction by default. So inside a create() method that has no @Transactional:
existsBy...opens a transaction, runs its SELECT, commits. Done.saveopens a separate transaction, runs its INSERT, commits. Done.
Two independent transactions, back to back. Nothing ties them into a single unit. Add @Transactional to create() and they join one transaction — now they commit or roll back together. That’s atomicity.
Third — and this is the natural wrong assumption — making them atomic does not fix the race. Three different concepts collide in this one method, and they’re easy to blur together:
| Concept | Question it answers | Role in this bug |
|---|---|---|
| Atomicity | do these operations take effect as one all-or-nothing unit? | groups the SELECT + INSERT — but doesn’t stop a neighbour sneaking in between them |
| Isolation | how much can concurrent transactions see and interfere with each other? | the actual villain — the default READ COMMITTED happily lets two transactions both pass the same existsBy check |
| Uniqueness | may two rows share these column values? | the constraint — the only thing that actually serializes the two inserts, at the index |
The classic atomicity example is the money transfer: debit account A, credit account B. Atomic means you can never end up debited-but-not-credited — if the credit fails, the debit is undone. Notice that says nothing about another transfer reading the same balance concurrently. That’s isolation’s job — and isolation is where the real fight happens. Next section, in slow motion.
10. The race, in slow motion — and why cranking isolation isn’t the fix
Two identical requests arrive nearly simultaneously — a double-click, a client retry. Under READ COMMITTED, Postgres’s default:
- Request A:
existsBy...→false(no row yet). - Request B:
existsBy...→false(still no row — A hasn’t inserted). - Request A:
INSERT→ succeeds. - Request B:
INSERT→ the unique index rejects it → duplicate-key error → Spring wraps it asDataIntegrityViolationException.
This shape has a name — check-then-act — and no amount of wrapping it in a transaction removes the gap between step 2 and step 4. The pre-check is fine as UX (a friendlier error on the common path); it is never the guarantee.
One mechanical detail worth knowing before you ever watch this under load: the loser doesn’t fail instantly. Postgres makes the slower INSERT block, waiting on the index entry the faster transaction is holding. Only when the faster one commits does the slower one wake up — and then get the duplicate-key error. “Waits, then fails”, not “both hit the wall at once”. Same outcome for your code; just don’t let the pause surprise you in a trace.
(And one phrasing trap: it’s never the exists-check that throws — it only returns true or false. The INSERT throws, when the constraint fires. You don’t make anything throw the violation; you let it happen and catch it.)
The isolation dial
Isolation is the “I” in ACID — how much concurrent transactions are shielded from each other’s in-progress work. It’s a dial with four positions, and it’s worth seeing exactly why the first three don’t save you:
| Level | What you see | Closes the check-then-act gap? |
|---|---|---|
READ UNCOMMITTED | others’ uncommitted changes (“dirty reads”) — Postgres doesn’t actually implement this; it silently treats it as READ COMMITTED | no |
READ COMMITTED (Postgres default) | each statement sees only what was committed before it started | no — a neighbour can commit its INSERT between your SELECT and your INSERT; your SELECT already ran |
REPEATABLE READ (Postgres: snapshot isolation) | your whole transaction sees one frozen snapshot from its start | no — both transactions snapshot “row absent”, both insert, the constraint catches the loser anyway |
SERIALIZABLE | outcome guaranteed as if transactions ran one at a time (Postgres aborts one of two conflicting transactions with a serialization error) | yes — but now every caller needs retry-on-serialization-failure logic |
So the gap can technically be closed by cranking the dial to SERIALIZABLE. Nobody does it for this problem, because the cheap, correct tool is already sitting there: the unique index is itself a serialization point. Postgres guarantees that only one of two concurrent inserts of the same key survives — at any isolation level. You don’t raise isolation; you let the constraint be the guard and catch the loser.
The tools that actually fit “insert if not present”
Once you name the problem precisely, the toolbox sorts itself:
| Tool | Fit |
|---|---|
| Unique constraint + catch the violation | the one to use — race-safe at any isolation level, minimal machinery |
Upsert — INSERT ... ON CONFLICT DO NOTHING | atomic, no exception at all, no race. Cleanest in raw SQL; clumsier to express through JPA |
Pessimistic lock — SELECT ... FOR UPDATE | can’t lock a row that doesn’t exist yet, so “insert if absent” needs an advisory or parent-row lock. Heavy, overkill here |
SERIALIZABLE + retry | correct but the sledgehammer |
| ShedLock | wrong problem entirely — it’s a distributed lock for scheduled jobs (“only one node runs this cron”), operating at job level, not row level. It can’t referee two HTTP requests racing into the same table |
That’s the whole philosophy behind catch-and-translate: don’t try to slam the concurrency window shut in application code. Assume no conflict, try the insert, let the constraint referee the rare collision, and translate the loser’s complaint into a clean 409. Optimistic by design.
11. The verdict on my review comment — where REQUIRES_NEW actually earns its place
With those fundamentals down, the correction writes itself.
REQUIRES_NEW is only load-bearing when there’s an outer transaction to protect. The whole job of REQUIRES_NEW (section 7) is to give the violation a separate transaction to die in, so the outer one doesn’t get marked rollback-only. My colleague’s endpoint had no @Transactional anywhere on the call path — just the two self-contained repository transactions from section 9. No outer transaction → no rollback-only poison to isolate → REQUIRES_NEW buys nothing. Worse, it costs: a second pooled connection per call, and independent-commit semantics — the inner transaction commits even if the request fails a line later, which is exactly wrong for an endpoint that should be atomic with its request. Copying the pattern there would have been cargo-culting my own blog post.
The catch, wherever it lives, must be narrow. If you translate any DataIntegrityViolationException into “already exists”, a foreign-key or not-null violation gets mislabeled as a duplicate and you’ll debug a ghost. Check the violated constraint’s name before translating; everything else should stay a loud 500.
And the codebase itself had already voted. A grep told the story better than any argument: REQUIRES_NEW appeared 107 times — every single one in listeners, schedulers, and seeders. Batch and async side-effects running inside longer transactions. Exactly the shape of the Spring Batch method this article started from, and not one plain create endpoint. The established pattern for endpoints was simpler: catch the violation at the facade, translate it to a domain conflict exception, map that to HTTP 409. No pre-check needed for correctness, no REQUIRES_NEW.
The pattern from section 7 is still the right tool. It’s just a tool for a specific failure mode — the poisoned outer transaction — and reaching for it when that failure mode can’t occur adds cost without adding safety.
12. The robust endpoint shape — and why saveAndFlush still belongs in it
One refinement left, and it comes from the sharpest follow-up of that whole review thread: if the endpoint isn’t transactional, doesn’t plain save flush anyway? Why bother with saveAndFlush?
It does flush — that’s the trap. When create() isn’t transactional, save() runs in its own transaction (section 9) and commits when it returns — and commit forces a flush. The INSERT hits the database, the constraint is checked, and the violation surfaces synchronously, right inside your try. So in that code as it stands, save and saveAndFlush give you identical early feedback. Functionally, saveAndFlush adds nothing today.
Why it’s still the smarter default: the catch only works if the flush happens inside your try — and plain save only guarantees that while there’s no outer transaction. Break that assumption and plain save fails silently:
- someone adds
@Transactionaltocreate()next quarter, or - a caller that is transactional starts invoking it.
Now save just stages the entity in the persistence context (section 1). The actual INSERT defers to the outer transaction’s commit — which happens after your method has returned, outside your try. The catch never fires. The violation escapes as a generic 500 — and nothing in the code looks wrong. An invisible regression, with no diff on the lines that broke.
saveAndFlush forces the INSERT at the call site regardless of what’s around it. It decouples “will my conflict-catch actually fire?” from “does this path happen to be transactional?” — and it reads as intent: this write must hit the database here, because I’m about to handle its failure.
That flush-timing guarantee — not
REQUIRES_NEW— is the real reasonsaveAndFlushexists.
The tradeoff that keeps it from being sprinkled everywhere: saveAndFlush flushes the entire persistence context, not just your one entity. In a small create path, nothing else is pending — non-issue. In a fat @Transactional method with several staged writes, forcing an early flush pushes all of them out ahead of schedule, which occasionally matters for write ordering. Cheap insurance where you’re about to catch a violation; noise anywhere else.
So the robust endpoint shape, spelled out: existsBy (friendly message on the common path) → saveAndFlush → catch(DataIntegrityViolationException), narrowed to the dedup constraint → translate to a domain conflict → 409.
And with every piece on the table, the honest decision table for the whole article:
| Your insert runs… | The guard you need |
|---|---|
| with no outer transaction (typical web request) | saveAndFlush + a narrow catch(DataIntegrityViolationException) → translate to a domain conflict. Skip REQUIRES_NEW. |
| inside a longer transaction (batch step, event listener, seeder) | this article’s pattern: REQUIRES_NEW + saveAndFlush + catch in the caller |
behind an existsBy pre-check only | you have a race — keep the pre-check for the friendly message if you like, but the constraint + catch is the real guard |
Pulling it all back together
Eight lines, two annotations, one method call:
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void addAttachmentInNewTransaction(EmailInbox inbox,
EmailAttachment attachment) {
attachment.setEmailInbox(inbox);
attachmentRepository.saveAndFlush(attachment);
}
What’s actually happening:
REQUIRES_NEW— suspend the caller’s transaction; run this method in a brand-new one. If anything goes wrong in here, the rollback is local to this transaction, and the caller’s transaction is untouched.saveAndFlush— don’t wait for the natural flush at commit time. Send theINSERTnow, force the database to evaluate the unique constraint now, and let anyDataIntegrityViolationExceptionsurface on this line, where the caller can catch it.- The caller’s
try/catch(DataIntegrityViolationException)— treat the duplicate as a routine outcome, not an error. Log it, move on. The outer transaction is clean because the violation died in a different transaction.
Three small things, each doing exactly one job, building one of the cleanest idempotency patterns the Spring + Hibernate + Postgres stack gives you. Worth committing to memory — every backend that uses a unique constraint as an idempotency guard inside a shared transaction ends up needing exactly this combination. (And when there’s no shared transaction, sections 9–12 are the honest answer: saveAndFlush plus a narrow catch-and-translate does the job with less machinery.)
The thing I wish someone had told me earlier is that the confusion isn’t really about the annotations. It’s about not having a clear picture of the four distinct moments a write goes through — save (in-memory), flush (SQL sent, constraints checked, still rollback-able), commit (durable and visible), and the AOP boundary around all of it that decides commit vs rollback in the first place. Once those four moments are separate in your head, everything REQUIRES_NEW and saveAndFlush do becomes obvious — they’re each just turning a dial on when one of those moments happens.



