Structural Design Patterns in .NET: Building Scalable and Maintainable Architectures

In software development, organizing code in a way that is easy to maintain and extend is essential. Structural design patterns provide a blueprint for how different parts of an application should work together, making code more reusable and flexible. By reducing tight coupling between components, these patterns make it easier to modify or expand functionality without breaking existing features.

In this article, we’ll explore key structural design patterns in .NET with step-by-step examples that developers use in real-world scenarios.

Adapter Pattern — Connecting Incompatible Interfaces

Allows incompatible interfaces to work together by converting the interface of a class into another interface that a client expects. For example, a class adapter or object adapter can be used to adapt different APIs into a common interface.

Common Use Cases

  • Integrating Legacy Code — Allows older APIs to work with modern interfaces without modification.
  • Third-Party Library Compatibility — Makes third-party components conform to existing system structures.
  • Hardware Adaptation — Connects incompatible devices (e.g., old peripherals to new computers).

SOLID Principles

  • Single Responsibility Principle (SRP) — The adapter class has a single responsibility: converting one interface to another.
  • Open-Closed Principle (OCP) — The adapter allows new functionality (compatibility) without modifying existing code.

Step-by-Step Implementation — Charging Different Devices

This example demonstrates the Adapter Pattern, which allows a device with a Micro-USB charger to be used with a USB-C charging interface.

1️⃣ Define an Existing Incompatible Interface (Micro-USB Charger)

This first step defines an existing but incompatible charging interface. The MicroUSBCharger class represents an older charging method that only supports Micro-USB, making it incompatible with newer USB-C devices.

public class MicroUSBCharger  
{
public void ChargeWithMicroUSB()
{
Console.WriteLine("Charging device using Micro-USB.");
}
}

2️⃣ Define a Standard Interface (USB-C Charging)

In this step, we define a standard charging interface for USB-C devices. The IUSBCCharger interface ensures that any compatible charger implements the ChargeWithUSBC method, establishing a uniform way to charge newer devices.

public interface IUSBCCharger  
{
void ChargeWithUSBC();
}

3️⃣ Implement an Adapter that Bridges the Two Interfaces

This step implements an adapter that bridges the gap between the Micro-USB charger and the USB-C interface. The USBAdapter class wraps a MicroUSBCharger and translates ChargeWithUSBC calls into ChargeWithMicroUSB, allowing older chargers to work with newer devices.

public class USBAdapter : IUSBCCharger  
{
private readonly MicroUSBCharger _microUSBCharger;

public USBAdapter(MicroUSBCharger charger)
{
_microUSBCharger = charger;
}

public void ChargeWithUSBC()
{
Console.WriteLine("Adapter converts USB-C to Micro-USB.");
_microUSBCharger.ChargeWithMicroUSB();
}
}

4️⃣ Use the Adapter to Connect Incompatible Components

Finally, we use the adapter to connect incompatible components. We create an instance of the MicroUSBCharger, then wrap it in a USBAdapter that implements the IUSBCCharger interface. The ChargeWithUSBC method is called, which uses the adapter to convert the request to a compatible Micro-USB charge.

var oldCharger = new MicroUSBCharger();  
IUSBCCharger adapter = new USBAdapter(oldCharger);
adapter.ChargeWithUSBC();

Output

Adapter converts USB-C to Micro-USB.  
Charging device using Micro-USB.

Bridge Pattern — Decoupling Abstraction from Implementation

Separates an abstraction from its implementation, allowing both to vary independently. It is often used to allow flexibility in switching between implementations without changing the abstraction itself. For example, UI components can be decoupled from the platform they run on, allowing easier implementation of the same component on different platforms.

Common Use Cases

  • UI Frameworks — Decouples UI components from their rendering logic.
  • Device Control Systems — Enables a universal remote to work with different devices.
  • Game Development — Separates character behavior from rendering engines.

SOLID Principles Supported

  • Open-Closed Principle (OCP) — New implementations can be added without modifying the core abstraction.
  • Dependency Inversion Principle (DIP) — High-level modules depend on abstractions rather than concrete implementations.

Step-by-Step Implementation — Cross-Platform Graphics Rendering

Imagine you’re building a graphics rendering system, and you need to abstract the rendering logic from the platform-specific graphics implementation. With the Bridge Pattern, you can define an interface for rendering shapes (like circles and squares) and then implement platform-specific rendering logic.

1️⃣ Define the Abstraction for Rendering Shapes

The Abstraction represents the high-level view of the system (in this case, drawing shapes), but delegates the platform-specific details to a Bridge.

public abstract class Shape  
{
protected IRenderer _renderer; // Bridge to platform-specific rendering logic

protected Shape(IRenderer renderer)
{
_renderer = renderer;
}

public abstract void Draw();
}

2️⃣ Create Concrete Implementations of Shapes

Define concrete shapes like Circle and Square that will be rendered via the platform-specific rendering engine.

public class Circle : Shape  
{
public Circle(IRenderer renderer) : base(renderer) { }

public override void Draw()
{
_renderer.RenderCircle(); // Delegating drawing to the renderer
}
}

public class Square : Shape
{
public Square(IRenderer renderer) : base(renderer) { }

public override void Draw()
{
_renderer.RenderSquare(); // Delegating drawing to the renderer
}
}

3️⃣ Define the Implementor Interface (Platform-Specific Rendering)

The Implementor defines the rendering behavior, but each platform-specific renderer will provide its own implementation of these methods.

public interface IRenderer  
{
void RenderCircle();
void RenderSquare();
}

4️⃣ Implement Platform-Specific Renderers

Now, implement different rendering strategies for each platform (e.g., Windows and Linux). Each platform’s renderer provides the actual drawing logic.

public class WindowsRenderer : IRenderer  
{
public void RenderCircle() => Console.WriteLine("Rendering Circle on Windows");
public void RenderSquare() => Console.WriteLine("Rendering Square on Windows");
}

public class LinuxRenderer : IRenderer
{
public void RenderCircle() => Console.WriteLine("Rendering Circle on Linux");
public void RenderSquare() => Console.WriteLine("Rendering Square on Linux");
}

5️⃣ Use the Bridge to Draw Shapes on Different Platforms

Finally, the Shape objects (Circle or Square) use the platform-specific renderer to draw the shapes.

class Program  
{
static void Main(string[] args)
{
IRenderer windowsRenderer = new WindowsRenderer();
IRenderer linuxRenderer = new LinuxRenderer();

Shape circleOnWindows = new Circle(windowsRenderer);
Shape squareOnLinux = new Square(linuxRenderer);

circleOnWindows.Draw(); // Output: Rendering Circle on Windows
squareOnLinux.Draw(); // Output: Rendering Square on Linux
}
}

6️⃣Output

Rendering Circle on Windows  
Rendering Square on Linux

Composite Pattern — Handling Hierarchies

Composes objects into tree-like structures to represent part-whole hierarchies. It allows clients to treat individual objects and compositions of objects uniformly. For example, a graphical object system where a Group (composed of multiple Shapes) and individual Shapes are treated the same.

  • File Systems: It allows uniform treatment of files and folders, simplifying file operations like display and management.
  • GUI Components: It enables managing both individual and grouped UI components in the same way, streamlining event handling and rendering.
  • Organization Structures: It simplifies managing hierarchical structures by treating individuals and departments uniformly.
  • Document Processing: It models documents with nested elements as a tree structure, ensuring consistent processing of both individual and grouped elements.
  • Graphics and Shapes: It helps manage complex shapes as individual components or collections, facilitating operations like drawing, resizing, or moving.
  • Single Responsibility Principle (SRP): It separates the responsibilities of individual objects and composite objects, ensuring each component focuses on a specific task.
  • Open/Closed Principle (OCP): It allows the addition of new components without modifying existing code, supporting extensibility.
  • Liskov Substitution Principle (LSP): It ensures that leaf and composite objects can be substituted without affecting the program’s correctness.
  • Interface Segregation Principle (ISP): It provides a unified interface that allows clients to interact with both leaf and composite components seamlessly.
  • Dependency Inversion Principle (DIP): It promotes working with abstractions rather than concrete implementations, enhancing flexibility and reducing dependency on low-level details.

Step-by-Step Code Example: File System with Folders and Files

This example demonstrates the Composite Pattern by organizing files and folders in a hierarchical structure. The pattern allows us to treat both individual files (leaf components) and folders (composite components) in a unified way.

1️⃣ Define a Common Interface for Files and Folders

In this step, we define the IFileSystem interface that both files and folders will implement. This interface ensures that both types of components have a consistent Display method, allowing them to be processed uniformly.

public interface IFileSystem  
{
void Display(string indent = "");
}

2️⃣ Implement a Leaf Component (File)

Here, we create the File class that implements the IFileSystem interface. This class represents a leaf component, meaning it does not contain other files or folders. The Display method prints the file’s name with optional indentation to show its hierarchy level.

public class File : IFileSystem  
{
private readonly string _name;

public File(string name)
{
_name = name;
}

public void Display(string indent = "")
{
Console.WriteLine($"{indent}- {_name}");
}
}

3️⃣ Implement a Composite Component (Folder)

In this step, we define the Folder class, which also implements the IFileSystem interface. This class represents a composite component, which can contain other files or folders. It has a List<IFileSystem> to store its child components (files and subfolders). The Display method prints the folder’s name and recursively calls Display on its children to show the entire folder structure.

public class Folder : IFileSystem  
{
private readonly string _name;
private readonly List<IFileSystem> _children = new();

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

public void Add(IFileSystem item) => _children.Add(item);

public void Display(string indent = "")
{
Console.WriteLine($"{indent}+ {_name}");
foreach (var child in _children)
child.Display(indent + " ");
}
}

4️⃣ Create a Folder Structure and Display It

Now, we create a folder structure with folders and files. The root folder contains two subfolders (Documents and Pictures), each with their own files. Finally, we call the Display method on the root folder to print the entire structure.

var root = new Folder("Root");  
var documents = new Folder("Documents");
var pictures = new Folder("Pictures");

documents.Add(new File("Resume.docx"));
documents.Add(new File("Project.pdf"));

pictures.Add(new File("Vacation.jpg"));
pictures.Add(new File("Profile.png"));

root.Add(documents);
root.Add(pictures);

root.Display();

5️⃣Output

+ Root  
+ Documents
- Resume.docx
- Project.pdf
+ Pictures
- Vacation.jpg
- Profile.png

Decorator Pattern — Dynamic Behavior Enhancement

Allows for adding new functionality to an object dynamically without altering its structure. This is typically done by wrapping the object in another object (decorator). For example, adding additional features like logging, caching, or error handling to existing services without modifying their core implementation.

Common Use Cases

  • Logging: Adds logging capabilities to methods without changing the method’s code, helping track operations and debug issues.
  • Authentication: Adds authentication checks to methods or services, ensuring that only authorized users can access certain functionality.
  • Caching: Adds caching mechanisms to methods to improve performance by storing frequently accessed results.
  • Middleware in Web Applications: Dynamically adds functionality to request handling in web applications, such as adding headers, managing sessions, or handling exceptions.

SOLID Principles Supported by the Decorator Pattern

  • Single Responsibility Principle (SRP): Each decorator has a single responsibility, allowing different behaviors to be added separately.
  • Open/Closed Principle (OCP): The class is open for extension (decorators can be added) but closed for modification (no need to alter the original class).
  • Liskov Substitution Principle (LSP): Decorators are designed to adhere to the same interface as the object they decorate, ensuring they can be used interchangeably.
  • Interface Segregation Principle (ISP): The Decorator Pattern enables the design of smaller, more specific interfaces for each decorator, reducing dependencies between classes.
  • Dependency Inversion Principle (DIP): The high-level module (e.g., business logic) is decoupled from low-level modules (e.g., caching, logging), allowing flexible integration of decorators.

Decorator Implementation — Adding Caching to a Data Retrieval Service

In this example, we will add caching behavior to our data service without modifying the original DataService class, demonstrating the power and flexibility of the Decorator Pattern.

1️⃣ Define the Core Interface

Create an interface that both the original class and decorators will implement. This ensures consistency across different decorators.

public interface IDataService
{
string GetData(int id);
}

2️⃣ Create the Concrete Class

The original DataService class implements the IDataService interface.

public class DataService : IDataService
{
public string GetData(int id)
{
// Simulating data retrieval from a database.
return "Data from DB";
}
}

3️⃣ Create the Decorator Base Class

Create an abstract decorator class that wraps the IDataService object and implements the IDataService interface.

public abstract class DataServiceDecorator : IDataService
{
protected IDataService _dataService;

public DataServiceDecorator(IDataService dataService)
{
_dataService = dataService;
}

public virtual string GetData(int id)
{
return _dataService.GetData(id);
}
}

4️⃣Create a Concrete Decorator (Caching)

Create a decorator class that adds caching functionality to the GetData method.

public class CachingDataService : DataServiceDecorator
{
private Dictionary<int, string> _cache = new Dictionary<int, string>();

public CachingDataService(IDataService dataService) : base(dataService) { }

public override string GetData(int id)
{
if (_cache.ContainsKey(id))
{
Console.WriteLine("Returning from cache.");
return _cache[id];
}

Console.WriteLine("Fetching from database.");
var result = base.GetData(id);
_cache[id] = result;
return result;
}
}

5️⃣Use the Decorator

Wrap the DataService instance with the CachingDataService to add caching behavior.

class Program
{
static void Main(string[] args)
{
IDataService dataService = new DataService();
IDataService cachedDataService = new CachingDataService(dataService);

Console.WriteLine(cachedDataService.GetData(1)); // Fetching from database
Console.WriteLine(cachedDataService.GetData(1)); // Returning from cache
}
}

Facade Pattern — Simplifying Interfaces

Provides a simplified interface to a complex subsystem, hiding the complexities and providing a higher-level interface to interact with. For example, a library that abstracts interaction with various APIs, offering a simpler interface for the client.

Common Use Cases

  • Simplifying Complex Systems: Simplifies interactions with complex subsystems like databases, third-party services, or large frameworks by providing a unified interface.
  • Integration of Multiple Subsystems: Combines multiple subsystems under one interface, making it easier to interact with several services or APIs without needing to understand the details of each.
  • User Interface Frameworks: Provides a simple interface for user interface frameworks, encapsulating complexity and allowing easy access to core functionalities.
  • Legacy Systems: Wraps legacy systems with a modern interface, allowing new code to interact with old systems without dealing with the outdated complexities.

SOLID Principles Supported by the Facade Pattern

  • Single Responsibility Principle (SRP): The Facade class has a single responsibility of exposing a simplified interface, leaving the complex subsystem’s responsibilities with other classes.
  • Open/Closed Principle (OCP): The Facade is open for extension (can be modified to add new subsystems) but closed for modification (does not require altering the existing client code).
  • Liskov Substitution Principle (LSP): The Facade class can substitute complex subsystem calls, maintaining consistent behavior when interacting with client code.
  • Interface Segregation Principle (ISP): The Facade class hides the complexity of the subsystem, offering a more focused and specific interface for the client.
  • Dependency Inversion Principle (DIP): The client depends on the Facade interface instead of low-level subsystem classes, allowing higher-level modules to remain decoupled from the details of the subsystem.

Facade Pattern Implementation — Simplifying a Complex Subsystem for Online Shopping

Imagine an online shopping system that involves interacting with various subsystems like payment processinginventory management, and order fulfillment. Instead of having the client interact with each subsystem individually, we can use the Facade Pattern to provide a simplified interface.

In this example, the client only needs to interact with the ShoppingFacade to complete an order. The complexity of interacting with the PaymentProcessorInventoryManager, and OrderFulfillment subsystems is hidden behind the facade, which simplifies the client’s code.

1️⃣ Define Subsystems

In this step, we define three separate classes that handle different aspects of an order processing system

public class PaymentProcessor
{
public void ProcessPayment(string paymentDetails)
{
Console.WriteLine("Processing payment: " + paymentDetails);
}
}

public class InventoryManager
{
public void CheckInventory(string productId)
{
Console.WriteLine("Checking inventory for product: " + productId);
}
}

public class OrderFulfillment
{
public void ShipOrder(string orderId)
{
Console.WriteLine("Shipping order: " + orderId);
}
}

2️⃣ Create the Facade

In this step, we define a ShoppingFacade class, which serves as a simplified interface to handle the process of completing an order.

  • The ShoppingFacade class does not create instances of PaymentProcessorInventoryManager, and OrderFulfillment directly.
  • Instead, it takes these dependencies as parameters in its constructor, where they are injected by a DI container (or manually for simple cases).
  • This allows greater flexibility through decoupling, as you can inject different implementations or mock dependencies for testing.
// Define interfaces
public interface IPaymentProcessor
{
void ProcessPayment(string paymentDetails);
}

public interface IInventoryManager
{
void CheckInventory(string productId);
}

public interface IOrderFulfillment
{
void ShipOrder(string orderId);
}

// Concrete implementations
public class PaymentProcessor : IPaymentProcessor
{
public void ProcessPayment(string paymentDetails)
{
Console.WriteLine("Processing payment: " + paymentDetails);
}
}

public class InventoryManager : IInventoryManager
{
public void CheckInventory(string productId)
{
Console.WriteLine("Checking inventory for product: " + productId);
}
}

public class OrderFulfillment : IOrderFulfillment
{
public void ShipOrder(string orderId)
{
Console.WriteLine("Shipping order: " + orderId);
}
}

// ShoppingFacade with Dependency Injection
public class ShoppingFacade
{
private readonly IPaymentProcessor _paymentProcessor;
private readonly IInventoryManager _inventoryManager;
private readonly IOrderFulfillment _orderFulfillment;

// Constructor receives dependencies via DI
public ShoppingFacade(IPaymentProcessor paymentProcessor, IInventoryManager inventoryManager, IOrderFulfillment orderFulfillment)
{
_paymentProcessor = paymentProcessor;
_inventoryManager = inventoryManager;
_orderFulfillment = orderFulfillment;
}

public void CompleteOrder(string productId, string paymentDetails, string orderId)
{
_inventoryManager.CheckInventory(productId);
_paymentProcessor.ProcessPayment(paymentDetails);
_orderFulfillment.ShipOrder(orderId);
Console.WriteLine("Order complete!");
}
}

3️⃣ Use the Facade

The client interacts with the ShoppingFacade rather than dealing directly with each subsystem, simplifying the code and interaction.

class Program
{
static void Main(string[] args)
{
ShoppingFacade shoppingFacade = new ShoppingFacade();

string productId = "12345";
string paymentDetails = "CreditCard#1234";
string orderId = "98765";

shoppingFacade.CompleteOrder(productId, paymentDetails, orderId);
}
}

4️⃣Output

Checking inventory for product: 12345
Processing payment: CreditCard#1234
Shipping order: 98765
Order complete!

Flyweight Pattern — Optimizing Memory Usage

Reduces the number of objects created by sharing common state between similar objects. It is especially useful for scenarios with many objects that have shared properties. For example, a text editor application that shares common font styles across characters to save memory.

Common Use Cases

  • Text Rendering: Storing each character or word as a shared object in a document editor, reducing memory consumption.
  • Graphical Objects: Reducing the memory usage when rendering millions of similar objects in a game (e.g., trees in a forest or tiles on a map).
  • Object Pooling: Caching identical objects to reuse them, instead of creating new ones.
  • Caching: Storing shared data or configurations that can be reused multiple times without the need to recreate them.

SOLID Principles Supported by Flyweight Pattern

  • Single Responsibility Principle (SRP): The CharacterFlyweight class only deals with character data (symbol and font), and the TextRenderer class focuses on rendering the text at different positions. Each class has a single responsibility.
  • Open/Closed Principle (OCP): The CharacterFlyweight class is open for extension (you can add new properties like color), but closed for modification. The factory ensures that new character instances are created when necessary.
  • Liskov Substitution Principle (LSP): Any subclass of CharacterFlyweight can replace the base class without affecting the behavior of the client code, ensuring that polymorphism does not break functionality.
  • Interface Segregation Principle (ISP): The Render method of the CharacterFlyweight class is designed to be simple and not force clients to implement unnecessary methods. It keeps the interface small and focused.
  • Dependency Inversion Principle (DIP): The TextRenderer class depends on the abstraction (CharacterFlyweight), not the concrete implementation. It can work with any CharacterFlyweight object that provides the required interface.

Flyweight Pattern Implementation — Optimizing Memory Usage for Text Rendering

Let’s consider a text editor that needs to render text on the screen. Every character (A, B, C, etc.) can be seen as a “flyweight” object because the properties of the character (e.g., font, size, color) remain constant across the document, whereas the position of the character (its extrinsic state) changes as we place it on the page.

1️⃣ Define the Intrinsic State (Shared Data)

The CharacterFlyweight class represents the shared data (font and symbol). The Render method displays the character at a particular position. The font and symbol don’t change for each instance; they can be reused across all characters.

// Flyweight class
public class CharacterFlyweight
{
public char Symbol { get; }
public string Font { get; }

public CharacterFlyweight(char symbol, string font)
{
Symbol = symbol;
Font = font;
}

public void Render(int x, int y)
{
Console.WriteLine($"Rendering '{Symbol}' at ({x},{y}) with font {Font}");
}
}

2️⃣ Create the Flyweight Factory

The CharacterFactory is responsible for checking if the character with the given font already exists. If it does, it returns the existing object. Otherwise, it creates a new one and stores it in the dictionary for future reuse.

// Flyweight Factory
public class CharacterFactory
{
private readonly Dictionary<string, CharacterFlyweight> _flyweights = new();

public CharacterFlyweight GetCharacter(char symbol, string font)
{
string key = $"{symbol}_{font}";
if (!_flyweights.ContainsKey(key))
{
_flyweights[key] = new CharacterFlyweight(symbol, font);
}
return _flyweights[key];
}
}

3️⃣ Define the Extrinsic State

The TextRenderer uses the CharacterFactory to retrieve shared character objects. It only passes the extrinsic data (the position of the character) while reusing the intrinsic data (the character’s symbol and font).

// Client code to render a string of text
public class TextRenderer
{
private readonly CharacterFactory _factory = new();

public void RenderText(string text, string font)
{
int x = 0;
foreach (var symbol in text)
{
// Retrieve or create the flyweight object for the character
var flyweight = _factory.GetCharacter(symbol, font);
flyweight.Render(x, 0); // Pass extrinsic state (position)
x += 10; // Move the next character 10 units to the right
}
}
}

4️⃣ Using the Flyweight Pattern in Action

The TextRenderer renders the string “HELLO” with the same font (“Arial”) by reusing the flyweight instances for each character. The position of each character is extrinsic, so only that data needs to change.

// Usage Example
class Program
{
static void Main()
{
TextRenderer renderer = new();
renderer.RenderText("HELLO", "Arial"); // Same font, different positions
}
}

Proxy Pattern — Controlling Access to Objects

  • Provides a surrogate or placeholder for another object to control access to it. Proxies are useful for scenarios like lazy loadingaccess control, or logging. For example, a proxy for a network connectioncontrolling when and how the connection is made or intercepted.

Common Use Cases

  • Lazy Initialization: The proxy can delay the creation of an object until it is actually needed (e.g., loading large resources or files).
  • Access Control: The proxy can manage access to sensitive operations by checking permissions before forwarding requests to the real object.
  • Logging and Monitoring: The proxy can add logging functionality to track method calls made to the real object.
  • Remote Access: The proxy can represent an object located in another address space (e.g., a remote server) and handle communication between the client and the remote system.
  • Caching: The proxy can cache results from the real object, so that expensive operations are only performed once.

SOLID Principles Supported by Proxy Pattern

  • Single Responsibility Principle (SRP): The proxy is responsible for controlling access and adding additional behavior like caching or logging, while the FileDownloader class is focused solely on downloading files. Each class has a distinct responsibility.
  • Open/Closed Principle (OCP): The proxy can be extended to add new behavior (such as logging or security checks) without modifying the FileDownloader. The system is open for extension, but closed for modification.
  • Liskov Substitution Principle (LSP): The FileDownloaderProxy can substitute the real FileDownloader without breaking the client code, because both classes implement the same IDownloadable interface.
  • Interface Segregation Principle (ISP): The IDownloadable interface is small and focused, only exposing the necessary methods for file downloading, allowing clients to use it without unnecessary complexity.
  • Dependency Inversion Principle (DIP): The client code depends on the abstraction (IDownloadable), not on the concrete implementation of the FileDownloader. This ensures that the client remains decoupled from the concrete behavior and can work with any class that implements the interface, such as the proxy.

Proxy Pattern Implementation — Controlling Access and Enhancing Functionality for File Downloads

Let’s consider an example of a Network Proxy for a FileDownloader that represents a file download operation. We will introduce a proxy that controls access to the actual file downloader, allowing features such as loggingcaching, and access control to be added without modifying the core downloader logic.

1️⃣ Define the Subject (Real Object)

FileDownloader is the class responsible for actually downloading files. It has the core functionality that the proxy will protect or control.

// Real Subject
public class FileDownloader
{
public string DownloadFile(string fileName)
{
Console.WriteLine($"Downloading file: {fileName}");
// Simulate downloading
return $"File {fileName} content";
}
}

2️⃣ Define the Proxy Interface

The IDownloadable interface ensures that both the FileDownloader and any proxies implement the same methods, allowing the client to interact with them interchangeably.

// Subject Interface
public interface IDownloadable
{
string DownloadFile(string fileName);
}

3️⃣ Implement the Proxy

FileDownloaderProxy intercepts the call to DownloadFile and checks if the file has been cached. If it has, the proxy returns the cached version of the file; otherwise, it delegates the task to the real FileDownloader object, caches the result, and returns it.

// Proxy
public class FileDownloaderProxy : IDownloadable
{
private readonly FileDownloader _realFileDownloader = new();
private readonly Dictionary<string, string> _cache = new();

public string DownloadFile(string fileName)
{
if (_cache.ContainsKey(fileName))
{
Console.WriteLine($"Returning cached version of {fileName}");
return _cache[fileName];
}

Console.WriteLine($"Fetching file {fileName} from server...");
string content = _realFileDownloader.DownloadFile(fileName);

// Cache the downloaded content
_cache[fileName] = content;
return content;
}
}

4️⃣ Using the Proxy in Action

The client interacts with the FileDownloaderProxy just like it would with the real FileDownloader. The proxy controls access and provides additional behavior such as caching.

// Client Code
class Program
{
static void Main()
{
IDownloadable fileDownloader = new FileDownloaderProxy();

// First download, no cache
Console.WriteLine(fileDownloader.DownloadFile("file1.txt"));

// Second download, returns cached version
Console.WriteLine(fileDownloader.DownloadFile("file1.txt"));
}
}

5️⃣Output

Fetching file file1.txt from server...
Downloading file: file1.txt
Returning cached version of file1.txt

Structural design patterns provide powerful solutions for organizing and managing code efficiently. Whether integrating legacy systems with an Adapterdecoupling abstractions and implementations with a Bridge, composing objects into tree structures with Compositeadding functionality dynamically with Decoratorsimplifying complex systems with Facade, reducing memory usage with Flyweight, or controlling object creation with Proxy, these patterns help create scalablemaintainable, and flexible applications. By understanding and applying these patterns in real-world scenarios, developers can write cleaner, more reusable code that stands the test of time.