Building Robust Systems with Structural Patterns: Adapter, Bridge, Façade

Why Structural Patterns Matter in .NET Architecture

If you've ever struggled to integrate a third-party library with an incompatible interface, or found yourself drowning in a sea of tightly coupled classes, structural design patterns offer a lifeline. These patterns solve the recurring problem of how objects and classes compose to form larger, more flexible structures without creating brittle dependencies that make your code hard to change.

Structural patterns give you proven blueprints for building maintainable architectures in .NET applications. The Adapter pattern lets you integrate incompatible interfaces seamlessly. The Bridge pattern separates abstractions from implementations, preventing the explosion of subclasses. The Façade pattern simplifies complex subsystems with a clean, unified interface. When you apply these patterns correctly, you reduce coupling, improve testability, and make your codebase resilient to change.

In this article, you'll learn how to implement five essential structural patterns—Adapter, Bridge, Decorator, Façade, and Composite—with practical C# examples targeting .NET 8. You'll see how each pattern solves specific architectural challenges and when to choose one over another. By the end, you'll have reusable patterns ready to apply in your own projects.

Adapter Pattern: Making Incompatible Interfaces Work Together

The Adapter pattern converts one interface into another that clients expect. This is crucial when you need to integrate external libraries, legacy code, or third-party APIs that don't match your application's expected interface. Rather than modifying code you don't control, you create an adapter that translates between the two interfaces.

Think of it like a power adapter for international travel. Your laptop charger expects a specific plug shape, but the wall outlet in another country has a different shape. The adapter bridges this gap without requiring you to rewire your charger or the building's electrical system. In software, the Adapter pattern serves the same purpose—it bridges the gap between what you have and what you need.

PaymentAdapter.cs
// Target interface that our application expects
public interface IPaymentProcessor
{
    PaymentResult ProcessPayment(decimal amount, string currency);
    bool ValidateCard(string cardNumber);
}

// Adaptee: Third-party payment library with different interface
public class StripePaymentGateway
{
    public StripeResponse Charge(int amountInCents, string currencyCode,
                                  string token)
    {
        Console.WriteLine($"Stripe charging {amountInCents} cents in {currencyCode}");
        return new StripeResponse { Success = true, TransactionId = "txn_123" };
    }

    public bool VerifyCardToken(string token)
    {
        return !string.IsNullOrEmpty(token) && token.Length > 10;
    }
}

// Adapter: Converts Stripe's interface to our IPaymentProcessor interface
public class StripeAdapter : IPaymentProcessor
{
    private readonly StripePaymentGateway _stripe;

    public StripeAdapter(StripePaymentGateway stripe)
    {
        _stripe = stripe;
    }

    public PaymentResult ProcessPayment(decimal amount, string currency)
    {
        // Adapt our decimal amount to Stripe's cents-based integer
        int amountInCents = (int)(amount * 100);
        string token = GenerateCardToken();

        var response = _stripe.Charge(amountInCents, currency.ToUpper(), token);

        return new PaymentResult
        {
            IsSuccess = response.Success,
            TransactionId = response.TransactionId,
            Message = response.Success ? "Payment processed" : "Payment failed"
        };
    }

    public bool ValidateCard(string cardNumber)
    {
        string token = ConvertCardToToken(cardNumber);
        return _stripe.VerifyCardToken(token);
    }

    private string GenerateCardToken() => "tok_visa_4242";
    private string ConvertCardToToken(string card) => $"tok_{card.Substring(0, 4)}";
}

public class PaymentResult
{
    public bool IsSuccess { get; set; }
    public string TransactionId { get; set; }
    public string Message { get; set; }
}

public class StripeResponse
{
    public bool Success { get; set; }
    public string TransactionId { get; set; }
}

The adapter wraps the Stripe gateway and translates calls to match our IPaymentProcessor interface. Notice how it converts decimal amounts to cents, generates tokens, and maps Stripe's response to our PaymentResult type. Our application code never knows it's talking to Stripe—it just sees IPaymentProcessor. This isolation makes it easy to swap payment providers without touching business logic.

Bridge Pattern: Separating Abstraction from Implementation

The Bridge pattern decouples an abstraction from its implementation so both can evolve independently. This is particularly valuable when you have multiple dimensions of variation. Instead of creating a class hierarchy that combines every possible combination, Bridge separates the dimensions and connects them through composition.

Consider a notification system that needs to send different message types (email, SMS, push) across different priorities (normal, urgent). Without Bridge, you'd need EmailNormalNotification, EmailUrgentNotification, SMSNormalNotification, SMSUrgentNotification, and so on—a combinatorial explosion. Bridge lets you vary message types and priorities independently.

NotificationBridge.cs
// Implementation interface (one dimension of variation)
public interface IMessageSender
{
    void Send(string recipient, string message);
}

// Concrete implementations
public class EmailSender : IMessageSender
{
    public void Send(string recipient, string message)
    {
        Console.WriteLine($"Sending email to {recipient}: {message}");
    }
}

public class SmsSender : IMessageSender
{
    public void Send(string recipient, string message)
    {
        Console.WriteLine($"Sending SMS to {recipient}: {message}");
    }
}

// Abstraction (other dimension of variation)
public abstract class Notification
{
    protected IMessageSender MessageSender;

    protected Notification(IMessageSender sender)
    {
        MessageSender = sender;
    }

    public abstract void Notify(string recipient, string content);
}

// Refined abstractions
public class NormalNotification : Notification
{
    public NormalNotification(IMessageSender sender) : base(sender) { }

    public override void Notify(string recipient, string content)
    {
        string formattedMessage = $"[INFO] {content}";
        MessageSender.Send(recipient, formattedMessage);
    }
}

public class UrgentNotification : Notification
{
    public UrgentNotification(IMessageSender sender) : base(sender) { }

    public override void Notify(string recipient, string content)
    {
        string formattedMessage = $"[URGENT] {content.ToUpper()}";
        MessageSender.Send(recipient, formattedMessage);
        LogUrgentNotification(recipient);
    }

    private void LogUrgentNotification(string recipient)
    {
        Console.WriteLine($"Urgent notification logged for {recipient}");
    }
}

// Usage example
public class NotificationService
{
    public void SendAlerts()
    {
        var emailSender = new EmailSender();
        var smsSender = new SmsSender();

        var normalEmail = new NormalNotification(emailSender);
        normalEmail.Notify("user@example.com", "Your order has shipped");

        var urgentSms = new UrgentNotification(smsSender);
        urgentSms.Notify("+1234567890", "Security alert detected");
    }
}

The Bridge pattern connects Notification abstractions to IMessageSender implementations through composition. You can now add new notification types or new senders independently without creating new combined classes. Want a critical notification level? Create a new class inheriting from Notification. Need push notifications? Implement IMessageSender. The two dimensions vary freely.

Decorator Pattern: Adding Behavior Dynamically

The Decorator pattern attaches additional responsibilities to an object dynamically. It provides a flexible alternative to subclassing for extending functionality. You wrap an object with decorators that add behavior before or after delegating to the wrapped component.

This pattern shines when you need stackable features. Imagine a data repository that needs caching, logging, and retry logic. Rather than creating CachedLoggingRetryRepository or every other combination, you stack decorators: new CacheDecorator(new LoggingDecorator(new RetryDecorator(repository))). Each decorator adds one concern without knowing about the others.

RepositoryDecorators.cs
public interface IDataRepository
{
    string GetData(int id);
    void SaveData(int id, string data);
}

// Base component
public class DataRepository : IDataRepository
{
    public string GetData(int id)
    {
        Console.WriteLine($"Fetching data for ID {id} from database");
        return $"Data_{id}";
    }

    public void SaveData(int id, string data)
    {
        Console.WriteLine($"Saving data for ID {id}: {data}");
    }
}

// Base decorator
public abstract class RepositoryDecorator : IDataRepository
{
    protected IDataRepository Repository;

    protected RepositoryDecorator(IDataRepository repository)
    {
        Repository = repository;
    }

    public virtual string GetData(int id) => Repository.GetData(id);
    public virtual void SaveData(int id, string data) => Repository.SaveData(id, data);
}

// Concrete decorator: Caching
public class CachingDecorator : RepositoryDecorator
{
    private readonly Dictionary _cache = new();

    public CachingDecorator(IDataRepository repository) : base(repository) { }

    public override string GetData(int id)
    {
        if (_cache.TryGetValue(id, out var cached))
        {
            Console.WriteLine($"Cache hit for ID {id}");
            return cached;
        }

        Console.WriteLine($"Cache miss for ID {id}");
        var data = Repository.GetData(id);
        _cache[id] = data;
        return data;
    }

    public override void SaveData(int id, string data)
    {
        Repository.SaveData(id, data);
        _cache[id] = data; // Update cache
    }
}

// Concrete decorator: Logging
public class LoggingDecorator : RepositoryDecorator
{
    public LoggingDecorator(IDataRepository repository) : base(repository) { }

    public override string GetData(int id)
    {
        Console.WriteLine($"[LOG] GetData called with ID {id}");
        var result = Repository.GetData(id);
        Console.WriteLine($"[LOG] GetData returned {result.Length} characters");
        return result;
    }

    public override void SaveData(int id, string data)
    {
        Console.WriteLine($"[LOG] SaveData called with ID {id}, length {data.Length}");
        Repository.SaveData(id, data);
        Console.WriteLine($"[LOG] SaveData completed");
    }
}

Each decorator wraps the repository and adds exactly one responsibility. The caching decorator checks the cache before delegating to the wrapped instance. The logging decorator logs method calls before and after delegation. You can stack them in any order and add or remove decorators without changing other code. This composition approach is far more flexible than inheritance.

Façade Pattern: Simplifying Complex Subsystems

The Façade pattern provides a unified, simplified interface to a complex subsystem. When you have multiple interacting classes that require coordinated setup or configuration, a Façade hides that complexity behind a clean API. This reduces coupling because clients depend only on the Façade, not on the internal subsystem classes.

Think of ordering food through a delivery app versus calling the restaurant, coordinating with a driver, and handling payment separately. The app is a Façade that orchestrates all these services for you. In code, a Façade does the same thing—it coordinates multiple components and exposes only what clients need.

OrderFacade.cs
// Complex subsystem classes
public class InventoryService
{
    public bool CheckStock(int productId, int quantity)
    {
        Console.WriteLine($"Checking stock for product {productId}, qty {quantity}");
        return true; // Simplified
    }

    public void ReserveItems(int productId, int quantity)
    {
        Console.WriteLine($"Reserved {quantity} units of product {productId}");
    }
}

public class PaymentService
{
    public bool ProcessPayment(decimal amount, string cardToken)
    {
        Console.WriteLine($"Processing payment of ${amount}");
        return true; // Simplified
    }
}

public class ShippingService
{
    public string CreateShipment(string address, int[] productIds)
    {
        Console.WriteLine($"Creating shipment to {address} for {productIds.Length} products");
        return "SHIP_12345";
    }
}

public class NotificationService
{
    public void SendConfirmation(string email, string orderId)
    {
        Console.WriteLine($"Sending confirmation to {email} for order {orderId}");
    }
}

// Façade: Simplified interface
public class OrderFacade
{
    private readonly InventoryService _inventory;
    private readonly PaymentService _payment;
    private readonly ShippingService _shipping;
    private readonly NotificationService _notification;

    public OrderFacade()
    {
        _inventory = new InventoryService();
        _payment = new PaymentService();
        _shipping = new ShippingService();
        _notification = new NotificationService();
    }

    public OrderResult PlaceOrder(int productId, int quantity,
                                   string customerEmail, string address,
                                   decimal amount, string cardToken)
    {
        // Orchestrate complex workflow
        if (!_inventory.CheckStock(productId, quantity))
        {
            return OrderResult.Fail("Out of stock");
        }

        _inventory.ReserveItems(productId, quantity);

        if (!_payment.ProcessPayment(amount, cardToken))
        {
            return OrderResult.Fail("Payment declined");
        }

        string shipmentId = _shipping.CreateShipment(address, new[] { productId });
        string orderId = $"ORD_{Guid.NewGuid().ToString().Substring(0, 8)}";

        _notification.SendConfirmation(customerEmail, orderId);

        return OrderResult.Success(orderId, shipmentId);
    }
}

public class OrderResult
{
    public bool IsSuccess { get; set; }
    public string OrderId { get; set; }
    public string ShipmentId { get; set; }
    public string ErrorMessage { get; set; }

    public static OrderResult Success(string orderId, string shipmentId) =>
        new() { IsSuccess = true, OrderId = orderId, ShipmentId = shipmentId };

    public static OrderResult Fail(string error) =>
        new() { IsSuccess = false, ErrorMessage = error };
}

The OrderFacade coordinates four subsystems to complete an order. Clients call a single PlaceOrder method instead of understanding how inventory, payment, shipping, and notifications interact. This makes the complex workflow easy to use and easy to change—if you need to add fraud detection, you modify the Façade without touching client code.

Composite Pattern: Treating Objects and Compositions Uniformly

The Composite pattern lets you compose objects into tree structures to represent part-whole hierarchies. Clients can treat individual objects and compositions uniformly through the same interface. This is essential for building recursive structures like file systems, organizational charts, or UI component trees.

The power of Composite is that you can call the same method on a leaf node or a composite node, and it just works. When you delete a folder, you expect all nested files and subfolders to be deleted too. Composite makes this natural by recursively delegating operations through the tree.

FileSystemComposite.cs
// Component interface
public interface IFileSystemNode
{
    string Name { get; }
    long GetSize();
    void Display(int depth = 0);
}

// Leaf: File
public class File : IFileSystemNode
{
    public string Name { get; }
    private long _sizeInBytes;

    public File(string name, long size)
    {
        Name = name;
        _sizeInBytes = size;
    }

    public long GetSize() => _sizeInBytes;

    public void Display(int depth = 0)
    {
        string indent = new string(' ', depth * 2);
        Console.WriteLine($"{indent}📄 {Name} ({_sizeInBytes} bytes)");
    }
}

// Composite: Folder
public class Folder : IFileSystemNode
{
    public string Name { get; }
    private readonly List _children = new();

    public Folder(string name)
    {
        Name = name;
    }

    public void Add(IFileSystemNode node)
    {
        _children.Add(node);
    }

    public void Remove(IFileSystemNode node)
    {
        _children.Remove(node);
    }

    public long GetSize()
    {
        // Recursive: sum all children's sizes
        return _children.Sum(child => child.GetSize());
    }

    public void Display(int depth = 0)
    {
        string indent = new string(' ', depth * 2);
        Console.WriteLine($"{indent}📁 {Name} ({GetSize()} bytes total)");

        foreach (var child in _children)
        {
            child.Display(depth + 1);
        }
    }
}

// Usage
public class FileSystemExample
{
    public static void ShowComposite()
    {
        var root = new Folder("root");

        var documents = new Folder("documents");
        documents.Add(new File("resume.pdf", 45000));
        documents.Add(new File("report.docx", 120000));

        var photos = new Folder("photos");
        photos.Add(new File("vacation.jpg", 2500000));
        photos.Add(new File("profile.png", 850000));

        root.Add(documents);
        root.Add(photos);
        root.Add(new File("readme.txt", 1200));

        root.Display();
        Console.WriteLine($"\nTotal size: {root.GetSize()} bytes");
    }
}

Both File and Folder implement IFileSystemNode, so you can treat them uniformly. When you call GetSize on a folder, it recursively sums the sizes of all its children. The Display method works the same way—folders delegate to their children with increased depth for indentation. This uniformity makes tree operations simple and consistent.

Try It Yourself

Let's build a working example that combines structural patterns. This demonstration creates a logging system that uses the Decorator pattern to add features and the Façade pattern to simplify usage. You'll see how these patterns work together in a practical scenario.

Steps

  1. Create a new console project: dotnet new console -n PatternDemo
  2. Navigate to the project directory: cd PatternDemo
  3. Replace the contents of Program.cs with the code below
  4. Update PatternDemo.csproj as shown
  5. Run the program: dotnet run
PatternDemo.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>
Program.cs
// Component interface
interface ILogger
{
    void Log(string message);
}

// Concrete component
class ConsoleLogger : ILogger
{
    public void Log(string message) => Console.WriteLine($"[LOG] {message}");
}

// Decorator: Timestamps
class TimestampDecorator : ILogger
{
    private readonly ILogger _logger;
    public TimestampDecorator(ILogger logger) => _logger = logger;

    public void Log(string message)
    {
        var timestamped = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} - {message}";
        _logger.Log(timestamped);
    }
}

// Decorator: Filtering
class ErrorFilterDecorator : ILogger
{
    private readonly ILogger _logger;
    public ErrorFilterDecorator(ILogger logger) => _logger = logger;

    public void Log(string message)
    {
        if (message.Contains("ERROR") || message.Contains("WARN"))
            _logger.Log(message);
    }
}

// Façade
class LoggingFacade
{
    private readonly ILogger _logger;

    public LoggingFacade(bool includeTimestamp, bool errorsOnly)
    {
        ILogger logger = new ConsoleLogger();
        if (includeTimestamp) logger = new TimestampDecorator(logger);
        if (errorsOnly) logger = new ErrorFilterDecorator(logger);
        _logger = logger;
    }

    public void Info(string msg) => _logger.Log($"INFO: {msg}");
    public void Warning(string msg) => _logger.Log($"WARN: {msg}");
    public void Error(string msg) => _logger.Log($"ERROR: {msg}");
}

// Demo
var facade = new LoggingFacade(includeTimestamp: true, errorsOnly: true);
facade.Info("Application started");
facade.Warning("Low memory warning");
facade.Error("Database connection failed");

What you'll see

[LOG] 2025-11-04 14:22:35 - WARN: Low memory warning
[LOG] 2025-11-04 14:22:35 - ERROR: Database connection failed

The output shows only warnings and errors (because of the filter decorator) with timestamps. The info message doesn't appear because it doesn't contain ERROR or WARN. This demonstrates how decorators stack behaviors and how a Façade simplifies configuration.

Choosing the Right Structural Pattern

Each structural pattern solves a distinct problem, and knowing when to use each one comes down to understanding your specific challenge. The wrong pattern adds unnecessary complexity, while the right one simplifies your architecture and makes future changes easier.

Choose Adapter when you're integrating existing code with incompatible interfaces—you gain seamless integration but accept one extra layer of indirection. This is ideal for third-party libraries, legacy systems, or external APIs where you can't change the original interface. If you control both sides and can modify the interfaces directly, skip the adapter and just align them.

Choose Bridge when you're designing a system where two dimensions vary independently—you gain flexibility to extend both dimensions without combinatorial class explosion but accept more upfront design complexity. Use Bridge when you see multiple variations that would otherwise require a huge class hierarchy. For example, if you have multiple data sources and multiple export formats, Bridge separates these concerns cleanly.

Choose Decorator when you need to add responsibilities dynamically at runtime—it favors composition over inheritance and supports stackable behaviors but adds complexity when debugging nested decorators. Decorators shine for cross-cutting concerns like caching, logging, or validation that you want to mix and match. If you only have one or two fixed variations, simple inheritance might be clearer.

Choose Façade when you're simplifying a complex subsystem for clients—you reduce coupling and make the subsystem easier to use, but you lose fine-grained control for advanced scenarios. A Façade is perfect when most clients need the same common operations and don't care about internal details. For power users who need full control, expose the subsystem classes alongside the Façade.

If unsure, start with the simplest solution and refactor to patterns as complexity grows. Monitor how often you need to change related classes together—that's a signal you need better separation. Premature pattern application can make simple problems harder, so apply patterns when they genuinely solve a pain point you're experiencing.

Common Questions

When should I use the Adapter pattern instead of changing existing code?

Use the Adapter pattern when you need to integrate third-party libraries, legacy code, or external APIs that you cannot modify. It's particularly valuable when the interface mismatch is significant or when you want to isolate changes. If you control both sides and the change is simple, direct modification might be cleaner.

What's the main difference between Bridge and Adapter patterns?

Bridge separates abstraction from implementation during design, allowing both to evolve independently. Adapter fixes interface incompatibilities after the fact. Bridge is proactive for flexibility; Adapter is reactive for compatibility. Use Bridge when planning for multiple implementations; use Adapter to integrate existing incompatible code.

Can Decorator and inheritance achieve the same goals?

No. Inheritance creates compile-time relationships and leads to class explosion when combining behaviors. Decorator enables runtime composition of behaviors and avoids the fragile base class problem. Prefer Decorator when you need flexible, stackable features that can be added or removed dynamically.

How does Façade differ from a simple wrapper class?

A Façade coordinates multiple subsystems and provides a simplified unified interface, handling orchestration logic. A wrapper typically wraps a single class to change its interface or add simple behavior. Façades reduce complexity across systems; wrappers adapt or enhance individual components.

Is it safe to combine multiple structural patterns in one system?

Yes, patterns solve different problems and often complement each other. A Façade might use Adapters internally, or Decorators can wrap Bridge implementations. Keep each pattern focused on its single responsibility and document how they interact to maintain clarity for future developers.

What are the performance implications of structural patterns?

Structural patterns add indirection, which has minimal overhead in most applications. Deep decorator chains or excessive adapter layers can impact performance. Profile before optimizing—maintainability gains usually outweigh minor performance costs. For hot paths, consider caching adapted objects or flattening decorator chains.

Back to Articles