The importance of object-oriented design patterns in software development
Object-oriented (not orientated!) design is a fundamental principle of modern software engineering, a crucial concept that all developers need to understand and employ effectively. Software design patterns like object-oriented design serve as universal solutions to common problems, across a range of instances and domains. As software engineers advance in their careers, they actually often start using these patterns instinctively, even without knowing it.
However, there’s a tough balance to strike. A developer who lacks knowledge or experience with these object-oriented design patterns often strays into suboptimal, ad-hoc solutions that violate key software engineering principles, such as code reusability and separation of concerns, potentially resulting in harder long-term maintenance.
On the other hand, misusing these patterns or applying them too rigidly can cause unnecessary complexity, resulting in tangled code with too many layers of abstraction. Overuse can make the codebase difficult to understand and navigate, slowing down development and increasing the likelihood of errors.
In part one of this article, we'll explore 4 of the most common object-oriented programming patterns using an array of example programming languages. We'll discuss how to apply these patterns in your software development effectively, compare them to ad hoc solutions, and demonstrate some common antipatterns that result from misuse or overuse. Our goal is to provide you with a comprehensive understanding of these patterns, help you avoid common pitfalls, and equip you with best practices to apply these patterns effectively in your development work.
This is part one of a thorough, 2-part exploration of these OOP patterns, covering the extension design pattern, the singleton pattern, the exception shielding pattern and the object pool pattern. Find part two here, exploring composite bridge, iterator and lock design patterns.
In this post:
1. Extension method {#extension-method}
Defining the extension design pattern
The extension method is a design pattern that solves a common problem: adding functionality to an existing class without modifying its source code. This is especially useful when you don’t have access to the original class source code or need to add functionality to a system type.
Using the extension design pattern
The extension method must be used judiciously. It’s tempting to lean on this object-oriented pattern as a convenient way to add functionality to a class, even when you have direct access to the class' source code. Overuse or misuse of the extension method can lead to unnecessary complexity, resulting in a convoluted code structure that’s difficult to understand and navigate. This is a stark example of how the inappropriate application of a design pattern can hinder development and increase the risk of errors.
Use the Extension Method with care. It's well-suited for small utilities or for extending system types, but not for types that you own and can modify directly. Instead, aim to extend the actual class with new methods, preserving code cleanliness and maintainability and adhering to object-oriented programming best practices.
Without
As an illustrative example of the extension design pattern in C#, let's look at a case-insensitive string comparison. In a direct approach, the developer might use the Equals
method with an extra parameter for StringComparison.OrdinalIgnoreCase
. This works, but can get clunky with repeated use. We could also create a new static method for comparing two strings, but this would violate object-oriented principles by decoupling data from functionality.
{{< highlight csharp>}}
string myString = "TeSt!";
/* Ad Hoc Option 1 */
if (myString.Equals("test!", StringComparison.OrdinalIgnoreCase)) { … }
/* Ad Hoc Option 2 */
if (NewStaticMethodEqualsIgnoreCase(myString, "test!")) { … }
{{< /highlight >}}
With
Instead, an elegant solution uses the Extension Method. We can create a new method, EqualsIgnoreCase
, which extends the string
class, yielding natural and readable code while preserving the object-oriented principle of associating data with functionality.
{{< highlight csharp>}}
public static class StringExtensions
{
public static bool EqualsIgnoreCase(this string str, string compareWith)
{
return str.Equals(compareWith, StringComparison.OrdinalIgnoreCase);
}
}
string myString = "TeSt!";
if (myString.EqualsIgnoreCase("test!")) { … }
{{< /highlight >}}
Antipattern
We still need to beware of falling into antipatterns. The extension method is not a one-size-fits-all solution. When a developer owns a class and needs to perform data manipulation entirely within the scope of that class, it’s more appropriate to add a new method within the class itself, rather than resorting to an extension method. This ensures we’re leveraging object-oriented design, keeping related data and functionality together in a logical and maintainable way.
{{< highlight csharp>}}
public class Customer
{
public string FirstName { get; set; }
public string LastName { get; set; }
}
public static class CustomerExtensions
{
public static string GetFullName(this Customer customer)
{
return $"{customer.FirstName} {customer.LastName}";
}
}
{{< /highlight >}}
Instead
{{< highlight csharp>}}
public class Customer
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string GetFullName()
{
return $"{FirstName} {LastName}";
}
}
{{< /highlight >}}
2. Singleton {#singleton}
Defining the singleton pattern
The purpose of the Singleton pattern in software design is enforcement of a single instance (i.e., object) of a class throughout a running application. It also provides a global point of access to this instance, making it easily accessible across different components of the system.
Using the singleton pattern
The singleton pattern is helpful where we need a single point of control or coordination, like database connections or logging services. It streamlines these processes by ensuring that the same instance is used consistently across different parts of the software, promoting coherency and reducing potential for error.
However, the Singleton pattern, like all tools, can be misused. It's common, and dangerous, to view the Singleton as a catch-all place for code that doesn't quite fit elsewhere. This can lead to a Singleton that's overburdened and difficult to manage, filled with miscellaneous code that should be organized better. This dumping ground strategy can also make the Singleton hard to test and prone to concurrency issues, given its global accessibility.
Without
Here’s a common scenario in Java, where a developer needs to log events across an application. An initial approach might be to simply create new Logger objects as needed and then discard them once they've served their purpose. This approach, while seemingly straightforward, can lead to problems.
Repeatedly creating and discarding Logger objects can result in subpar performance due to the overhead associated with object creation and garbage collection. Even worse, it can lead to synchronization issues. As various Logger instances write to the log, entries could overlap and become muddled, creating a garbled, confusing log.
{{< highlight shell>}}
void myFunction() {
…
Logger logger = new Logger(...);
logger.log("Message"); /*Potential synchronization conflicts!*/
…
}
{{< /highlight >}}
With
A more efficient solution is to employ the Singleton pattern for our Logger. This way, we create a single instance of the Logger class and store it in a static field within the class itself. To enforce the Singleton behavior, we mark the constructor as private. This prevents any external invocations, stopping additional Logger instances from being created.
Access to the singleton Logger is then managed through a synchronized getInstance method. Synchronization ensures thread-safety, preventing race conditions that could create multiple Logger instances simultaneously.
The Singleton approach also allows a streamlined initialization process for various logging mechanisms. In our example, we need to log events both to Raygun's Crash Reporting service and to local disk storage. Using a Singleton Logger, we can manage both efficiently from a central location.
{{< highlight shell>}}
public class Logger {
private static Logger instance;
private RaygunClient raygunClient;
private DiskLogging diskLogging;
private Logger() {
raygunClient = new RaygunClient("api-key");
diskLogging = new DiskLogging(...);
}
public static synchronized Logger getInstance() {
if (instance == null) {
instance = new Logger();
}
return instance;
}
public void log(String message) {
raygunClient.send(message);
diskLogging.write(message);
}
}
Logger.getInstance().log("Message");
{{< /highlight >}}
Antipattern
While widely used, Singleton critics label it an antipattern. They argue it encourages global state, making the code unpredictable and violating the Single Responsibility Principle. Singletons can also generate concurrency issues, obfuscate dependencies, and complicate refactoring efforts due to their pervasive presence in the codebase. However, Singleton can still be helpful in the right context.
Misuse of the Singleton pattern can violate the tenets of object-oriented design. A common pitfall is using the Singleton as a global data dump, transforming it into a code equivalent of an overstuffed closet. This is clearly illustrated in our example, where the Logger instance has been burdened with a mishmash of global fields that don’t align with its primary function.
Consider the following Java code:
{{< highlight java>}}
public class Logger {
private static Logger instance;
…
private long lastTimeAUserLoggedIn;
private String applicationReleaseVersion;
private Object someGlobalObjectADevAddedTenVersionsAgo;
…
}
Logger.getInstance().someGlobalObjectADevAddedTenVersionsAgo.wtf();
{{< /highlight >}}
Here, the Logger Singleton has been laden with fields such as lastTimeAUserLoggedIn
, applicationReleaseVersion, and a vague someGlobalObjectADevAddedTenVersionsAgo
. The presence of these fields in the Logger
class not only muddles the class's purpose but also makes the system confusing and hard to maintain. Developers are left puzzling over the purpose of someGlobalObjectADevAddedTenVersionsAgo
and why it's even part of the Logger class. This is a prime example of how misusing the Singleton pattern can lead to bloated and confusing code.
Instead
A more sophisticated approach to handle global configurations like the applicationReleaseVersion
is to use Java's built-in Properties
class. This allows you to maintain a set of key-value pairs where both are Strings, making it an ideal choice for storing configuration data.
In this context, we can have a Singleton class AppConfig
that encapsulates a Properties
object. The Properties
object can be populated at the startup of the application, loading key-value pairs from a configuration file. This not only promotes separation of concerns but also allows the configurations to be easily modified without changing the source code.
Here's an example of how to structure the AppConfig Singleton class in Java:
{{< highlight java>}}
public class AppConfig {
private static AppConfig instance;
private static final RaygunClient raygunClient = new RaygunClient("API-KEY");
private Properties properties;
private AppConfig() {
properties = new Properties();
try (InputStream input = getClass().getClassLoader().getResourceAsStream("app.config")) {
if (input == null) {
raygunClient.send("Unable to find config file!");
return;
}
//load properties from the config file
properties.load(input);
} catch (IOException ex) {
raygunClient.send(ex);
}
}
public static synchronized AppConfig getInstance() {
if (instance == null) {
instance = new AppConfig();
}
return instance;
}
public String getProperty(String key) {
return properties.getProperty(key);
}
}
{{< /highlight >}}
In th
is example, the `AppConfig` class loads key-value pairs from a file named app.config located in the classpath at the startup of the application. Then we can use the `getProperty` method to retrieve the application's version or any other configuration data as needed:
{{< highlight java>}}
String applicationVersion = AppConfig.getInstance().getProperty("applicationReleaseVersion");
{{< /highlight >}}
This not only promotes better organization and separation of concerns but also provides a flexible and efficient way to manage application configurations.
3. Exception Shielding{#exception-shielding}
Defining the exception shielding pattern
The exception shielding design pattern aims to prevent the spread of sensitive or unnecessary exception information beyond the architectural boundaries of a system. This is particularly essential in user-facing systems, like web apps, where unhandled exceptions can reveal sensitive details about the system’s inner workings or result in a poor user experience. Exception shielding ensures that the user only sees meaningful, non-sensitive error information.
Using the exception shielding pattern
A key rule of thumb when implementing exception shielding is to only catch exceptions that you can meaningfully handle. This means exceptions where your code can either recover from the error, perform some mitigation steps, or enrich the exception details before rethrowing it. Avoid blanket-catching all exceptions without any handling logic, as this can lead to silently swallowed errors, making debugging a nightmarish process.
Every exception that you catch but can't handle properly should be logged or recorded. Ignoring an exception, or catching it only to rethrow a less specific one without a fix or logging can bury crucial diagnostic information. Effective exception handling should balance protecting the system and preserving information.
Without
Let's consider the following Node.js Express application route handler, designed to fetch and display a customer's data based on their ID.
{{< highlight js>}}
app.get('/customer/:id', (req, res) => {
const customerId = req.params.id;
res.send(displayCustomer(customerId);
});
{{< /highlight >}}
While this route handler is straightforward and concise, it doesn't include any exception handling. This means if something goes wrong within the displayCustomer function, like a database error, or if the customer with the provided ID doesn't exist, an error will be thrown. This unhandled error can potentially crash the entire application, or at least interrupt its normal operation.
Plus, depending on the configuration of your server, this unhandled error might expose sensitive details to the end user, like database schema information or proprietary business logic. This could mean a significant security risk and a poor user experience, sending raw error messages instead of a helpful, user-friendly response.
Antipattern
Let's break down Exception Shielding further via two common antipatterns: Exception Swallowing and Incorrect Exception Chaining or Exception Wrapping. These antipatterns illustrate misuse of try-catch blocks, and even though they can satisfy compiler requirements, they often introduce more problems than they solve.
Antipattern 1: Exception Swallowing
In the following Node.js code snippet, a try-catch block is used, but the catch block is left empty. This is known as exception swallowing. While the code will still run, it can lead to unseen errors, making debugging difficult.
{{< highlight js>}}
/* Antipattern 1: Exception Swallowing*/
app.get('/customer/:id', (req, res) => {
const customerId = req.params.id;
var response = "";
try {
response = displayCustomer(customerId);
} catch (err) {
/*Do Nothing! Exception Swallowed…*/
} finally {
res.send(response);
}
});
{{< /highlight >}}
Antipattern 2: Incorrect Exception Chaining or Exception Wrapping
Incorrect Exception Chaining (or Exception Wrapping) happens when a new generic error is thrown, instead of correctly handling an exception or passing it along with sufficient information. This discards any valuable details from the original error, including its stack trace.
{{< highlight js>}}
/* Antipattern 2: Exception Chaining or Exception Wrapping*/
/*Creating, throwing new exception discards info in original*/
} catch (err) {
throw new Error("My error text");
}
{{< /highlight >}}
With/Instead
The exception shielding design pattern, when used correctly, can significantly enhance your application's robustness and user experience. Below, we're applying this pattern in a Node.js web application, using Raygun error monitoring.
{{< highlight js>}}
const raygun = require('raygun');
const raygunClient = new raygun.Client().init({ apiKey: 'api-key' });
app.get('/customer/:id', (req, res) => {
const customerId = req.params.id;
try {
const response = displayCustomer(customerId);
res.send(response);
} catch (err) {
raygunClient.send(err);
res.status(500).send('Error while processing your request.');
}
});
{{< /highlight >}}
In the above code, any exceptions thrown during the execution of displayCustomer are caught and logged by Raygun. The user-facing response, however, is a generic 500 error message, free of any unnecessary or sensitive exception information.
If you need to throw a new error, always try to attach the original error to preserve useful debugging information. Here's an example:
{{< highlight js>}}
/*Alternatively, if new Error must be thrown, attach the original to it*/
} catch (err) {
raygunClient.send(err);
const higherLevelError = new Error('A higher level error occurred');
higherLevelError.originalError = err;
throw higherLevelError;
}
{{< /highlight >}}
In this snippet, we create a new error, but also keep a reference to the original error. This retains the valuable debugging information from the initial error, even as we raise a new error to be handled at a higher level of our application.
4. Object Pool{#object-pool}
Defining the object pool pattern
The object pool design pattern provides an efficient way to manage resources that are expensive to create and release. It recycles objects that are no longer in use, reducing the costly overhead of resource acquisition and release. Examples could be database connections, thread pools, or large bitmap objects.
Using the object pool pattern
However, like our other object-oriented design patterns, we should use object pool only in the right context. It isn't a universal solution for all types of objects. Some language runtimes provide a multitude of optimizations that work "under-the-hood". For example, in C++ and C#, objects can be allocated on the stack, offering better performance in certain scenarios compared to an object pool.
Without
In the example provided, we have a class Enemy in C++ (inspired by the efficacy of the object pool pattern in game development). To create an instance of the class, we use the new keyword which triggers the constructor, allocating memory on the heap and initializing the new object. Once we're done using the object, we call delete to deallocate the memory.
This is effective for simple applications or when the Enemy instances are infrequently created and destroyed. However, it has its issues, particularly for performance-critical applications or those creating and destroying a large number of instances in short time spans.
Each call to create and delete involves memory allocation and deallocation operations which are expensive in terms of CPU time. If this is happening frequently, it can lead to significant performance overhead. Repeated allocation and deallocation of memory can also lead to heap fragmentation, which can degrade performance over time.
{{< highlight cpp>}}
class Enemy {
private:
…
public:
Enemy() {
…
}
~Enemy() {
…
}
}
Enemy* enemy = new Enemy();
…
delete enemy;
{{< /highlight >}}
With
In this revised object pool example, we implement an EnemyPool class (which falls under the object pool design pattern). Instead of creating and destroying Enemy objects each time we need them, we pre-allocate a pool of Enemy instances and recycle them. This can dramatically reduce the overhead of frequent memory allocation and deallocation.
In the constructor of EnemyPool, a specified number of Enemy objects are pre-allocated and stored in a stack data structure for use when needed. The getEnemy method retrieves an Enemy object from the stack if one is available, otherwise it throws an error. Once the Enemy object is no longer needed, it's returned to the pool using the returnEnemy method, where it gets pushed back onto the stack for reuse.
The destructor of EnemyPool ensures that all the Enemy objects are properly deleted when the EnemyPool object goes out of scope. This prevents memory leaks.
{{< highlight cpp>}}
class EnemyPool {
private:
std::stack<Enemy*> available;
public:
EnemyPool(int size) {
for (int i = 0; i < size; ++i) {
available.push(new Enemy());
}
}
~EnemyPool() {
while (!available.empty()) {
delete available.top();
available.pop();
}
}
Enemy* getEnemy() {
if (available.empty()) {
throw std::runtime_error("No enemies available");
}
Enemy* enemy = available.top();
available.pop();
return enemy;
}
void returnEnemy(Enemy* enemy) {
available.push(enemy);
}
};
{{< /highlight >}}
This design pattern is beneficial for performance-critical applications or if we’re creating and destroying a large number of instances rapidly. It reduces the number of memory allocation and deallocation operations, mitigates heap fragmentation, and generally increases application speed and efficiency. But be careful not to hold onto Enemy objects for longer than necessary, to ensure that there are always enough available in the pool.
Antipattern
The misuse of the Object Pool pattern can lead to unnecessary complexity and potential memory leaks. The antipattern presented here shows an Enemy object being borrowed from the EnemyPool and then returned back to the pool within a function. This may seem correct, but it poses a few problems.
A big issue arises if an exception gets thrown or an early return is encountered within the function, skipping the returnEnemy method. In this case, we end up with a memory leak as the Enemy object is never returned to the pool and can’t be reclaimed.
{{< highlight cpp>}}
void functionLocalEnemy(EnemyPool enemyPool) {
const enemy = enemyPool.getEnemy();
…
enemyPool.returnEnemy(enemy);
}
{{< /highlight >}}
Instead
Instead of using the Object Pool pattern in this situation, you can try a simpler approach. If the scope of an object is limited to a single function or block, it might be more efficient and safer to allocate it on the stack. When you declare an Enemy object inside a function like functionLocalEnemy, the Enemy instance is created on the stack when the function is entered and automatically deallocated when the function exits. This stack-based allocation and deallocation is handled by the runtime, so there's no need to remember to deallocate the object manually, preventing potential memory leaks.
{{< highlight cpp>}}
void functionLocalEnemy(EnemyPool enemyPool) {
Enemy enemy; /*Allocate on the stack*/
…
} /*Stack object automatically deallocated on function exit*/
{{< /highlight >}}
Wrapping up{#wrap}
That’s the first 4 of our 7 essential object-oriented software patterns, and you should now have a solid grasp of the extension method, singleton, exception shielding, and object pool design patterns. (You can find part two here, exploring composite bridge, iterator and lock design patterns.) These patterns, used appropriately, provide robust solutions to common challenges, leading to more efficient and resilient software development. These are among the most common object oriented design patterns for good reason, and serve as vital tools in a developer's arsenal, promoting code reusability, separation of concerns, and overall software engineering best practices.
However, we have to reiterate how important it is to be discerning in the application of these patterns. As shown by our examples, misuse or over-application can lead to a tangle of complexity, obscuring the simplicity and clarity that characterizes good code. A proficient software engineer understands when a pattern adds value and when it might obscure the essence of the code. Striking a balance between robust design patterns and clean, simple code is a show of serious engineering finesse, and is a skill to practise and hone over time.
To assist in this process, developers can leverage software intelligence tools like Raygun Crash Reporting, which can provide deep insights into your application's health, alerting you when things go wrong and helping you diagnose the root cause of issues more efficiently. Raygun's Real User Monitoring (RUM) tool can provide valuable insights into how your application performs in real-world conditions, helping you understand where performance bottlenecks may exist and guiding optimization efforts. And lastly, Raygun's Application Performance Monitoring (APM) tool can give you the visibility you need to understand how effectively your design patterns are performing in your live environment, alerting you to any issues before they become problematic. Take a 14-day trial of Raygun, no credit card needed, for free here.
With the intelligent use of design patterns and the powerful insights offered by Raygun's tools you'll be building bulletproof software that is efficient, maintainable, and high quality. Happy coding!
Author & Affiliations
Dr. Panos Patros, CPEng
Principal Engineer, Raygun
New Zealand Adjunct Professor in Computer Science, IBM Center of Advanced Studies-Atlantic, UNB
Canada Steering Committee Member, IT Engineers, Engineering New Zealand
Top comments (0)