Facade Pattern: Presenting a Complex Subsystem Through a Single Door
A facade gathers the knowledge of using several subsystems in the right order, with the right compensation steps, into one class; it gives clients an easy entry point without closing off the subsystem. A CheckoutFacade in Bookstore that coordinates stock, payment, invoicing and notification, the real code behind File.ReadAllTextAsync and GetFromJsonAsync, how Facade differs from Service Layer, Adapter and Mediator, and the god facade trap.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
Bookstore takes orders from two places: a Web API (Application Programming Interface) endpoint called by the website, and the phone-order screen the call center uses. Both use the same four subsystems: Inventory, which reserves stock; PaymentGateway, which authorizes and captures the card; InvoiceService, which issues the invoice; and CustomerNotifier, which emails the customer. The phone screen was written a year ago by copying the web endpoint. The demo payment provider declines anything above 100 EUR because of the card limit.
namespace Bookstore.Checkout;
// Web API endpoint: knows all four subsystems and the order between them
public static class OrderEndpoint
{
public static async Task<string> PlaceOrderAsync(Cart cart, Inventory inventory, PaymentGateway payments,
InvoiceService invoices, CustomerNotifier notifier, CancellationToken ct)
{
Reservation reservation = inventory.Reserve(cart.Lines);
PaymentResult payment = await payments.AuthorizeAsync(cart.CustomerId, cart.Total, ct);
if (!payment.Approved)
{
inventory.Release(reservation);
return $"declined ({payment.DeclineCode})";
}
await payments.CaptureAsync(payment.AuthorizationId!, ct);
inventory.Commit(reservation);
string invoiceNo = await invoices.CreateAsync(Guid.CreateVersion7(), cart, ct);
await notifier.SendOrderConfirmationAsync(cart.Email, invoiceNo, ct);
return $"placed ({invoiceNo})";
}
}
// Call-center screen: a copy of the same flow, one line short
public static class PhoneOrderScreen
{
public static async Task<string> PlaceOrderAsync(Cart cart, Inventory inventory, PaymentGateway payments,
InvoiceService invoices, CustomerNotifier notifier, CancellationToken ct)
{
Reservation reservation = inventory.Reserve(cart.Lines);
PaymentResult payment = await payments.AuthorizeAsync(cart.CustomerId, cart.Total, ct);
if (!payment.Approved)
return $"declined ({payment.DeclineCode})";
await payments.CaptureAsync(payment.AuthorizationId!, ct);
inventory.Commit(reservation);
string invoiceNo = await invoices.CreateAsync(Guid.CreateVersion7(), cart, ct);
await notifier.SendOrderConfirmationAsync(cart.Email, invoiceNo, ct);
return $"placed ({invoiceNo})";
}
}
There are 5 copies of Dune in stock. Two orders come in, first from the web and then by phone, each for 3 copies and 149.70 EUR; both will be declined. After both, how many copies of Dune does the stock show?
const string Dune = "978-0441013593";
Inventory inventory = new();
PaymentGateway payments = new();
InvoiceService invoices = new();
CustomerNotifier notifier = new();
Cart small = new(Guid.CreateVersion7(), "ada@example.com", [new(Dune, 2, 12.99m)]);
Cart large = new(Guid.CreateVersion7(), "alan@example.com", [new(Dune, 3, 49.90m)]);
if (args is ["naive"])
{
Console.WriteLine("web: " + await OrderEndpoint.PlaceOrderAsync(large, inventory, payments, invoices, notifier, default));
Console.WriteLine("phone: " + await PhoneOrderScreen.PlaceOrderAsync(large, inventory, payments, invoices, notifier, default));
Console.WriteLine($"Dune available: {inventory.Available(Dune)}, open reservations: {inventory.OpenReservations}");
return;
}
Two Copies of the Same Flow
Output:
inventory: reserved 3 item(s)
payments: authorize 149.70 EUR -> insufficient_funds
inventory: reservation released
web: declined (insufficient_funds)
inventory: reserved 3 item(s)
payments: authorize 149.70 EUR -> insufficient_funds
phone: declined (insufficient_funds)
Dune available: 2, open reservations: 1
Not a single book was sold, yet the stock shows 2 copies of Dune instead of 5 and there is a dangling reservation. The web endpoint calls inventory.Release when the payment is declined; the phone screen's copy doesn't have that line. The code compiles, the only path with tests is the web one, and the bug surfaces at the end-of-month count as "the warehouse has 3 books, the system says 0".
The real problem isn't the missing line. Both clients know the four subsystems, the order in which to call them and which step to undo when another step fails. That knowledge is a business rule, but one nobody owns: it is rewritten in every client. When a mobile app or a bulk order import arrives tomorrow, a third and fourth copy will be written. In terms of coupling, every client depends on four classes and their calling protocol. It is the exact opposite of the Law of Demeter (an object should only talk to its immediate friends) we covered in the "Coupling and Cohesion" post of the SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) and Design Principles series.
This post is about the pattern that gathers that knowledge into one place, Facade: the GoF definition, a CheckoutFacade in Bookstore, two facades from .NET's own library that you use every day, how Facade differs from Service Layer, Adapter and Mediator, and the moment the facade itself turns into a god object.
The GoF Definition: What a Facade Does and Doesn't Do
Facade is one of the structural patterns in the GoF (Gang of Four: Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides' 1994 book Design Patterns: Elements of Reusable Object-Oriented Software) catalog. Summarizing its intent in my own words: give a set of interfaces in a subsystem a single, unified interface; define a higher-level entry point that makes the subsystem easier to use.
The book's example is a compiler: a subsystem made of classes such as Scanner, Parser, ProgramNodeBuilder and CodeGenerator, with a single Compiler class in front of them. Most users of a compiler just want to say "compile this source"; for the few advanced users who want to use the parser on its own, the lower-level classes stay accessible. The book is deliberate about that second point: a facade doesn't prevent clients from using the subsystem classes directly. It offers the easy path without closing the hard one.
There are two roles: the Facade knows which subsystem class is responsible for which request and delegates the client's request to them; the subsystem classes do the actual work and have no knowledge of the facade. That last detail matters: the dependency runs one way; the facade knows the subsystem, the subsystem doesn't know the facade. The benefits GoF lists under "consequences" follow from it: the number of classes a client deals with drops, the coupling between client and subsystem weakens, and the subsystem can change its internal classes without breaking the client.
CheckoutFacade: Gathering the Flow in One Place
Let's move the order flow and its compensation steps into one class. The outcome is returned as a CheckoutResult hierarchy rather than as exceptions; insufficient stock and a declined card are expected business outcomes, not exceptional situations.
namespace Bookstore.Checkout;
public abstract record CheckoutResult
{
public sealed record Placed(Guid OrderId, string InvoiceNo) : CheckoutResult;
public sealed record Declined(string Reason) : CheckoutResult;
public sealed record OutOfStock(string Isbn) : CheckoutResult;
}
// Facade: keeps the order of the subsystems and the compensation steps in one place
public sealed class CheckoutFacade(
Inventory inventory,
PaymentGateway payments,
InvoiceService invoices,
CustomerNotifier notifier)
{
public async Task<CheckoutResult> PlaceOrderAsync(Cart cart, CancellationToken ct = default)
{
Reservation reservation;
try
{
reservation = inventory.Reserve(cart.Lines);
}
catch (OutOfStockException ex)
{
return new CheckoutResult.OutOfStock(ex.Isbn);
}
PaymentResult payment = await payments.AuthorizeAsync(cart.CustomerId, cart.Total, ct);
if (!payment.Approved)
{
inventory.Release(reservation);
return new CheckoutResult.Declined(payment.DeclineCode!);
}
try
{
await payments.CaptureAsync(payment.AuthorizationId!, ct);
}
catch
{
// Compensation: void the authorization, release the stock
await payments.VoidAsync(payment.AuthorizationId!, CancellationToken.None);
inventory.Release(reservation);
throw;
}
inventory.Commit(reservation);
Guid orderId = Guid.CreateVersion7();
string invoiceNo = await invoices.CreateAsync(orderId, cart, ct);
await notifier.SendOrderConfirmationAsync(cart.Email, invoiceNo, ct);
return new CheckoutResult.Placed(orderId, invoiceNo);
}
}
Three orders with the same customers: one that goes through, one that hits the card limit and one without enough stock.
CheckoutFacade checkout = new(inventory, payments, invoices, notifier);
foreach (Cart cart in (Cart[])[small, large, new(Guid.CreateVersion7(), "grace@example.com", [new("978-0547928227", 2, 15.00m)])])
{
CheckoutResult result = await checkout.PlaceOrderAsync(cart);
Console.WriteLine(result switch
{
CheckoutResult.Placed placed => $"-> placed {placed.InvoiceNo}",
CheckoutResult.Declined declined => $"-> declined: {declined.Reason}",
CheckoutResult.OutOfStock outOfStock => $"-> out of stock: {outOfStock.Isbn}",
_ => throw new UnreachableException()
});
}
Console.WriteLine($"Dune available: {inventory.Available(Dune)}, open reservations: {inventory.OpenReservations}");
inventory: reserved 2 item(s)
payments: authorize 25.98 EUR -> approved
payments: captured
invoices: INV-2026-00001 for 25.98 EUR
notifier: confirmation sent to ada@example.com
-> placed INV-2026-00001
inventory: reserved 3 item(s)
payments: authorize 149.70 EUR -> insufficient_funds
inventory: reservation released
-> declined: insufficient_funds
-> out of stock: 978-0547928227
Dune available: 3, open reservations: 0
Of 5 copies of Dune, 2 were sold and 3 remain, with no dangling reservation. The web endpoint and the phone screen are now a single line: await checkout.PlaceOrderAsync(cart, ct). Only the facade knows which subsystem is called in which order, and that if the capture fails the authorization is voided and the stock released. When the rule has to change (say, "if the invoice email can't be sent, the order still stands and the email goes to a queue"), this is the only place that changes.
Notice that the facade doesn't do any new work; stock, payment, invoicing and notification live in their own classes. The facade only takes on the coordination. A good facade's methods speak the client's language ("complete the order"), not the subsystem's ("reserve, authorize, capture, commit").
The Dependency Injection (DI) registration shows the same intent:
using Microsoft.Extensions.DependencyInjection;
namespace Bookstore.Checkout;
public static class CheckoutRegistration
{
// The subsystems stay registered too: the facade is a shortcut, not the only door
public static IServiceCollection AddCheckout(this IServiceCollection services) =>
services
.AddSingleton<Inventory>()
.AddSingleton<PaymentGateway>()
.AddSingleton<InvoiceService>()
.AddSingleton<CustomerNotifier>()
.AddScoped<CheckoutFacade>();
}
if (args is ["di"])
{
using ServiceProvider provider = new ServiceCollection().AddCheckout().BuildServiceProvider();
using IServiceScope scope = provider.CreateScope();
CheckoutResult placed = await scope.ServiceProvider.GetRequiredService<CheckoutFacade>().PlaceOrderAsync(small);
int left = scope.ServiceProvider.GetRequiredService<Inventory>().Available(Dune);
Console.WriteLine($"{placed.GetType().Name}, stock report reads Inventory directly: {left} left");
return;
}
inventory: reserved 2 item(s)
payments: authorize 25.98 EUR -> approved
payments: captured
invoices: INV-2026-00001 for 25.98 EUR
notifier: confirmation sent to ada@example.com
Placed, stock report reads Inventory directly: 3 left
The stock report screen has nothing to do with the order flow; it uses Inventory directly. This is what GoF means by offering the easy path without closing the hard one: making the subsystem classes internal and forcing everything through the facade is the shortest road to the god facade we'll discuss shortly. For lifetimes (singleton, scoped) and how the container works, see the "Dependency Injection Basics" post in the Advanced C# series.
Facades in .NET: File.ReadAllTextAsync and GetFromJsonAsync
.NET's BCL (Base Class Library) is full of facades; you use most of them without thinking of them as facades. Let's look behind two of them. The source of File.ReadAllTextAsync (github.com/dotnet/runtime, File.cs) opens a FileStream configured for asynchronous, sequential reads, puts a StreamReader on top that detects the encoding from the BOM (Byte Order Mark), rents a buffer from ArrayPool<char>.Shared, reads chunk by chunk, collects the chunks in a StringBuilder and finally returns the buffer to the pool. HttpClient.GetFromJsonAsync (System.Net.Http.Json) sends the request with HttpCompletionOption.ResponseHeadersRead, calls EnsureSuccessStatusCode and hands the body to System.Text.Json as a stream. Let's run both next to hand-written equivalents:
using System.Buffers;
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
string path = Path.Combine(Path.GetTempPath(), "catalog.csv");
await File.WriteAllTextAsync(path, "978-0441013593;Dune;12.99\n978-0547928227;The Hobbit;15.00\n");
// One line: the facade
string viaFacade = await File.ReadAllTextAsync(path);
// The same job written by hand: the subsystem behind the facade
string byHand;
using (FileStream stream = new(path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096,
FileOptions.Asynchronous | FileOptions.SequentialScan))
using (StreamReader reader = new(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true))
{
char[] buffer = ArrayPool<char>.Shared.Rent(reader.CurrentEncoding.GetMaxCharCount(4096));
try
{
StringBuilder text = new();
int read;
while ((read = await reader.ReadAsync(buffer.AsMemory())) != 0)
text.Append(buffer, 0, read);
byHand = text.ToString();
}
finally
{
ArrayPool<char>.Shared.Return(buffer);
}
}
Console.WriteLine($"same text: {viaFacade == byHand}, {viaFacade.Length} chars");
using HttpClient client = new(new CatalogStub()) { BaseAddress = new Uri("https://catalog.example/") };
// One line: the facade
Book? dune = await client.GetFromJsonAsync<Book>("books/978-0441013593");
// By hand: request, status check, stream, deserialize
using (HttpResponseMessage response = await client.GetAsync("books/978-0441013593", HttpCompletionOption.ResponseHeadersRead))
{
response.EnsureSuccessStatusCode();
await using Stream body = await response.Content.ReadAsStreamAsync();
Book? manual = await JsonSerializer.DeserializeAsync<Book>(body, JsonSerializerOptions.Web);
Console.WriteLine($"facade: {dune}");
Console.WriteLine($"manual: {manual}");
}
try
{
await client.GetFromJsonAsync<Book>("books/000");
}
catch (HttpRequestException ex)
{
Console.WriteLine($"{ex.GetType().Name}: {(int?)ex.StatusCode} {ex.Message}");
}
public sealed record Book(string Isbn, string Title, decimal Price);
// A handler that answers without touching the network
sealed class CatalogStub : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) =>
Task.FromResult(request.RequestUri!.AbsolutePath.EndsWith("978-0441013593")
? new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"isbn":"978-0441013593","title":"Dune","price":12.99}""", Encoding.UTF8, "application/json")
}
: new HttpResponseMessage(HttpStatusCode.NotFound));
}
same text: True, 58 chars
facade: Book { Isbn = 978-0441013593, Title = Dune, Price = 12.99 }
manual: Book { Isbn = 978-0441013593, Title = Dune, Price = 12.99 }
HttpRequestException: 404 Response status code does not indicate success: 404 (Not Found).
The results are the same, but your hand-written version doesn't actually do everything the facade does. Reading the source of GetFromJsonAsync shows two more things: it sets up a linked CancellationTokenSource so that client.Timeout also covers reading the body, and because it uses ResponseHeadersRead, it wraps the body in a stream limited by MaxResponseContentBufferSize. In your hand-written code the timeout only applies until the headers arrive, and you are ready to read a response of unlimited size. A facade's value isn't just the line count; it is that the knowledge of using the subsystem in the right order with the right settings lives in one place, tested.
Also notice that these facades don't close off the subsystem. If you want to read a file line by line, memory-friendly for a large file, FileStream and StreamReader are still there; if you need to look at the headers of an HTTP (Hypertext Transfer Protocol) response, GetAsync is still there. It's exactly the structure of the GoF compiler example.
Facade, Service Layer, Adapter and Mediator
These four concepts get confused often because they can sit side by side in the same code. You have to read the difference from the intent and the direction of the dependency:
| Concept | Intent | Dependency direction | In Bookstore |
|---|---|---|---|
| Facade | Present a complex subsystem through an easy-to-use entry point | The facade knows the subsystem, the subsystem doesn't know the facade | CheckoutFacade |
| Service Layer | Define the application's boundary: the operations it exposes and their transaction boundaries | Controllers and endpoints to the service, the service to the domain and infrastructure | OrderService.PlaceOrderAsync |
| Adapter | Convert an existing interface into the interface the client expects | The adapter wraps a single adaptee | Fitting the payment provider's SDK (Software Development Kit) to IPaymentGateway |
| Mediator | Centralize the conversation between objects that talk to each other | Two-way: colleagues know the mediator, the mediator knows the colleagues | Form fields on the cart screen that update each other |
Service Layer is the pattern from Martin Fowler's PoEAA (Patterns of Enterprise Application Architecture, 2002); it can behave like a facade, and Fowler himself describes building it as a set of thin facades as one of the options. The difference is scope: a Service Layer describes the application's entire outer boundary, while a facade can sit at any level, in front of a single subsystem. File.ReadAllTextAsync is a facade, but nobody calls it a service layer. The "Layered Structure: The Service Layer and the Repository Debate" post in the ASP.NET Core Web API series covers this layer's place in a Web API.
The difference from Adapter, as we saw in this series' Adapter Pattern post, is in number and intent. An adapter usually wraps a single class and changes its interface; a facade coordinates several classes and defines a new, simpler interface. The difference from Mediator is in direction: GoF notes that Mediator resembles Facade because it also abstracts the functionality of existing classes; but a mediator centralizes the conversation between colleagues, and the colleagues know the mediator and send it messages. Subsystem classes, by contrast, are unaware that a facade exists. We'll discuss Mediator, and where the MediatR library sits between the two, in the upcoming Mediator post of this series.
Common Pitfalls
The god facade. CheckoutFacade has a single method today. Six months later CancelOrder, RefundOrder, ChangeShippingAddress, GetOrderHistory and ExportInvoicesToExcel have been added and the constructor takes eleven dependencies. The facade has become the "everything about orders goes here" bucket. The symptom is simple: the methods don't share dependencies; each touches a different subset of the constructor parameters. So is the fix: split the facade by use case (CheckoutFacade, RefundFacade), each coordinating its own small set of subsystems.
Leaking subsystem types. If a facade method returns PaymentResult or a type from the provider's SDK, the client still depends on the subsystem, just one step removed. The facade's signatures should speak in the client's types; that's why CheckoutResult is a separate type in the example.
Putting a facade in front of a single class. A class that republishes one service's methods one-to-one isn't a facade, it's an extra layer. If there aren't several parts to coordinate, or the single part's interface already suits the client, you don't need a facade.
Making the facade the only door. Hiding the subsystem classes and adding every need to the facade as a method guarantees a god facade. The stock report should be able to use Inventory directly.
Assuming compensation is atomic. The facade gathers the order and the undo steps, but it isn't a database transaction. If the process crashes after the capture succeeds and while the invoice is being issued, the money is taken and no invoice exists. Distributed consistency problems like these (Outbox, Saga) are not this series' topic but that of the upcoming Software Architecture series; to keep the same order from being created twice on retry, see the "Idempotency Key" post in the Solution Patterns series.
When Not to Use It
If the client talks to a single subsystem class, a facade is unnecessary. If the flow is used in one place and there's no second client on the horizon, extracting the flow into a method is enough; that method may become CheckoutFacade later, or it may not. Also avoid adding facades "for the sake of a layer": in a codebase with a facade in front of every controller, a service in front of every facade and a manager in front of every service, changing one line of behavior means opening four files. A facade pays for itself where the same coordination knowledge repeats in at least two clients, or where the subsystem is genuinely hard to use.
A Small Experiment
★☆☆ Change CustomerNotifier to throw an exception for a specific email address. What happens with the current facade: was the money taken, was the stock committed, what does the client see? Fix the facade so the order stands and the notification failure is only logged.
★★☆ Extract an ICheckout interface for CheckoutFacade and write the web endpoint against that interface. In the endpoint's test, how do you produce a "declined" result without touching the real subsystems at all?
★★★ Imagine adding CancelOrderAsync and RefundOrderAsync to the facade. Which new dependencies come into the constructor, and which method uses which dependencies? Draw up a table and decide when splitting the facade by use case makes sense.
In the next post we move on to the least known structural pattern: Flyweight. We'll look at sharing the same publisher and category information instead of storing it millions of times in a million-row report, the intrinsic versus extrinsic state split, string.Intern, ArrayPool<T> and measuring all of it with BenchmarkDotNet.