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:

  1. 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.

  2. It charges the customer

    If the payment fails, the flow stops here. No booking is saved without a payment.

  3. It saves the booking

    Now we have a Booking that really exists.

  4. 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 PaymentGateway exists,
  • 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.

PatternThe 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.