Software Design Patterns
Software engineering can be viewed as an ever-expanding field of categorically unique problems that are grouped together in subsets to achieve new goals. Under this premise, it can be easy to assume that existing, known approaches won’t always work and only new ideas will prevail to meet new goals. This is actually an anti-pattern called “Reinventing the Wheel”, and can lead to subpar implementations.
Take 2+2. It’s a known math problem, and the foundation for many new forms of mathematics that venture into the theoretical. The most basic approach for addition is to count from the first number by the second number of integers. This approach works for all numbers, but it’s cumbersome and time consuming when approaching large numbers. For those, it’s easier to line up the numbers in stacks and add up each column of digits individually, accounting for a carry digit from the previous column. This is a known approach, and it works regardless of base digits. Whether adding 2+2 in decimal (4), octal (04), hexadecimal (0x04), quartenary (10), trinary (11), or binary (100, after “2” to “10”), the pattern is the same and is a known best-practice for efficiency.
The same came be applied to software. Consider a software module that may be prone to transient errors. A software engineer may want to wrap calls to that module in some kind of retry logic knowing that it will eventually be correct:
DbClient dbClient = new DbClient();
// ...
SomeObject someObject = null;
while (someObject == null)
{
try
{
someObject = dbClient.Query<SomeObject>();
}
catch (DbException dbe)
{
// ephemeral connection issue - retry
}
catch (DataException dataError)
{
break; // bad data - abort
}
}
A bad software engineer will look at that snippet and shrug. A good software engineer will look at that snippet and throw up in their mouth or – preferably – a nearby trash can. This code has a significant performance implication that it will just keep retrying until the query works. If the database connection is down, it will just happily consume CPU cycles retrying until the local system suffers a power failure.
To alleviate this, one could change the while loop to a bounded for loop. But if the code features a lot of calls to DbClient, then that’s a lot of refactoring to change retry loops around those calls. And if someone ever decides to tweak the base iteration limit in the for all of those loops, that’s another large set of changes.
Another solution would be to use a software design pattern called the “Strategy Pattern”. The strategy pattern seeks to allow software to swap out strategies for dealing with problems at runtime without modifying the code. This can be achieved through a basic abstraction:
public interface IRetryStrategy
{
void Attempt(Action callback);
T Attempt<T>(Func<T> callback);
}
This retry strategy abstraction could have multiple implementations:
// retry until it works
public class AlwaysRetryStrategy : IRetryStrategy { /* ... */ }
// retry some set number of times
public class LimitedRetryStrategy : IRetryStrategy
{
public int RetryLimit { get; set; }
/* ... */
}
// retry with an increasing delay in case the system is under load
public class ExponentialBackoffRetryStrategy : IRetryStrategy
{
public TimeSpan InitialRetryDelay { get; set; }
public double Exponent { get; set; }
/* .... */
}
// for one-off liveness requests or if the system is under extreme load
public class NeverRetryStrategy : IRetryStrategy
{
/* .... */
}
Using one of these strategies could greatly simplify the DbClient code:
DbClient dbClient = new DbClient();
// ...
IRetryStrategy retryStrategy = new LimitedRetryStrategy(10);
try
{
SomeObject someObject = retryStrategy
.Attempt(() => dbClient.Query<SomeObject>());
}
catch (Exception e)
{
// handle error since we've already retried 10 times
}
This could be combined with the Factory Pattern that could read strategy information from teh application’s configuration, and could even monitor the system’s state to return different strategies at runtime (such as the “NeverRetryStrategy” when under load):
DbClient dbClient = new DbClient();
IFactory<IRetryStrategy> retryStrategyFactory = new RetryStrategyFactory();
// ...
IRetryStrategy retryStrategy = retryStrategyFactory.CreateStrategy();
try
{
SomeObject someObject = retryStrategy
.Attempt(() => dbClient.Query<SomeObject>());
}
catch (Exception e)
{
// handle error since we've already retried according to the strategy
}
“That’s great,” some developers may think, “but what makes this a pattern?” Simple: change the scenario and replace some words. Imagine a requirement to cache results from the database in order to speed up performance in some key areas, but not in others. This could be achieved with a caching strategy:
public interface ICacheStrategy
{
TItem GetCachedItem<TItem, TKey>(TKey key, Func<TItem> fillCache);
}
// only cache the most recently used items
public class MostRecentlyUsedCacheStrategy : ICacheStrategy
{
public int MaxCachedItems { get; set; }
/* ... */
}
// auto-expunge cached items after some time
public class LimitedWindowCacheStrategy : ICacheStrategy
{
public TimeSpan CahceTimeToLive { get; set; }
/* ... */
}
// don't cache, ever
public class NeverCacheStrategy : ICacheStrategy
{
/* ... */
}
By just replacing a handful of words and parameters, the meaning and utility of the classes have changed while retaining the basic semantics.
Some software engineers might look at this, shrug, and say “all I see are code snippets and some search-and-replace refactoring.” To these so-called engineers who cannot see the forest through the trees, I ask what else do you think patterns are?
Unlike principles, software design patterns are tangible, concrete code entities that can be incrementally refactored to suit a particular purpose. There are many software design patterns. The Factory Pattern, Abstract Factory Pattern, Prototype Pattern, Twin Pattern, Double-Checked Locking Pattern, Flyweight Pattern, Translator Pattern, Interpreter Pattern, and Collector Pattern to name a small few. In general, these patterns are meant to solve specific types of problems and are generally viewed as best-case implementations for those problems.
When used correctly, software design patterns can increase code legibility, decouple implementations, and greatly improve system performance. However, sometimes a pattern is misused, or a known bad pattern is used instead. These become anti-patterns.