When a single workflow in your app starts touching dozens of services, it is very easy to lose control of the code.
Think of an airline booking. To create one, you first have to validate the request. Then you charge the customer, save the booking to the database, create the ticket PDF, tell the baggage system about it, send an email or SMS to the customer, and maybe add loyalty points.
At the start it looks easy to call all of this in one place. But over time it turns into this:
BookingService
├── BookingCheckPipeline
├── PaymentGateway
├── BookingRepository
├── TicketPdfGenerator
├── BaggageSystemClient
├── NotificationService
└── LoyaltyPointsService
The problem is not that these services exist. The problem is that every caller now has to know this whole orchestration.
This is where the Facade Pattern helps. The idea is simple:
Do not remove the complex subsystems. Put them behind one door.
Without a Facade: the controller knows everything
First, let’s look at it without a Facade:
@RestController
public class BookingController {
private final BookingCheckPipeline pipeline;
private final BookingRepository bookingRepository;
private final PaymentGateway paymentGateway;
private final TicketPdfGenerator ticketPdfGenerator;
private final BaggageSystemClient baggageSystemClient;
private final NotificationService notificationService;
private final LoyaltyPointsService loyaltyPointsService;
@PostMapping("/bookings")
public BookingResponse book(@RequestBody BookingRequest request) {
BookingContext ctx = pipeline.run(request);
PaymentResult payment = paymentGateway.charge(
request.customer().paymentMethod(), ctx.getTotalAmount());
if (!payment.isSuccessful()) {
throw new PaymentFailedException(payment.errorMessage());
}
Booking booking = bookingRepository.save(Booking.from(ctx, payment));
byte[] ticketPdf = ticketPdfGenerator.generate(booking);
baggageSystemClient.registerPassengers(booking);
notificationService.sendConfirmation(booking, ticketPdf);
loyaltyPointsService.addPoints(booking.getCustomerId(), booking.getTotalAmount());
return BookingResponse.from(booking, ticketPdf);
}
}
This code works. In a small project it can even work well for a while. But look at what the controller is responsible for, and you can see something going wrong.
The controller no longer deals only with HTTP. It also:
- runs the validation pipeline,
- charges the customer,
- saves the booking,
- creates the PDF,
- tells the baggage system,
- sends the notification,
- adds loyalty points.
So the controller now knows in which order and how the subsystems must be called. That is a heavy dependency.
The real problem: what if we need the same flow somewhere else?
Say tomorrow you add a gRPC endpoint for the mobile app. Or you write a CLI for the operators in the call centre. The booking flow has to stay the same:
Validate
↓
Charge
↓
Save the booking
↓
Create the ticket
↓
Tell the baggage system
↓
Send the notification
↓
Add loyalty points
What do you do then? You can write the same 20-30 lines of orchestration again somewhere else. But now you manage the same business flow in two places. It becomes very easy to change the payment step in one place and forget the other.
This is exactly the problem the Facade solves.
With a Facade: one door
Instead of letting the controller know every subsystem, we put a BookingFacade in between:
graph TD
C[BookingController] --> F{{BookingFacade}}
CLI[CallCenterBookingCli] --> F
F --> P[BookingCheckPipeline]
F --> PG[PaymentGateway]
F --> R[(BookingRepository)]
F --> T[TicketPdfGenerator]
F --> B[BaggageSystemClient]
F --> N[NotificationService]
F --> L[LoyaltyPointsService]The controller now only knows the Facade. The whole orchestration stays behind it.
You can see the difference most clearly like this:
// The controller carries seven dependencies and knows the order itself
BookingContext ctx = pipeline.run(request);
PaymentResult payment = paymentGateway.charge(...);
Booking booking = bookingRepository.save(...);
byte[] pdf = ticketPdfGenerator.generate(booking);
baggageSystemClient.registerPassengers(booking);
notificationService.sendConfirmation(booking, pdf);
loyaltyPointsService.addPoints(...);
// The controller carries one dependency and knows no order at all
CompletedBooking result = bookingFacade.completeBooking(request);
1. The Facade class — the single owner of the orchestration
@Service
public class BookingFacade {
private final BookingCheckPipeline pipeline;
private final BookingRepository bookingRepository;
private final PaymentGateway paymentGateway;
private final TicketPdfGenerator ticketPdfGenerator;
private final BaggageSystemClient baggageSystemClient;
private final NotificationService notificationService;
private final LoyaltyPointsService loyaltyPointsService;
public BookingFacade(BookingCheckPipeline pipeline,
BookingRepository bookingRepository,
PaymentGateway paymentGateway,
TicketPdfGenerator ticketPdfGenerator,
BaggageSystemClient baggageSystemClient,
NotificationService notificationService,
LoyaltyPointsService loyaltyPointsService) {
this.pipeline = pipeline;
this.bookingRepository = bookingRepository;
this.paymentGateway = paymentGateway;
this.ticketPdfGenerator = ticketPdfGenerator;
this.baggageSystemClient = baggageSystemClient;
this.notificationService = notificationService;
this.loyaltyPointsService = loyaltyPointsService;
}
@Transactional
public CompletedBooking completeBooking(BookingRequest request) {
// 1. Validation chain
BookingContext ctx = pipeline.run(request);
// 2. Payment
PaymentResult payment = paymentGateway.charge(
request.customer().paymentMethod(), ctx.getTotalAmount());
if (!payment.isSuccessful()) {
throw new PaymentFailedException(payment.errorMessage());
}
// 3. Save
Booking booking = bookingRepository.save(Booking.from(ctx, payment));
// 4. Side systems — error handling belongs here
byte[] ticketPdf = ticketPdfGenerator.generate(booking);
baggageSystemClient.registerPassengers(booking);
notificationService.sendConfirmation(booking, ticketPdf);
loyaltyPointsService.addPoints(booking.getCustomerId(), booking.getTotalAmount());
return new CompletedBooking(booking, ticketPdf);
}
}
The important point: the Facade does not remove the business logic. It collects the coordination of the workflow in one place.
When completeBooking runs, this is what happens, in order:
It runs the validation chain
Does the flight exist, is the seat free, are the passenger details valid — all of that stays inside the pipeline.
It charges the customer
If the payment fails, the flow stops here. No booking is saved without a payment.
It saves the booking
Now we have a
Bookingthat really exists.It triggers the side systems
Ticket, baggage, notification and loyalty points. Only the Facade knows the order of these.
So if a booking must never be saved before the payment succeeds, only BookingFacade knows that rule now. The other layers that use this flow do not have to know it.
That @Transactional is not as harmless as it looks
The whole method runs inside one transaction. But it contains network calls to the payment provider, the baggage system and the notification service. That creates two problems:
- A long transaction. The database connection is held from the pool until the external services answer. Under load, this is what drains your connection pool.
- Side effects you cannot roll back. If adding loyalty points fails, the transaction rolls back and the booking disappears — but the money is still charged, and the email is already sent.
A transaction should only cover things that can really be rolled back: the database write. The rest should run after the commit.
How do you move side effects out of the transaction?
The easiest way is to split the Facade in two: a short part that is really transactional, and side effects that run after the commit.
public CompletedBooking completeBooking(BookingRequest request) {
BookingContext ctx = pipeline.run(request);
PaymentResult payment = paymentGateway.charge(
request.customer().paymentMethod(), ctx.getTotalAmount());
if (!payment.isSuccessful()) {
throw new PaymentFailedException(payment.errorMessage());
}
// Only this part is transactional
Booking booking = bookingWriter.save(ctx, payment);
// After the commit: ticket, baggage, notification, loyalty
events.publishEvent(new BookingCompleted(booking.getId()));
return new CompletedBooking(booking, ticketPdfGenerator.generate(booking));
}
And on the listener side:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onBookingCompleted(BookingCompleted event) { ... }
Now the notification only goes out if the booking was really saved. And a side system that is temporarily down does not cancel the booking. If you need stronger guarantees, the next step is the outbox pattern.
One note: the Facade still charges the customer outside the transaction. If the save fails after the payment, you need a refund to make up for it. This is part of the nature of distributed work. The Facade does not solve it — it only makes clear where it has to be solved.
2. The controller now only deals with HTTP
The new controller is quite simple:
@RestController
public class BookingController {
private final BookingFacade bookingFacade;
public BookingController(BookingFacade bookingFacade) {
this.bookingFacade = bookingFacade;
}
@PostMapping("/bookings")
public BookingResponse book(@RequestBody BookingRequest request) {
CompletedBooking result = bookingFacade.completeBooking(request);
return BookingResponse.from(result.booking(), result.ticketPdf());
}
}
The controller no longer knows:
- whether a
PaymentGatewayexists, - how the ticket is created,
- when the baggage system is told,
- how the notification is sent,
- when loyalty points are added.
The only thing it knows is:
bookingFacade.completeBooking(request);
It takes an HTTP request, calls the Facade, and turns the result into an HTTP response. That is all.
3. Using the same Facade from another entry point
This is where the real value shows up. Say tomorrow we write a CLI for the call centre:
@Component
public class CallCenterBookingCli implements CommandLineRunner {
private final BookingFacade bookingFacade;
@Override
public void run(String... args) {
BookingRequest request = readFromOperatorInput();
bookingFacade.completeBooking(request);
}
}
We copied no business logic here. Both entry points use the same flow:
REST API
REST Controller
│
▼
BookingFacade
│
├── Validation
├── Payment
├── Booking
├── Ticket
├── Baggage
├── Notification
└── Loyalty
Call centre
Call Center CLI
│
▼
BookingFacade
│
├── Validation
├── Payment
├── Booking
├── Ticket
├── Baggage
├── Notification
└── Loyalty
Two entry points, one business flow.
What does the Facade really solve?
It is important not to misread what the Facade does. It does not remove the complexity. PaymentGateway, BaggageSystemClient and NotificationService are all still there.
It only moves that complexity behind one door. For the caller outside, there is only:
bookingFacade.completeBooking(request);
I think this is the best way to describe a Facade:
It does not remove complexity. It sets a border around where the complexity lives.
Testing gets easier too
To test the controller, we no longer have to deal with six different dependencies. We can mock the Facade only:
@Test
void returnsTicketOnSuccessfulBooking() {
when(bookingFacade.completeBooking(any()))
.thenReturn(sampleCompletedBooking());
var response = controller.book(sampleRequest());
assertThat(response.ticketNumber()).isNotNull();
}
The point of a controller test is not to test the payment gateway or the baggage system anyway. The job of the controller is:
HTTP Request
↓
Facade
↓
HTTP Response
That border is much clearer now. The Facade itself is tested separately. There you can check that payment, booking, ticket, baggage and notification run in the right order.
In a Facade test, InOrder is more useful than plain verify. In this flow what matters is not only that the steps were called, but the order:
InOrder inOrder = inOrder(paymentGateway, bookingRepository, notificationService);
inOrder.verify(paymentGateway).charge(any(), any());
inOrder.verify(bookingRepository).save(any());
inOrder.verify(notificationService).sendConfirmation(any(), any());
The test fails as soon as somebody moves the notification before the payment.
Changes have a smaller blast radius
Say the baggage system changes tomorrow. Instead of:
BaggageSystemClient
we will use:
NewBaggageProvider
The dependency inside the Facade changes. The controller never even hears about it.
The same is true when you move to a new notification provider: the REST endpoint and the call centre CLI do not have to change. That is another important benefit of the Facade: keeping the effect of a change in one place.
How it relates to Chain of Responsibility
There is an important difference between BookingCheckPipeline and the Facade. The validation chain is a classic Chain of Responsibility: each link looks at the request, applies its own rule, and passes it on.
The Facade does not replace the pipeline. The pipeline still does its own job:
BookingCheckPipeline
↓
Validations
The Facade manages the bigger workflow:
BookingFacade
│
├── BookingCheckPipeline
├── Payment
├── Booking
├── Ticket
├── Baggage
├── Notification
└── Loyalty
In other words: the pipeline does one job, the Facade puts jobs in order.
| Pattern | The question it answers |
|---|---|
| Chain of Responsibility | “Is this request valid?” |
| Facade | “Which steps do I run, and in which order, to complete this booking?” |
The two patterns solve different problems, and they work well together.
When is a Facade useful?
You do not need a Facade in front of every service. But it is worth thinking about when you see these signs:
- A controller or service starts taking too many dependencies.
- More than one entry point uses the same subsystems.
- The same orchestration code repeats in different places.
- More than one class knows in which order an operation must run.
- Upper layers start to know the details of low-level services.
- One use case slowly turns into dozens of service calls.
These usually mean “there could be a Facade here”.
The Facade has its own trap
A Facade does its job as long as it coordinates one use case. Once you push booking, cancellation, changes, check-in and refunds all into the same class, you are left with a god object — a bigger version of the thing you were running from.
The rule is simple: one Facade, one workflow. Splitting BookingFacade into CreateBookingFacade, CancelBookingFacade and CheckInFacade is almost always healthier.
Where do we see this in real life?
The Facade Pattern is far more common than you think.
In payment systems, a single pay() or charge() call can hide dozens of details: authentication, the HTTP request, retries, serialization, error handling and response mapping.
The AWS SDKs give you the same idea. You call a high-level API and never deal with the low-level HTTP and protocol details behind it.
On the microservice side, a BFF (Backend for Frontend) layer can play a similar role. It calls several services to collect the data a frontend needs, and gives the frontend a simpler API on top of them.
A BFF is not exactly a Facade, but it carries the same core idea:
Show a complex system to the outside with a simpler interface.
The rule underneath
The simplest rule to remember for the Facade Pattern:
If there are many systems behind a class, you do not have to show all of them to the outside.
The controller does not need to know six different systems:
PaymentGateway
BookingRepository
TicketPdfGenerator
BaggageSystemClient
NotificationService
LoyaltyPointsService
Instead, you can say:
Controller
↓
BookingFacade
↓
Complex subsystems
A Facade does not magically remove complexity. It just makes the complexity live in one place.
And most of the time that is exactly the goal of good design: not to make the system simple, but to make the borders of the complexity clear.

Comments