Software Design Patterns Not Always the Best Solution
Introduction
Software design patterns are often hailed as the holy grail of best practices in software engineering. They provide pre-defined solutions to common problems, promoting efficiency and readability. However, every tool has its limitations, and design patterns are no exception. This article aims to dive into the nuances of when design patterns can be more of a hindrance than a help.
Overview of Design Patterns
Design patterns are not just technical solutions to common problems; they also serve as a common language that can enhance communication among developers. Concepts like Singleton, Observer, and Strategy have become part of the standard lexicon in software engineering. However, it is crucial to understand that these patterns are not universal remedies, and applying them indiscriminately can have consequences.
Overengineering and Unnecessary Complexity
Overengineering is perhaps one of the most common issues when it comes to misapplying design patterns. Imagine a scenario where you apply the Factory pattern to create objects in an application that is simple enough and doesn’t require that complexity. This misuse could confuse other developers, causing them to spend more time untangling the code than adding new functionalities.
Example: CRUD Operations
In a simple web application for task management, using the Command pattern for CRUD (Create, Read, Update, Delete) operations might be overkill since modern frameworks often provide these functionalities out of the box.
Command Pattern
When we use the Command pattern, the idea is to encapsulate a request as an object, allowing parameters to be passed, queued, logged, etc.
Let’s imagine a simplified implementation to add a new task:
public interface ICommand
{
void Execute();
}
public class AddTaskCommand : ICommand
{
private Task _task;
private ITaskRepository _repository;
public AddTaskCommand(Task task, ITaskRepository repository)
{
_task = task;
_repository = repository;
}
public void Execute()
{
_repository.Add(_task);
}
}
To use this command, you would create an instance and execute it:
var command = new AddTaskCommand(newTask, taskRepository);
command.Execute();
In this example, notice that the Command pattern introduces an additional layer of complexity that may not be necessary for a simple application.
Simplifying the Approach with Modern Frameworks
Modern frameworks, such as ASP.NET Core, often provide CRUD functionalities in a simple and efficient manner.
Here’s how you could add a task using Entity Framework:
public class TasksController : ControllerBase
{
private readonly MyDbContext _context;
public TasksController(MyDbContext context)
{
_context = context;
}
[HttpPost]
public async Task<ActionResult<Task>> AddTask(Task task)
{
_context.Tasks.Add(task);
await _context.SaveChangesAsync();
return CreatedAtAction("GetTask", new { id = task.Id }, task);
}
}
Modern frameworks often have well-optimized and easy-to-use CRUD functionalities, making the use of additional patterns excessive and even counterproductive in terms of development and maintenance.
Hopefully, this example makes it clear that while design patterns are useful tools, they are not always the best or most efficient option, especially when simpler, more direct solutions are available.
Performance and Efficiency
Efficiency is another factor that can be impacted by the misuse of design patterns. Certain patterns can introduce additional layers of complexity, potentially resulting in latency and increased resource usage.
The Balance Between Abstraction and Performance
Design patterns often offer a layer of abstraction that simplifies software development and maintenance. However, this abstraction can come at a cost: performance. Introducing additional layers, though helpful from an architectural standpoint, can introduce latency and increase resource consumption.
When Abstraction Becomes Overhead
Additional complexity in a system does not come without costs. For example, using the Observer pattern to notify a large number of objects about a state change can lead to a cascade of calls, increasing processing time and potentially causing perceptible delays for the user.
// Example in C# showing how the Observer pattern can cause overhead
public class Observer
{
public void Update()
{
// Operations that could be time-consuming
}
}
public class Subject
{
private List<Observer> observers = new List<Observer>();
public void NotifyObservers()
{
foreach (Observer observer in observers)
{
observer.Update(); // Can become slow if there are many observers
}
}
}
Measuring the Impact
Using performance monitoring and profiling tools can help identify where a pattern is causing issues. These tools can provide useful metrics such as runtime, memory usage, and other key performance indicators.
Pragmatism in Choosing Patterns
Ultimately, the decision to use a design pattern should be based on a rigorous analysis of the project’s needs and constraints. If a pattern introduces more complexity than value, it might be wise to consider simpler, more direct alternatives.
Context and Specificities
Not all scenarios require the complexity or generalization provided by design patterns. Additionally, some platforms or runtime environments have their own idiosyncrasies that make certain patterns unsuitable.
Example: Embedded Systems
In embedded systems where memory efficiency is a major concern, using the Observer pattern to implement functionalities may not be viable.
Observer Pattern
Suppose we are developing a temperature sensor that sends data to multiple displays. A common approach would be to use the Observer pattern.
public interface IObserver
{
void Update(int temperature);
}
public class TemperatureSensor
{
private List<IObserver> observers = new List<IObserver>();
public void AddObserver(IObserver observer)
{
observers.Add(observer);
}
public void RemoveObserver(IObserver observer)
{
observers.Remove(observer);
}
public void Notify(int temperature)
{
foreach (var observer in observers)
{
observer.Update(temperature);
}
}
}
While this approach is elegant and extensible, it can be costly in terms of memory, especially if we have a large number of observers or if the embedded system has very limited resources.
Callback Functions as an Alternative Technique
In contrast, an alternative technique that deviates from more formal design patterns would be the use of callback functions. This approach is much less costly in terms of memory and may be more suitable for an embedded system with limited resources.
public delegate void TemperatureUpdate(int temperature);
public class SimpleTemperatureSensor
{
private TemperatureUpdate callback;
public void RegisterCallback(TemperatureUpdate callback)
{
this.callback = callback;
}
public void Notify(int temperature)
{
callback?.Invoke(temperature);
}
}
While callback functions might be considered a pattern in other contexts, in this example, they are presented as a pragmatic and direct technique that avoids the complexity of more formal design patterns. The goal here is to demonstrate that simpler and more direct solutions can be more resource-efficient and easier to implement and maintain, especially in environments with severe constraints.
This approach is much less costly in terms of memory and may be more suitable for an embedded system with limited resources. It is also much simpler to implement and understand, which can be critical in an environment where debugging is difficult.
Team and Learning Curve
The “Best Pattern” Dilemma
It is common in software engineering teams to seek the “best practice” or the “best pattern” to apply. However, what is often overlooked is the fact that “best” is relative to the team’s context. A pattern can be extremely effective in an environment where everyone is familiar with it, but it can be counterproductive if introduced to a team without adequate knowledge.
Learning Curve and Productivity
Implementing a new design pattern can require a significant investment in training and adaptation time. This is especially true for more complex patterns like CQRS (Command Query Responsibility Segregation) or Event Sourcing. The learning curve can lead to implementation errors, which, in turn, can compromise code quality and increase maintenance time.
See an example of a common error with Singleton:
// Example of incorrect Singleton implementation by an inexperienced team
public class BadSingleton
{
private static BadSingleton instance;
public static BadSingleton Instance
{
get
{
if (instance == null)
{
instance = new BadSingleton();
}
return instance;
}
}
// This Singleton is not thread-safe and can cause issues in concurrent environments
}
Communication and Collaboration
Lack of familiarity with specific patterns can also create communication barriers within the team. If half the team understands the Strategy pattern while the other half doesn’t, this can lead to misunderstandings and inefficiencies.
Skill Assessment and Training
Before adopting a specific pattern, it is essential to assess the team’s comfort level and experience. Training can be provided to level up knowledge, but this is a time and resource investment that should be considered.
Alternatives to Conventional Patterns
There are occasions when ad hoc solutions, developed with specific context in mind, can be more effective than applying a universal design pattern
.
Example: Simple Chat Application
In a simple chat application, instead of using a complex state system with the State pattern, a simple set of if-else
conditionals might be more than sufficient to manage the chat state.
State Pattern
Let’s start with an example that uses the State pattern to manage the chat state.
public interface IChatState
{
void SendMessage(string message);
}
public class OnlineState : IChatState
{
public void SendMessage(string message)
{
// Implementation to send message when the user is online
}
}
public class OfflineState : IChatState
{
public void SendMessage(string message)
{
// Implementation to store the message for later sending
}
}
public class Chat
{
private IChatState state;
public void SetState(IChatState state)
{
this.state = state;
}
public void SendMessage(string message)
{
state.SendMessage(message);
}
}
While this approach is clean and extensible, it may be considered overkill for a simple chat application, especially if the only variation is between online and offline states.
Simple Approach with if-else
Now let’s see how this could be done in a more direct way using if-else
conditionals.
public class SimpleChat
{
private bool isOnline;
public void SetIsOnline(bool isOnline)
{
this.isOnline = isOnline;
}
public void SendMessage(string message)
{
if (isOnline)
{
// Implementation to send message when the user is online
}
else
{
// Implementation to store the message for later sending
}
}
}
As you can see, this approach is much simpler and more direct. It’s easy to understand and doesn’t require developers to learn a design pattern to contribute to the code. This example serves to illustrate that, in some cases, a simpler solution might be more appropriate.
Conclusion
Design patterns emerged as a way to solve recurring problems in software engineering effectively and efficiently. However, as explored throughout this article, they are not a universal panacea. Each pattern has its ideal context of application, and using the wrong pattern — or using the right pattern the wrong way — can result in more problems than solutions.
In this sense, the keyword is “balance.” Design patterns should be employed in a balanced way and always with a critical eye on the specific context of the project. This includes considering the technologies employed, resource constraints, and even the team’s experience and comfort level with the proposed patterns. The decision to use or not use a pattern should be pragmatic, not dogmatic.
It is also crucial to understand that patterns can be adapted and customized according to the specific needs of a project. Patterns are more like guidelines than fixed rules, and as such, are subject to evolution and adaptation.
This article aimed to highlight the importance of context in the choice and implementation of design patterns. We analyzed practical examples, from embedded systems to CRUD operations in web applications, highlighting situations where the use of patterns might be reconsidered in favor of simpler or more direct solutions.
Therefore, while design patterns are an invaluable tool in any developer’s arsenal, it’s essential to remember that they are just that: a tool. Like any other tool, they have their limitations and should be used with discernment. We hope this article serves as a practical guide for when — and why — to avoid or adapt the use of design patterns in your software engineering projects.
References
- Gamma, Erich, et al. “Design Patterns: Elements of Reusable Object-Oriented Software.”
- Martin, Robert C. “Clean Code: A Handbook of Agile Software Craftsmanship.”
- Fowler, Martin. “Refactoring: Improving the Design of Existing Code.”