One of the most common distributed-systems mistakes in Java shops is not a messaging choice or a database choice. It is taking the mental model of @Transactional from a monolith and quietly carrying it into a system that no longer has a single transaction boundary.
Inside a monolith, @Transactional is a useful abstraction. You call a service method, several database writes happen, an exception bubbles up, and the framework rolls everything back. The code looks linear because, locally, it is. That model trains engineers to believe that “the operation” is one thing.
In a distributed system, it is not one thing anymore.
The moment a request spans a database write, a message publish, a cache invalidation, another service call, or an external API, the neat transactional story is gone. What often remains is the shape of the code: one service method that still looks authoritative, still looks atomic, and still tempts everyone into believing the platform will clean up if step four fails after step three already committed.
That is the anti-pattern: writing distributed workflows as if local transaction semantics still apply.
Java makes this mistake easy because the tooling is so good.
Spring has taught multiple generations of engineers that boundary management can be declarative. A transaction can be an annotation. A retry can be an annotation. A circuit breaker can be an annotation. Dependency injection can make the orchestration layer look cleaner than it really is.
None of that is bad on its own. The problem starts when clean code shape is mistaken for strong system guarantees.
This is how the failure usually appears:
OrderService.placeOrder().placeOrder() inserts an order row.PaymentClient.authorize().At that point, teams discover that only one of those steps was covered by the database transaction. The rest already happened, or maybe happened, or happened but the caller did not see the acknowledgment.
What looked like one operation was actually a distributed workflow with partial failure modes at every boundary.
The subtle bug is not when everything is obviously async. It is when half the workflow is synchronous and half is not.
For example:
@Transactional
public void createSubscription(CreateSubscriptionCommand cmd) {
Subscription subscription = subscriptionRepository.save(...);
billingClient.createCustomer(...);
eventPublisher.publishEvent(new SubscriptionCreated(subscription.getId()));
}
This looks disciplined. It is not.
If billingClient.createCustomer(...) succeeds and the database transaction later rolls back, your billing system and your source of truth are now inconsistent. If the DB commit succeeds and the event publish fails, downstream consumers never hear about the subscription. If the HTTP call times out, you may not even know whether billing actually created the customer.
The transaction annotation does not help because the system boundary is larger than the database boundary.
The replacement is not “give up on consistency.” It is to model the consistency you can actually have.
A few rules go a long way.
Use the database transaction only for the state that truly lives in that database. Commit that state cleanly. Do not pretend the transaction covers the network.
If the workflow needs to trigger side effects, record them as data inside the same local transaction. That is the outbox pattern in its simplest form: commit the business state and the “work to be published” together, then publish from a separate reliable process.
The important shift is conceptual. The database transaction stops being “the whole operation” and becomes “the durable record of the next state and the work that must follow from it.”
Distributed systems do not usually roll back. They compensate.
If payment authorization succeeded but inventory reservation failed, the system needs a compensating action: release the payment hold, mark the order as failed, emit the right event, and surface a state that operators can reason about.
That means your domain model needs intermediate states. Engineers often resist this because it makes the model feel messier:
PENDING_PAYMENTPAYMENT_AUTHORIZEDINVENTORY_RESERVEDFAILEDCANCELLEDBut those states are not noise. They are the truth of what the distributed workflow is actually doing.
The clean-looking alternative - one big method named placeOrder() that either “works” or “throws” - is only cleaner in source code. It is much less honest operationally.
Most teams discover idempotency only after a retry storm.
If a payment call times out, you will retry it. If a message is delivered twice, you will process it twice unless you defend against it. If a job crashes after step two and restarts, you need to know whether step two should run again.
In Java systems, this often means:
The easiest distributed workflow to operate is the one where every step can be replayed without panic.
A common Java anti-pattern is orchestration by nested service calls:
Controller -> OrderService -> PaymentService -> InventoryService -> NotificationService
At each level, more side effects happen. Nobody owns the whole saga explicitly. Failures bubble up as exceptions, but the actual business workflow is spread across five classes and two transport protocols.
An explicit workflow engine is not always required, but an explicit workflow model usually is. Whether you implement it with a queue, a state machine table, scheduled retries, or a proper orchestration platform, the workflow should be visible as a workflow - not hidden inside a stack trace.
The real test of a distributed design is not the happy path. It is whether an operator can answer these questions during an incident:
If the answer to those questions is “look through logs and infer it,” the design is not finished.
Java teams often invest heavily in abstraction and not enough in recoverability. But a system that is elegant in code and opaque in failure is not well-designed. It is just well-hidden.
The reason this anti-pattern persists is that it feels professional. The code is layered. The services are separated. The transaction boundary is annotated. The exceptions are typed.
But distributed-systems correctness does not come from polish at the method level. It comes from being explicit about partial failure, retries, idempotency, compensation, and durable workflow state.
@Transactional is still valuable. It is just smaller than many teams want it to be.
That is the real lesson: keep the local transaction, but stop asking it to solve a distributed problem.