@Transactional protects one database. The moment you also publish to Kafka, you have two writes and no shared commit. Outbox, inbox, and saga code in Spring Boot 4.
The recruiter moved the candidate to Interview. The row was committed. The event never left the service. Every log line said success. The board showed the card in the Interview column. The candidate notification service, the interview scheduler, and the hiring analytics pipeline had no idea anything happened, because the message that should have told them was lost between one line of code and the next. No invite went out. The candidate waited, and then accepted another offer. @Transactional did exactly what it promised. It was never asked to protect the second write. I have seen this bug in applicant tracking systems, document pipelines, and scheduling systems. It always looks the same in the postmortem: a method annotated with @Transactional , a repository save, and a call to a message broker inside the same method. The team believed the annotation covered the whole thing. It covered half. One business fact, two systems, no shared commit A dual write happens when a single business fact has to land in two systems that do not share a transaction. In the ATS case, the fact is “this application moved to Interview.” It has to exist in two places: the application service’s own database, so the next request sees the new stage the message broker, so every other service can react Those are two independent writes. Each can succeed or fail on its own. There is no moment where both become true together. That is the whole problem. Everything else in this article is a way to stop pretending there is a shared commit. Why @Transactional cannot see the broker Here is the method I keep finding. Spring Boot 4, Java 25. Imports are omitted in every sample. the dual write I keep finding @Service public class ApplicationStageService { private final JobApplicationRepository applications; private final KafkaTemplate<String, ApplicationStageChanged> kafka; private final Clock clock; // constructor injection omitted @Transactional public void moveToStage(UUID applicationId, ApplicationStage target) { JobApplication application = applications.findById(applicationId) .orElseThrow(() -> new ApplicationNotFoundException(applicationId)); ApplicationStageChanged event = application.moveTo(target, clock); // write 1: Postgres, at commit kafka.send("ats.application-stage", applicationId.toString(), event); // write 2: Kafka, right now } } @Transactional is a promise about one resource. It opens a transaction on a database connection, runs your method, and commits or rolls back that connection. The broker is not on that connection. The send goes over a different client, to a different system, with its own acknowledgment. Read the comments again: JPA flushes the changed entity when the transaction commits, after the method returns. The Kafka send has already happened by then. If two recruiters drag the same card at the same moment, the optimistic lock fails the second commit, and Kafka still holds an event for a stage change that never happened. Kafka has transactions too, and they are useful. They make a read-process-write cycle inside Kafka atomic. They do not make a Kafka send and a database commit atomic. Two transaction managers in the same method are still two transactions. Spring will happily run both and let them disagree. This is why the annotation feels safe in code review. It sits right there on the method. It reads like a guarantee for everything inside. It is a guarantee for one thing inside. @Transactional is a promise about one resource. A dual write is a promise about two. The four ways it breaks Every dual-write bug I have debugged fits one of four windows. Window What happens What the business sees Commit, then lose the event The database commits; the broker is down, slow, or the send times out The card says Interview, but no invite goes out and the scheduler never hears. Silent and permanent Publish, then roll back The event is sent first; a lock conflict or constraint fails the commit The candidate gets an invite for a stage change that does not exist. A ghost event Crash in between The process dies after one write and before the other Either of the two above, depending on order, with no error logged at all Retry that duplicates A timeout looks like a failure, the code retries, and both attempts actually landed The candidate gets two invites; the analytics count the move twice Reordering the two calls does not fix this. It only picks which failure you prefer. Sending after the commit gives you lost events. Sending before the commit gives you ghost events. Wrapping the send in a try/catch and logging it gives you lost events with a nicer log line. The crash window is the one that convinces teams. You cannot catch a process that is already dead. Why I do not reach for distributed transactions There is a textbook answer: two-phase commit. XA coordinates several resources so they all commit or all roll back. It comes up in almost every design review on this topic, and I steer teams away from it for a microservices platform. Not because it does not work. Because of what it costs: every participant must support it, and most modern brokers and cloud databases either do not or make it painful a coordinator becomes a component that can hold locks while it waits, and a slow participant slows everyone recovery after a coordinator failure is a runbook, not a feature it couples services at the exact point microservices were supposed to decouple them If you split a system to let teams deploy independently, and then tie their commits together with a coordinator, you rebuilt the monolith with a network in the middle. The patterns that work in practice share one idea instead: stop doing two writes. Do one write, and derive the second from it. The transactional outbox: make the event data The transactional outbox is the pattern I default to, and the one I would put in front of any team that publishes events from a service with its own database. The idea is small. Instead of sending the event to the broker inside the method, you insert it as a row into an outbox table in the same database, in the same transaction as the business change. outbox columns match Debezium defaults create table outbox ( id uuid primary key, aggregatetype varchar(64) not null, aggregateid varchar(64) not null, type varchar(64) not null, payload jsonb not null, created_at timestamptz not null default now(), published_at timestamptz ); create index outbox_unpublished on outbox (created_at) where published_at is null; The column names are not an accident. They match the defaults of Debezium’s outbox event router, so moving from a polling relay to change data capture later does not need a migration. The event itself is a record with a stable identifier, the stage it left, the stage it entered, and a revision number that only goes up. It carries identifiers, never the candidate’s name or email. Consumers that need contact details look them up in the service that owns them, which keeps personal data out of the outbox and out of the broker. stage change as a fact, identifiers only public record ApplicationStageChanged( UUID eventId, UUID applicationId, UUID jobId, ApplicationStage from, ApplicationStage to, long revision, Instant occurredAt) { } @Entity public class JobApplication { @Id private UUID id; private UUID jobId; @Enumerated(EnumType.STRING) private ApplicationStage stage; private long stageRevision; @Version private long version; public ApplicationStageChanged moveTo(ApplicationStage target, Clock clock) { if (!stage.canMoveTo(target)) { throw new IllegalStageTransitionException(id, stage, target); } ApplicationStage previous = stage; stage = target; stageRevision++; return new ApplicationStageChanged(...