Facade Pattern in C#: One Service for Many Subsystems
Facade pattern in C# / .NET 10: collapse six injections into one application service that fans out, without leaking the inner classes back to the caller.
Table of contents
- What problem does the Facade pattern solve in C#?
- How does the Facade structure look in .NET 10?
- When should the Facade be its own interface vs just a class?
- When does the Facade pattern misfire?
- How does Facade compare to Adapter and Mediator?
- What does a real .NET 10 example look like?
- Where should you read next in this series?
The CheckoutController started with one job and one dependency. Six
months later, its constructor takes:
public CheckoutController(
ICartRepository carts,
IInventoryService inventory,
ITaxCalculator tax,
IPaymentProcessor payment,
IShippingService shipping,
INotificationService notifications,
IOrderRepository orders)
{ /* ... */ }
Seven injections. The single Checkout() action method is sixty
lines long. It loads the cart, validates inventory, computes tax,
charges the card, books shipping, persists the order, and sends a
confirmation email. None of those steps is hard. Together they are a
maze, and the maze is the controller's problem now.
The Facade pattern is the answer. Pull the orchestration into a
single application service — let us call it ICheckoutService — and
let the controller inject one thing. Inside the service, the seven
collaborators do their work. Outside, the seven collaborators stop
existing as far as callers are concerned. This is the most quietly
useful structural pattern in .NET: most well-organised codebases use
it without naming it.
What problem does the Facade pattern solve in C#?
The pattern earns its place when a subsystem of cooperating classes has grown beyond what callers should reasonably coordinate. Three concrete shapes:
- Use-case orchestration. Checkout, signup, refund — each is a sequence of calls into different subsystems. The Facade gives each use case a name.
- Library entry point. A library exposes ten classes for advanced users but ninety percent of callers want one method. The Facade is the friendly door that hides the rooms behind it.
- Legacy refuge. A legacy system has thirty interlocking modules. New code wraps the four it actually uses in a Facade so the rest never bleed in.
What is not a Facade problem: "I want one foreign type to fit one local interface" — that is Adapter. "I want to add behaviour around an existing call" — that is Decorator. Facade is specifically about reducing the surface area the caller has to know.
How does the Facade structure look in .NET 10?
A regular class that implements an interface and accepts the inner
collaborators in its constructor. Each public method is one use case
and orchestrates two or more inner calls:
public sealed record CheckoutRequest(string CartId, string PaymentMethod);
public sealed record CheckoutResult(string OrderId, decimal TotalCharged, string ConfirmationEmail);
public interface ICheckoutService
{
Task<CheckoutResult> CheckoutAsync(CheckoutRequest req, CancellationToken ct);
}
public sealed class CheckoutService : ICheckoutService
{
private readonly ICartRepository _carts;
private readonly IInventoryService _inventory;
private readonly ITaxCalculator _tax;
private readonly IPaymentProcessor _payment;
private readonly IShippingService _shipping;
private readonly INotificationService _notifications;
private readonly IOrderRepository _orders;
private readonly ILogger<CheckoutService> _log;
public CheckoutService(/* all of the above */) { /* ... */ }
public async Task<CheckoutResult> CheckoutAsync(CheckoutRequest req, CancellationToken ct)
{
var cart = await _carts.GetAsync(req.CartId, ct);
await _inventory.ReserveAsync(cart.Items, ct);
var totals = _tax.Compute(cart);
var charge = await _payment.PayAsync(totals.GrandTotal, cart.CustomerId, req.CartId, ct);
if (charge.Status != "succeeded")
throw new CheckoutFailedException(charge.Error);
var order = await _orders.CreateAsync(cart, totals, charge.TransactionId!, ct);
await _shipping.BookAsync(order, ct);
await _notifications.SendConfirmationAsync(order, ct);
return new CheckoutResult(order.Id, totals.GrandTotal, order.Customer.Email);
}
}
The controller now reads:
[ApiController, Route("checkout")]
public sealed class CheckoutController : ControllerBase
{
private readonly ICheckoutService _checkout;
public CheckoutController(ICheckoutService checkout) => _checkout = checkout;
[HttpPost]
public async Task<ActionResult<CheckoutResult>> Post(CheckoutRequest req, CancellationToken ct)
=> Ok(await _checkout.CheckoutAsync(req, ct));
}
The structural picture:
flowchart LR
Caller[CheckoutController]
Caller --> Facade[ICheckoutService]
Facade --> Cart[ICartRepository]
Facade --> Inv[IInventoryService]
Facade --> Tax[ITaxCalculator]
Facade --> Pay[IPaymentProcessor]
Facade --> Ship[IShippingService]
Facade --> Email[INotificationService]
Facade --> Order[IOrderRepository]
One arrow from caller to facade; six arrows from facade to inner collaborators. The simplification lives at the boundary the caller sees.
When should the Facade be its own interface vs just a class?
A Facade is just a class with collaborators; whether it has its own interface is a separate decision:
- Interface is worth it when callers will mock or swap it in tests
(controllers calling
ICheckoutServiceis the canonical case), when more than one implementation is plausible (test mode that skips real charges), or when the Facade is part of a public API. - Class without an interface is fine for purely internal orchestration where the inner collaborators are already tested and the Facade is just glue. Two extra files for an interface that has no second implementation is overhead, not architecture.
In modern .NET 10, most application services have an interface because tests use it. Default to having one; drop it when you can demonstrate it has never been useful.
When does the Facade pattern misfire?
Three real-world traps:
- The Facade leaks its internals.
CheckoutAsyncreturns the rawOrderaggregate. Every consumer of the Facade now depends on the inner repository's type. Convert to a DTO (CheckoutResultabove) at the boundary; never let inner aggregates cross. - The Facade becomes a god class. Every new use case is added as
another method on
CheckoutServiceuntil it has thirty methods on the same interface. Split: one Facade per use-case family (ICheckoutService,IRefundService,ICartService). - The Facade is a one-to-one passthrough.
CheckoutService.PayAsyncjust calls_payment.PayAsyncwith the same parameters. The class earned no simplification — delete it and injectIPaymentProcessordirectly. Facades earn their existence by gathering, not by forwarding.
How does Facade compare to Adapter and Mediator?
| Pattern | Number of inner objects | Changes interface? | Adds behaviour? |
|---|---|---|---|
| Facade | Many | Yes (many → one) | No (orchestration only) |
| Adapter | One | Yes (mismatch → match) | No |
| Mediator | Many (peers, not subordinates) | Yes (peers → bus) | No |
The cleanest split: Adapter is one-to-one with shape changes; Facade is many-to-one with orchestration; Mediator is many-to-many through a hub. A Facade owns the relationships and dictates the order of calls; Mediator just brokers messages between equals.
What does a real .NET 10 example look like?
A complete shape: the same checkout, with transactional co-ordination and a DTO boundary. The Facade is the only public type; the inner classes are registered separately and stay internal to their assemblies.
// Public contract (what callers see) ------------------------------
public sealed record CheckoutRequest(string CartId, string PaymentMethod);
public sealed record CheckoutResult(string OrderId, decimal TotalCharged, string ConfirmationEmail);
public interface ICheckoutService
{
Task<CheckoutResult> CheckoutAsync(CheckoutRequest req, CancellationToken ct);
}
// Implementation (internal) ---------------------------------------
internal sealed class CheckoutService : ICheckoutService
{
private readonly ICartRepository _carts;
private readonly IInventoryService _inventory;
private readonly ITaxCalculator _tax;
private readonly IPaymentProcessor _payment;
private readonly IShippingService _shipping;
private readonly INotificationService _notifications;
private readonly IOrderRepository _orders;
private readonly IUnitOfWork _uow;
private readonly ILogger<CheckoutService> _log;
public CheckoutService(
ICartRepository carts, IInventoryService inventory, ITaxCalculator tax,
IPaymentProcessor payment, IShippingService shipping,
INotificationService notifications, IOrderRepository orders,
IUnitOfWork uow, ILogger<CheckoutService> log)
{
_carts = carts; _inventory = inventory; _tax = tax;
_payment = payment; _shipping = shipping; _notifications = notifications;
_orders = orders; _uow = uow; _log = log;
}
public async Task<CheckoutResult> CheckoutAsync(CheckoutRequest req, CancellationToken ct)
{
await using var tx = await _uow.BeginAsync(ct);
var cart = await _carts.GetAsync(req.CartId, ct)
?? throw new NotFoundException($"Cart {req.CartId} not found");
await _inventory.ReserveAsync(cart.Items, ct);
var totals = _tax.Compute(cart);
var charge = await _payment.PayAsync(totals.GrandTotal, cart.CustomerId, req.CartId, ct);
if (charge.Status != "succeeded")
throw new CheckoutFailedException(charge.Error ?? "Unknown");
var order = await _orders.CreateAsync(cart, totals, charge.TransactionId!, ct);
await _shipping.BookAsync(order, ct);
await tx.CommitAsync(ct);
// Notifications happen after commit — failing to send an email must not
// roll back a successful order.
await _notifications.SendConfirmationAsync(order, ct);
_log.LogInformation("Order {OrderId} created for cart {CartId}", order.Id, req.CartId);
return new CheckoutResult(order.Id, totals.GrandTotal, order.Customer.Email);
}
}
// Composition root ------------------------------------------------
builder.Services.AddScoped<ICheckoutService, CheckoutService>();
// inner collaborators registered as their own scoped/singleton services
// Caller (the controller from earlier) ----------------------------
[ApiController, Route("checkout")]
public sealed class CheckoutController : ControllerBase
{
private readonly ICheckoutService _checkout;
public CheckoutController(ICheckoutService checkout) => _checkout = checkout;
[HttpPost]
public async Task<ActionResult<CheckoutResult>> Post(CheckoutRequest req, CancellationToken ct)
=> Ok(await _checkout.CheckoutAsync(req, ct));
}
The decision worth highlighting: notifications happen after commit. The Facade is the only place that knows that decision exists, because the Facade is the only place that orchestrates both. Hiding that knowledge inside the Facade is the entire point.
Where should you read next in this series?
- Previous: Decorator — same shape but one-to-one wrapping with added behaviour.
- Next: Flyweight — sharing immutable data across many instances.
- Cross-reference: Mediator — when the inner objects are peers that talk to each other through a bus, not subordinates the Facade orchestrates.
- Cross-reference: Singleton — Facades
are usually
Scoped(per request) but sometimesSingletonwhen stateless and frequently called. - Decision tree: How to choose the right design pattern.
A pragmatic note: Facade is the most under-named pattern in real codebases. Most application services are Facades; most teams just call them "the X service". That is fine — the pattern earns its keep regardless of what you call it. The benefit of knowing the name is recognising the failure modes (god classes, leaking returns, one-to-one passthroughs) earlier.
Frequently asked questions
What is the difference between Facade and Adapter?
Is an application service the same thing as a Facade?
Can a Facade leak its internals through return types?
CheckoutFacade.Checkout() returns the same Order aggregate the inner repository handed it, every caller now depends on the inner type. Convert to a small DTO at the Facade boundary so callers depend only on data they actually need.