The 30 second answer
The database work rolls back, but the file stays, because the filesystem is not a transactional resource. You end up inconsistent. Default rollback is only for unchecked exceptions and Error; a checked exception commits unless you set rollbackFor. Do the side effect after commit with a transactional event listener, write a temp file and move it on commit, compensate on rollback, or use an outbox for external effects.
The DB work rolls back; the file stays — the filesystem isn't a transactional resource, so you get inconsistency. Also, default rollback is only for unchecked exceptions/Error; a checked exception commits unless rollbackFor. Fixes: do the side effect after commit (@TransactionalEventListener(phase = AFTER_COMMIT) or TransactionSynchronization.afterCommit), write to a temp file and move on commit, compensate on rollback, or outbox pattern for external effects. Remember after-commit can still fail → need retry/idempotency.
Editor's note: after-commit work can still fail — you still need retry and idempotency for anything that talks to the outside world.
Sources: