Nothing is easier than complicating something simple. That’s what I think in many cases when design patterns are mentioned and in this post, we’ll see why.
Index
1 - What are design patterns?
I want to start by explaining what design patterns are, because unlike what many people think, a design pattern is not a piece of code, but a name for a solution to a problem in code.
We classify these patterns into three different groups: creational, structural, and collaborational. This distinction comes from the book Design Patterns: Elements of Reusable Object-Oriented Software, where the authors identified these groups since they distinguish intent, not implementation, within object-oriented programming in software development.
If you’ve been doing this for a long time, you will have realized that the use of design patterns varies according to the developer’s experience. There’s a simple reason for this: less experienced people don’t know them, so they don’t apply any. One day you learn about a certain pattern and want to use it everywhere, and finally, you reach the third stage, when you realize that applying design patterns in some cases is using a sledgehammer to crack a nut, or worse, trying to solve a problem you don’t even have, just because you want to implement a pattern.
2 - So should you use design patterns or not?
This is the point I wanted to get to, because design patterns are very good and help both code and developers a lot when they’re implemented where and how they should be, since they provide a common language for developers and keep code much easier to read and maintain for everyone.
If you don’t use it well, you can end up with a builder pattern (which I mention later) that brings no value. For example, if you have a pizza object, you have several options, you could have an object with its constructor:
new pizza(toppings, size)
, or you could have the following:
new Pizza.WithToping(a).WithTopping(b).WithTopping(c).Size(medium).build()
; Which you might think adds value, but in reality is just a wrapper over the constructor. A more correct implementation is new Pizza.Margarita(size)
, or new Pizza.Hawaiian().WithExtraPineaple().Build()
;
Although the first alternative I mentioned is better than having 20 different constructors with default values. So, using patterns correctly is as important as implementing them well.
There are situations where you can avoid them or use alternatives that may be more suitable.
For instance, a design pattern is the singleton, where you only have one instance of an object, but nowadays you can use dependency injection, where the end result is practically the same, but in my opinion, the implementation is better with dependency injection.
There are situations where it can cause more work than it saves. In that case, it’s better not to do it. Or if you use builder, you don’t have to do it for every object, you can implement builder only where you need it.
A pattern can even not only create more work, but you might end up needing to modify or add practices in your code that make no sense just to force-fit a pattern.
With this in mind, there are a series of patterns that I find very useful for professional programming that can help us a lot at different stages of software development.
3 - Design Patterns
I don’t want to make a post or a course that talks about all design patterns, but I’ll show you the most essential ones, in my opinion, along with their real-world implementation.
3.1- Creational Design Patterns
Creational patterns are those used when creating objects.
Among these we have Singleton, Factory Method, and Abstract Factory.
3.1.1 - Builder Pattern
Although I briefly mentioned it before, with the builder pattern what we do is build objects step by step through methods instead of using the constructor.
This ensures that the resulting object is always valid, and in the long run, if we have many variables (like in the pizza example), it makes code maintenance easier.
In C# it can be done as follows:
namespace DesignPatterns.Creationals;public enum Size { Small, Medium, Large }public class Pizza{ public Size Size { get; } public IReadOnlyList<string> Toppings { get; } private Pizza(Size size, IEnumerable<string> toppings) { Size = size; Toppings = toppings.ToList().AsReadOnly(); } public sealed class PizzaBuilder { private Size _size = Size.Medium; private readonly List<string> _toppings = new(); public PizzaBuilder WithSize(Size size) { _size = size; return this; } public PizzaBuilder AddTopping(string topping) { _toppings.Add(topping); return this; } public PizzaBuilder AddExtraTopping(string topping) { _toppings.Add(topping); _toppings.Add(topping); // +1 unidad extra return this; } public PizzaBuilder RemoveTopping(string topping) { _toppings.RemoveAll(t => t.Equals(topping, StringComparison.OrdinalIgnoreCase)); return this; } public PizzaBuilder AddCheese() => AddTopping("Cheese"); public PizzaBuilder AddExtraCheese() => AddExtraTopping("Cheese"); public PizzaBuilder RemoveCheese() => RemoveTopping("Cheese"); public PizzaBuilder AddHam() => AddTopping("Ham"); public PizzaBuilder AddExtraHam() => AddExtraTopping("Ham"); public PizzaBuilder RemoveHam() => RemoveTopping("Ham"); public PizzaBuilder AddPineapple() => AddTopping("Pineapple"); public PizzaBuilder AddExtraPineapple() => AddExtraTopping("Pineapple"); public PizzaBuilder RemovePineapple() => RemoveTopping("Pineapple"); public PizzaBuilder AddTomatoes() => AddTopping("Tomatoes"); public PizzaBuilder AddExtraTomatoes() => AddExtraTopping("Tomatoes"); public PizzaBuilder RemoveTomatoes() => RemoveTopping("Tomatoes"); public PizzaBuilder AddBasil() => AddTopping("Basil"); public PizzaBuilder AddExtraBasil() => AddExtraTopping("Basil"); public PizzaBuilder RemoveBasil() => RemoveTopping("Basil"); public Pizza Build() { return new Pizza(_size, _toppings); } } public static PizzaBuilder Margarita(Size size = Size.Medium) => new PizzaBuilder().WithSize(size) .AddCheese() .AddTomatoes() .AddBasil(); public static PizzaBuilder Hawaiian(Size size = Size.Medium) => new PizzaBuilder() .WithSize(size) .AddHam() .AddPineapple();}
When to use builder pattern?
For objects where you can have many variations of the same thing, like the pizza example, and then you want it to be immutable.
3.1.2 - Factory Method Pattern
With the Factory Method pattern, we define a class where we delegate the responsibility of creating objects and the functionality to its subclasses, so the main class acts as a parent for others and never as a standalone object.
In C# we do this with abstract classes. This would be the implementation in C# continuing with the pizza example.
public abstract class PizzaStore{ protected abstract Pizza CreateStarPizza();}public class MadridStore : PizzaStore{ protected override Pizza CreateStarPizza() { return Pizza.Margarita().Build(); }}public class ParisStore : PizzaStore{ protected override Pizza CreateStarPizza() { return Pizza.Margarita().AddTopping("baguette").Build(); }}
Each child class implements the parent abstract class.
When to use Factory Method Pattern?
When the main class using a service must not know the implementation of that service. In .NET, ILogger is used as an interface for logging, but behind that interface, you can put Serilog, Log4Net, etc. The consumer doesn’t care which one.
3.2 - Structural Design Patterns
Structural design patterns are mainly used for the relationship between objects and how to separate or make them independent so that a change in one object doesn’t affect the whole system.
Among this group we have bridge, decorator, composite, facade, adapter, flyweight, and proxy.
3.2.1 - Adapter
One of the most used patterns in professional environments is the adapter, as it allows two interfaces to work together when they are not directly compatible.
The clearest example of where this code is used is working with systems we don’t have access to. These systems can be external APIs, or even the modernization of current systems (from monolith to microservices) where we want to leave behind old practices and build the microservice according to the latest standards.
In this way, with the adapter you can maintain temporary compatibility until all uses of the “old” way change (assuming this change ever happens).
Other common cases include when you’re working with external services that require data your normal process doesn’t generate. Instead of modifying the normal process, you do it only in your adapter layer.
3.3 - Behavioral Design Patterns
Behavioral patterns are used to identify and define communication between objects.
Some are more famous or widely used, and it’s also the group with the most options: chain of responsibility, command, memento, state, iterator, mediator, observer, strategy, template method, and visitor.
Here we’ll look at the two most popular or common in business applications.
3.3.1 - Mediator Pattern
Everyone’s favorite pattern, especially in C#, is the mediator pattern. We already discussed it in a post on the topic, but to summarize, it’s a pattern that allows classes or objects to communicate indirectly, and in C#, Mediatr handles the communication between them.
[ApiController][Route("[controller]")]public class MediatRExampleController : ControllerBase{ private readonly ISender _sender; public MediatRExampleController(ISender sender) { _sender = sender; } [HttpPut("item")] public async Task<bool> UpdateItem(ItemDto itemDto) => await _sender.Send(new UpdateItemCommand() { Id = itemDto.Id, Price = itemDto.Price, Title = itemDto.Title });}// Execution of business logicpublic class ItemUpdatedEventHandler : INotificationHandler<ItemUpdated>{ //We dont need this working for the example. public async Task Handle(ItemUpdated notification, CancellationToken cancellationToken) { decimal percentageDifference = ((notification.NewPrice - notification.OldPrice) / notification.NewPrice) * 100; if (percentageDifference <= -30) { Console.WriteLine("Logic to get who is wishlisting that ID and send the emails"); } }
From our code, we invoke the mediator, and it’s the mediator who redirects the call.
When to use the mediator pattern?
When you want to decouple the different layers of a system. It’s especially helpful in microservices to avoid spaghetti code by cutting direct dependencies.
3.3.2 - Observer Pattern
The observer pattern allows an object in the system to maintain a list of subscribers, which will automatically be notified when the state changes or a new event occurs.
This way, neither subscribers nor subscribed objects are tightly coupled to each other.
// Client subscribes and reactsStock st = new Stock();st.PropertyChanged += (_, e) => Console.WriteLine($"{e.PropertyName} cambia a {st.Price}");st.Price = 10;st.Price = 35;class Stock : INotifyPropertyChanged{ private int _price; public int Price { get => _price; set { if (_price == value) return; _price = value; OnPropertyChanged(nameof(Price)); } } public event PropertyChangedEventHandler? PropertyChanged; private void OnPropertyChanged(string p) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(p));}
In C# we have a widely used implementation of the observer pattern if we use WPF or MVVM in general, since the INotifyPropertyChanged
property is basically the observer pattern.
When to use the observer pattern?
When you need elements to react to others without touching the original object.
In the example we just saw, we are printing to the console, but it could be a UI with a graph where the data is updated from the database, so you would update the graph in real time.
Conclusion
Design patterns are a great tool, but like any other, we must use them wisely when necessary. The only way to gain the necessary knowledge of when to implement them is through experience. There’s no other way.
If there is any problem you can add a comment bellow or contact me in the website's contact form