11 Design Patterns Every C# Developer Should Know

Abnoan Muniz
21 min readMar 18, 2023

--

Design Patterns Demystified

.NET CORE | .NET | C# | DESIGN PATTERNS | SINGLETON | FACADE | STRATEGY | MEDIATOR
Photo provided by the Author

As a C# developer, knowing and implementing design patterns in your codebase can significantly improve your application's quality, maintainability, and scalability. This article will discuss 14 design patterns that every C# developer should know, categorized into Creational, Structural, and Behavioral patterns. By understanding these patterns and their appropriate use cases, you can make your code more modular, easier to understand, and less prone to bugs. So, let's dive into the world of design patterns and learn how they can take your C# development skills to the next level.

Creational Patterns

Creational patterns are a group of design patterns in software development that deal with object creation mechanisms. They aim to provide flexible and reusable processes for creating objects that suit the specific requirements of a given situation. Creational patterns are essential in increasing code's maintainability, scalability, and flexibility by offering adequate and efficient solutions for creating new objects. As a C# developer, understanding these patterns can help you write more modular, flexible, and testable code in your applications.

Singleton Pattern

A creational pattern ensures that only one class instance is created and provides a global access point to that instance. It is often used to manage system-wide resources and to ensure that there is only one instance of a class that other objects in the application can access.

To implement the Singleton Pattern, we need to make the class constructor private, preventing the creation of new instances outside the class. We then create a static method that provides access to the singleton instance, typically created the first time the static method is called. Here's an example implementation of the Singleton Pattern in C#:

public sealed class Singleton
{
private static Singleton instance = null;

private Singleton()
{
// Private constructor to prevent creation of new instances.
}

public static Singleton Instance
{
get
{
if (instance == null)
{
instance = new Singleton();
}
return instance;
}
}

// Other methods and properties.
}

Benefits:

  • Guarantees the existence of only one instance of the class.
  • Provides a global point of access to the instance, which other objects in the application can access.
  • Reduces memory usage and improves performance by reusing the same instance.
  • Facilitates the implementation of different patterns, such as the Factory Pattern.

Drawbacks:

  • Can make the code more complex and harder to test.
  • Can introduce a global state, making the application more difficult to understand and maintain.
  • Can lead to potential race conditions and thread-safety issues if not implemented carefully.

Best Practices:

  • Keep the Singleton class simple and focused on a specific responsibility.
  • Avoid using the Singleton Pattern for classes that change frequently or have mutable states.
  • Ensure that the Singleton instance is thread-safe, either using a thread-safe implementation or lazy initialization with double-check locking.
  • Consider using dependency injection instead of the Singleton Pattern to improve testability and maintainability.

Factory Method Pattern

It is a creational pattern that provides an interface for creating objects in a superclass but allows subclasses to alter the type of objects that will be created. This pattern is proper when a class can't anticipate the kind of objects it needs to create beforehand.

To implement the Factory Method Pattern in C#, you can create an abstract class that defines a method for creating objects. Subclasses of this abstract class will implement the method to create their own objects. Here's an example:

public abstract class AnimalFactory
{
public abstract Animal CreateAnimal();
}

public class DogFactory : AnimalFactory
{
public override Animal CreateAnimal()
{
return new Dog();
}
}

public class CatFactory : AnimalFactory
{
public override Animal CreateAnimal()
{
return new Cat();
}
}

In the above code, AnimalFactory is the abstract class that defines the CreateAnimal() . DogFactory and CatFactory are subclasses that implement the method to create Dog and Cat objects, respectively.

To use the Factory Method Pattern, you can create an instance of the appropriate factory subclass and call its CreateAnimal() to create an object of the desired type.

AnimalFactory factory = new DogFactory();
Animal animal = factory.CreateAnimal();
animal.MakeSound(); // Output: Woof!

In the above code, an instance of DogFactory is created, and its CreateAnimal() is called to create a Dog . The MakeSound() of the Dog is then called to output "Woof!".

Benefits:

  • Provides a way to create objects without exposing the instantiation logic to the client.
  • Encourages loose coupling between classes, making modifying the system's behavior easier by substituting objects.
  • Supports the open-closed principle, allowing new types of objects to be added without modifying existing code.
  • Improves code maintainability and scalability by separating responsibilities and reducing code duplication.

Drawbacks:

  • Can introduce additional complexity, as a new set of classes and interfaces may be required.
  • Can be difficult to understand for developers unfamiliar with the pattern, leading to a steep learning curve.
  • May require additional setup and configuration, making it less suitable for small projects or simple systems.

Best Practices:

  • Using the Factory Method Pattern when creating objects with varying properties or behavior is necessary.
  • Ensure that the Factory class focuses on a single responsibility and that each product type has its corresponding factory method.
  • Consider using Dependency Injection to decouple the code and improve testability.
  • Use meaningful names for the factory methods and product classes to improve code readability and maintainability.

Abstract Factory Pattern

It is a creational design pattern that provides an interface for creating families of related objects without specifying their concrete classes. In simpler terms, it allows you to create objects related to each other without knowing their exact class or implementation details.

The purpose of the Abstract Factory Pattern is to provide a way to create objects that are part of a more extensive system or family of related objects. Using this pattern, you can decouple the client code from the specific classes of objects it needs to work with, making the system more flexible and easier to maintain.

Suppose you have two different operating systems, Windows and MacOS. Each operating system has its own user interface controls, such as buttons, text boxes, and labels. You want to create a UI library that can create different controls for different operating systems. You can use the Abstract Factory Pattern to accomplish this task.

Here is an example implementation of the Abstract Factory Pattern:

public interface IUIFactory
{
IButton CreateButton();
ITextBox CreateTextBox();
ILabel CreateLabel();
}

public class WindowsUIFactory : IUIFactory
{
public IButton CreateButton()
{
return new WindowsButton();
}

public ITextBox CreateTextBox()
{
return new WindowsTextBox();
}

public ILabel CreateLabel()
{
return new WindowsLabel();
}
}

public class MacOSUIFactory : IUIFactory
{
public IButton CreateButton()
{
return new MacOSButton();
}

public ITextBox CreateTextBox()
{
return new MacOSTextBox();
}

public ILabel CreateLabel()
{
return new MacOSLabel();
}
}

public interface IButton { }

public class WindowsButton : IButton { }

public class MacOSButton : IButton { }

public interface ITextBox { }

public class WindowsTextBox : ITextBox { }

public class MacOSTextBox : ITextBox { }

public interface ILabel { }

public class WindowsLabel : ILabel { }

public class MacOSLabel : ILabel { }

public class Client
{
private readonly IUIFactory _factory;

public Client(IUIFactory factory)
{
_factory = factory;
}

public void CreateUI()
{
var button = _factory.CreateButton();
var textBox = _factory.CreateTextBox();
var label = _factory.CreateLabel();

// Use the created controls to build the UI
}
}

Benefits:

  • Encapsulates the creation of objects.
  • Ensures that a family of related objects is created.
  • Provides a way to enforce constraints on the types of objects created.
  • Improves the flexibility of the code by making it easy to switch between families of related objects.

Drawbacks:

  • Can make the code more complex.
  • Can be challenging to extend to support new types of products.

Best Practices:

  • Design the Abstract Factory interface carefully to ensure it is easy to use and understand.
  • To create families of related objects.
  • When you want to provide a way to enforce constraints on the types of objects created.
  • To improve your code's flexibility by making switching between families of related objects easy.

Builder Pattern

The Builder Pattern is a creational design pattern that allows developers to create complex objects using a builder object step by step. It separates the construction of a complex object from its representation, allowing the same construction process to create different representations.

Benefits:

  • Simplifies the creation of complex objects by breaking them down into smaller, manageable steps.
  • Makes code more readable and easier to maintain by separating object creation from its representation.
  • Allows to create of different representations of the same complex object without changing the object's construction process.
  • Improves the flexibility of the object creation process, making it more adaptable to changing requirements.

Let's say we want to create a Car object with multiple properties, such as color, engine, and wheels. Then, using the Builder Pattern, we can create a CarBuilder class to construct the Car object. Here's an example implementation in C#:

public class Car
{
public string Color { get; set; }
public string Engine { get; set; }
public int Wheels { get; set; }
}

public class CarBuilder
{
private readonly Car _car = new Car();

public CarBuilder SetColor(string color)
{
_car.Color = color;
return this;
}

public CarBuilder SetEngine(string engine)
{
_car.Engine = engine;
return this;
}

public CarBuilder SetWheels(int wheels)
{
_car.Wheels = wheels;
return this;
}

public Car Build()
{
return _car;
}
}

We can now use the CarBuilder class to create a Car object step by step:

CarBuilder carBuilder = new CarBuilder();
Car car = carBuilder.SetColor("Red")
.SetEngine("V8")
.SetWheels(4)
.Build();

Drawbacks:

  • Can introduce overhead and additional complexity to the code, especially for simple objects.
  • May require creating additional classes and interfaces, increasing the overall codebase.
  • Requires careful consideration of the steps involved in constructing the object, as changes to the construction process can impact the entire codebase.

Best Practices:

  • For complex objects that require a step-by-step construction process.
  • Keep the Builder class separate from the object it constructs to maintain separation of concerns.
  • Consider using a fluent interface to make the code readable and easier.
  • Avoid using the Builder Pattern for simple objects, as it can introduce unnecessary complexity.

Structural Patterns

They are a category of design patterns in software engineering that aim to simplify the structure of a system by identifying relationships between objects and classes. These patterns focus on creating efficient and flexible class hierarchies and interfaces to solve design problems in organizing and connecting different system parts. They help in achieving code reusability, maintainability, and scalability.

Adapter Pattern

It is a structural design pattern that allows two incompatible interfaces to work together by wrapping one of the objects with a new interface. This pattern is particularly useful when existing code needs to work with new code with a different interface.

The Adapter Pattern has three main components: the Target interface, the Adaptee class, and the Adapter class. The Target interface is the interface that the client expects to work with, but the Adaptee class has a different interface. The Adapter class converts the Adaptee's interface into the Target interface.

Let's say we have an existing Logger interface in our system that logs messages to the console:

public interface ILogger
{
void Log(string message);
}

We also have a new component, the FileLogger, which writes messages to a file but has a different interface:

public class FileLogger
{
public void WriteToFile(string message)
{
// write message to file
}
}

To use the FileLogger with our existing ILogger interface, we can create an adapter class:

public class FileLoggerAdapter : ILogger
{
private readonly FileLogger _fileLogger;

public FileLoggerAdapter(FileLogger fileLogger)
{
_fileLogger = fileLogger;
}

public void Log(string message)
{
_fileLogger.WriteToFile(message);
}
}

Now we can use the FileLogger with our existing ILogger interface:

ILogger logger = new FileLoggerAdapter(new FileLogger());
logger.Log("Log message");

Benefits:

  • Allows existing code to work with new code that has a different interface.
  • Promotes reusability by enabling different components to work together that would not otherwise be compatible.

Drawbacks:

  • Can lead to complex code with many layers of adapters.
  • May introduce performance overhead due to additional method calls and object creation.

Best Practices:

  • Use the Adapter Pattern sparingly and only when necessary.
  • Keep adapters simple and focused on a specific responsibility.
  • Consider using dependency injection to decouple components and make them more easily testable.

Facade Pattern

It is another structural design pattern providing a simplified interface to a complex system of classes, interfaces, and objects. It allows clients to interact with a system through a unified interface, hiding the complexity and details of the system's components.

Suppose we have a complex system consisting of multiple subsystems and interfaces, each with its own methods and properties. Using the Facade Pattern, we can create a simplified interface that encapsulates the system's functionality and provides a unified interface for clients.

public class ComplexSystem
{
public void MethodA() { }
public void MethodB() { }
public void MethodC() { }
}

public class Facade
{
private readonly ComplexSystem _system;

public Facade()
{
_system = new ComplexSystem();
}

public void DoSomething()
{
_system.MethodA();
_system.MethodB();
_system.MethodC();
}
}

public class Client
{
private readonly Facade _facade;

public Client()
{
_facade = new Facade();
}

public void UseSystem()
{
_facade.DoSomething();
}
}

In this example, the ComplexSystem represents the complex system we want to simplify for clients. The Facade encapsulates the system's functionality and provides a simplified interface for clients. Finally, the Client uses the Facade to interact with the system.

Benefits:

  • Simplifies client usage by providing a unified interface to a complex system.
  • Reduces coupling between the client code and the system's components, making it easier to maintain and update the system.
  • Encapsulates the system's complexity, allowing developers to focus on high-level functionality.

Drawbacks:

  • May result in creating a "god" object that knows too much about the system's components, making the code more complex and harder to maintain.
  • May hide important details about the system from the client, making it more difficult to diagnose and debug issues.

Best Practices:

  • Keep the Facade class simple and focused on a specific responsibility.
  • Use it to simplify interactions with a complex system but avoid overuse or misuse.
  • Ensure the Facade interface is stable and doesn't frequently change, as this can impact client code.
  • Consider using the Facade Pattern and other design patterns, such as the Adapter Pattern, to achieve more advanced functionality.

Proxy Pattern

A structural pattern provides a surrogate or placeholder for another object to control its access. It creates a proxy object that acts as an intermediary between a client and a real object, hiding the complexities of accessing the real object and providing additional functionality.

How it works:

  • The client interacts with the proxy object instead of the real object.
  • The proxy object creates the real object only when needed, a process known as lazy initialization.
  • The proxy object intercepts and forwards the client's requests to the real object.
  • The proxy object can add additional functionality before or after forwarding the request.

Example: the IHttpService defines a contract for an HTTP service. The HttpService implements the IHttpService , providing functionality for sending HTTP requests. The HttpServiceProxy acts as a proxy for the HttpService , adding caching functionality to the HTTP requests.

public interface IHttpService
{
Task<string> Get(string url);
}

public class HttpService : IHttpService
{
public async Task<string> Get(string url)
{
using (var client = new HttpClient())
{
var response = await client.GetAsync(url);
return await response.Content.ReadAsStringAsync();
}
}
}

public class HttpServiceProxy : IHttpService
{
private readonly IHttpService _httpService;
private readonly Dictionary<string, string> _cache;

public HttpServiceProxy(IHttpService httpService)
{
_httpService = httpService;
_cache = new Dictionary<string, string>();
}

public async Task<string> Get(string url)
{
if (_cache.ContainsKey(url))
{
return _cache[url];
}

var response = await _httpService.Get(url);

_cache[url] = response;

return response;
}
}

Benefits:

  • Simplifies access to a complex or resource-intensive object by providing a surrogate.
  • Enhances the security of the real object by controlling access to it.
  • Allows additional functionality to be added to the real object without changing its interface.

Drawbacks:

  • Adds additional complexity to the system by introducing a new layer of abstraction.
  • Can impact performance if the proxy object is resource-intensive or the real object requires frequent access.

Best practices:

  • Use the Proxy Pattern when you need to control access to a resource or enhance the functionality of an existing object.
  • Consider the performance impact of introducing a proxy object and lazy initialization.
  • Keep the proxy and real objects as similar as possible to reduce complexity.
  • Use dependency injection to decouple the proxy object from the real object and improve testability.

Behavioral Patterns

They are a category of design patterns in software engineering that focus on the behavior and communication between objects and classes in a system. They help solve complex design problems related to the interaction, coordination, and delegation of responsibilities among different objects and classes. Behavioral patterns provide a set of techniques for implementing algorithms, workflows, and communication protocols that enhance a system's flexibility, maintainability, and extensibility.

Chain of Responsibility Pattern

It is a behavioral design pattern that provides a way to handle a request through a chain of objects, each potentially handling the request or passing it on to the next object in the chain. This pattern is useful when you have a complex system of objects that need to handle a request, but the responsibility for handling the request is yet to be known at compile time.

In this example, we have a simple interface called IHandler which defines a method HandleRequest() for handling requests. We also have two concrete handlers, ConcreteHandler1 and ConcreteHandler2, which handles specific types of requests.

public interface IHandler
{
void HandleRequest(Request request);
}

public class ConcreteHandler1 : IHandler
{
private IHandler _nextHandler;

public void SetNextHandler(IHandler handler)
{
_nextHandler = handler;
}

public void HandleRequest(Request request)
{
if (request.Type == RequestType.Type1)
{
// handle the request
}
else if (_nextHandler != null)
{
_nextHandler.HandleRequest(request);
}
}
}

public class ConcreteHandler2 : IHandler
{
private IHandler _nextHandler;

public void SetNextHandler(IHandler handler)
{
_nextHandler = handler;
}

public void HandleRequest(Request request)
{
if (request.Type == RequestType.Type2)
{
// handle the request
}
else if (_nextHandler != null)
{
_nextHandler.HandleRequest(request);
}
}
}

In the above example, each handler can handle a specific type of request. If the handler cannot handle the request, it passes the request to the next handler in the chain using the SetNextHandler() .

To use the chain, you would create an instance of each handler and set the next handler using the SetNextHandler() . You would then call the HandleRequest() on the first handler in the chain, passing in the request to be handled.

var handler1 = new ConcreteHandler1();
var handler2 = new ConcreteHandler2();

handler1.SetNextHandler(handler2);

handler1.HandleRequest(new Request(RequestType.Type1));
handler1.HandleRequest(new Request(RequestType.Type2));

In the above example, the ConcreteHandler1 can handle requests of Type1 and passes requests of Type2 to ConcreteHandler2. The ConcreteHandler2 can handle requests of Type2.

Benefits:

  • Reduces coupling between objects by avoiding the need to specify the receiver explicitly.
  • Simplifies handling complex requests by breaking them down into smaller, more manageable parts.
  • Provides flexibility in adding or removing handlers from the chain without affecting the other handlers.
  • Allows you to change the order of the handlers in the chain dynamically.

Drawbacks:

  • Can lead to performance issues if the chain is shorter or the processing needs to be simplified.
  • Can be difficult to debug if the chain is not designed or implemented correctly.
  • Can result in requests being left unhandled if there is a break in the chain.

Best Practices

  • Keep the chain simple and easy to understand by limiting the number of handlers in the chain.
  • Design the chain so that each handler does one thing and does it well.
  • Ensure that the chain is designed to handle requests appropriately, even if a handler is added or removed.
  • Consider using the Command Pattern in conjunction with the Chain of Responsibility Pattern to simplify handling complex requests further.

Strategy Pattern

A behavioral design pattern allows a client object to choose from a family of related algorithms at runtime. Using this pattern, we can define a family of algorithms, encapsulate each one as an object, and make them interchangeable.

Let's consider a shopping cart system that calculates the total cost of items. Using the Strategy pattern, we can define different strategies to calculate the total cost based on the offered discount. For instance, we can have a strategy to calculate the total cost based on a percentage discount, a fixed discount, or no discount.

public interface ITotalCostStrategy
{
decimal CalculateTotalCost(IEnumerable<Item> items);
}

public class PercentageDiscountStrategy : ITotalCostStrategy
{
private readonly decimal _discountPercentage;

public PercentageDiscountStrategy(decimal discountPercentage)
{
_discountPercentage = discountPercentage;
}

public decimal CalculateTotalCost(IEnumerable<Item> items)
{
decimal totalCost = items.Sum(item => item.Price);
return totalCost * (1 - _discountPercentage);
}
}

public class FixedDiscountStrategy : ITotalCostStrategy
{
private readonly decimal _fixedDiscount;

public FixedDiscountStrategy(decimal fixedDiscount)
{
_fixedDiscount = fixedDiscount;
}

public decimal CalculateTotalCost(IEnumerable<Item> items)
{
decimal totalCost = items.Sum(item => item.Price);
return totalCost - _fixedDiscount;
}
}

public class ShoppingCart
{
private readonly IEnumerable<Item> _items;
private readonly ITotalCostStrategy _totalCostStrategy;

public ShoppingCart(IEnumerable<Item> items, ITotalCostStrategy totalCostStrategy)
{
_items = items;
_totalCostStrategy = totalCostStrategy;
}

public decimal CalculateTotalCost()
{
return _totalCostStrategy.CalculateTotalCost(_items);
}
}

By using the Strategy pattern, the ShoppingCart class is decoupled from the specific discount calculation logic. Instead, it can accept different ITotalCostStrategy implementations at runtime, enabling greater flexibility and maintainability of the codebase.

Benefits:

  • Allows for a clean separation of concerns, isolating algorithms from the client code that uses them.
  • Encapsulating the algorithms can be easily modified, added, or removed, making the code more flexible and reusable.
  • We can achieve greater flexibility and customization by choosing the appropriate strategy at runtime.

Drawbacks:

  • It can increase the number of classes and objects in a program, negatively impacting performance and memory usage.
  • Implementing the pattern may require additional effort and time upfront, involving creating multiple classes and interfaces.
  • It can be more difficult to understand and maintain code that uses the Strategy pattern, especially for developers who are unfamiliar with the pattern.
  • Using it may only be appropriate for some situations, and other design patterns may be more suitable.
  • The increased level of abstraction provided by the pattern can make it harder to debug code and locate errors.

Best Practices:

  • Use the Strategy pattern when you have a family of related algorithms and want to make them interchangeable.
  • Encapsulate the algorithms into separate classes, implementing a common interface or base class.
  • Use dependency injection to inject the appropriate strategy at runtime, making the code more flexible and customizable.
  • Use it with other patterns, such as the Factory pattern, to create the appropriate strategy object at runtime.

Iterator Pattern

It is another behavioral design pattern that provides a way to access the elements of a collection without exposing the underlying representation of the collection. It separates the traversal logic from the collection, allowing different algorithms to be used on the same collection.

Let's consider a scenario where we have a collection of books, and we want to be able to iterate over the collection and perform some operations on each book. Using the Iterator pattern, we can define an iterator interface that allows us to access the collection's elements without exposing the collection's underlying representation.

public interface IIterator<T>
{
bool HasNext();
T Next();
}

public class BookIterator : IIterator<Book>
{
private readonly List<Book> _books;
private int _current = 0;

public BookIterator(List<Book> books)
{
_books = books;
}

public bool HasNext()
{
return _current < _books.Count;
}

public Book Next()
{
Book book = _books[_current];
_current++;
return book;
}
}

public class BookCollection : IEnumerable<Book>
{
private readonly List<Book> _books = new List<Book>();

public void AddBook(Book book)
{
_books.Add(book);
}

public IEnumerator<Book> GetEnumerator()
{
return new BookIterator(_books);
}

IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}

Benefits:

  • The Iterator pattern provides a uniform way to iterate over different collections of objects without exposing their internal implementation details.
  • It simplifies the client code that uses the collection by encapsulating the iteration logic within the Iterator object.
  • It supports multiple iterations over the same collection without interfering with each other.
  • It allows for lazy loading of elements, improving performance by only loading the necessary elements when needed.
  • It enables the creation of custom iterators to traverse the collection in a specific order or pattern.

Drawbacks:

  • The Iterator pattern can increase code complexity and require additional classes and interfaces.
  • It may not be suitable for small collections or collections expected to change infrequently.
  • Modifying the collection while iterating over it can be difficult, as this can cause errors or unexpected behavior.
  • Lazy loading can lead to unexpected performance issues if the elements are not on time.

Best Practices:

  • Use the Iterator pattern when iterating over a collection of objects without exposing its internal structure.
  • Encapsulate the iteration logic within the Iterator object to simplify the client code.
  • Use lazy loading when appropriate to improve performance.
  • Avoid modifying the collection while iterating over it.
  • Consider using other patterns, such as the Composite pattern, to create complex collections that can be iterated with a single Iterator object.

Observer Pattern

A behavioral design pattern that defines a one-to-many dependency between objects. This means that when the state of one object changes, all its dependents are notified and updated automatically. In this way, we can achieve loose coupling between objects and avoid tight coupling that can lead to maintenance issues and code that is hard to change.

Let's consider an example of a weather station that measures temperature, humidity, and pressure. We want to notify different types of displays when the weather data changes. We can use the Observer pattern to achieve this.

First, we define an interface IObservable that represents the observable (the weather station) and an interface IObserver that represents the observer (the display).

public interface IObservable
{
void AddObserver(IObserver observer);
void RemoveObserver(IObserver observer);
void NotifyObservers();
}

public interface IObserver
{
void Update(float temperature, float humidity, float pressure);
}

Then, we implement the WeatherStation that implements the IObservable .

public class WeatherStation : IObservable
{
private List<IObserver> _observers;
private float _temperature;
private float _humidity;
private float _pressure;

public WeatherStation()
{
_observers = new List<IObserver>();
}

public void AddObserver(IObserver observer)
{
_observers.Add(observer);
}

public void RemoveObserver(IObserver observer)
{
_observers.Remove(observer);
}

public void NotifyObservers()
{
foreach (var observer in _observers)
{
observer.Update(_temperature, _humidity, _pressure);
}
}

public void SetMeasurements(float temperature, float humidity, float pressure)
{
_temperature = temperature;
_humidity = humidity;
_pressure = pressure;
NotifyObservers();
}
}

Finally, we implement the Display that implements the IObserver .

public class Display : IObserver
{
public void Update(float temperature, float humidity, float pressure)
{
Console.WriteLine($"Temperature: {temperature}, Humidity: {humidity}, Pressure: {pressure}");
}
}

Benefits:

  • The Observer pattern allows for loose coupling between the subject and the observers, making the code more flexible and easier to maintain.
  • Observers can be added or removed at runtime without affecting the subject or other observers, providing more flexibility and scalability to the system.
  • The pattern promotes the Single Responsibility Principle (SRP), by separating the notification logic from the business logic of the subject.

Drawbacks:

  • The Observer pattern can lead to performance issues if the number of observers is very large or if the notification logic is complex.
  • Modifying or extending the system may take work if the subject and the observers are tightly coupled.
  • Overusing the Observer pattern can make the code harder to understand and maintain, especially for developers unfamiliar with the pattern.

Best Practices:

  • Use the Observer pattern when you need to notify multiple objects of changes in a subject and when you want to avoid tight coupling between them.
  • Encapsulate the subject's state to prevent direct access by the observers and provide a clear API for them to use.
  • Use a generic interface or base class for the observers to ensure flexibility and reusability.
  • Consider using an event-based approach if you are working with C# events, as it provides a simpler and more efficient way of implementing the Observer pattern.

State Pattern

Another behavioral design pattern allows an object to alter its behavior when its internal state changes. Using this pattern, we can define a set of states for an object, encapsulate each state as an object, and make them interchangeable.

public interface IState
{
void Handle();
}

public class StateA : IState
{
public void Handle()
{
Console.WriteLine("State A handled.");
}
}

public class StateB : IState
{
public void Handle()
{
Console.WriteLine("State B handled.");
}
}

public class Context
{
private IState _state;

public Context()
{
_state = new StateA();
}

public void ChangeState(IState state)
{
_state = state;
}

public void Request()
{
_state.Handle();
}
}

In this example, we have an interface IState that defines the Handle() method. We have two concrete implementations of IState: StateA and StateB.

We also have a Context that maintains the current state of the system through its _state . The Context has a ChangeState() that allows the state to be changed and a Request() delegates the handling of the request to the current state.

Note that this example is a simplified version of the State Pattern, and in practice, the implementation may involve more complex state transitions and logic.

Benefits:

  • The State pattern allows for a clean separation of concerns, isolating states from the client code that uses them.
  • Encapsulating the states can be easily modified, added, or removed, making the code more flexible and reusable.
  • We can achieve greater flexibility and customization by switching to the appropriate state at runtime.

Drawbacks:

  • It can increase the number of classes and objects in a program, which may negatively impact performance and memory usage.
  • Implementing the pattern may require additional effort and time upfront, involving creating multiple classes and interfaces.
  • It can be more difficult to understand and maintain code that uses the State pattern, especially for developers who are unfamiliar with the pattern.
  • Using the State pattern may not be appropriate for all situations, and other design patterns may be more suitable for some cases.
  • The increased level of abstraction provided by the pattern can make it harder to debug code and locate errors.

Best Practices:

  • Use the State pattern when you have an object in different states and want to make them interchangeable.
  • Encapsulate the states into separate classes, implementing a common interface or base class.
  • Use dependency injection to inject the appropriate state at runtime, making the code more flexible and customizable.
  • Use the State pattern with other patterns, such as the Factory pattern, to create the appropriate state object at runtime.

If you found this article helpful, consider giving it some claps.

--

--

Abnoan Muniz

Senior .NET Developer, Passionate about problem-solving. Support me: https://ko-fi.com/abnoanmuniz, Get in touch: linktr.ee/AbnoanM