Last time, we discussed the singleton pattern and looked how it can be easily implemented in a correct way by leveraging the
Lazy<T> class. Now let’s look at why you almost certainly don’t want to do this.
The key problem is that any decision “there should be only one of these” is a policy decision, not a technical one.
Policies change over time.
Enforcing policies like the Singleton pattern in the structure of your application will, sooner or later, end up as causing you, your team, or your successors, significant pain.
Consider one of the classic examples for use of the singleton pattern - logging.
At first inspection - and indeed upon second as well - it seems a bit of a no-brainer to use the singleton pattern for logging. After all, all of the logging from the application needs to go to the same log destination (whether a file, a database, a micro-service or something else) and we don’t really want to be passing around references to loggers. Our code might look like this:
… what do we do if/when someone wants to easily correlate all of the logging for processing of a single action (or request) in the logs?
One way to do this is to introduce a correlation id (often a Guid), that is included in every log message; once a support engineer finds one message, they can then do a simple search to find all the related log entries across the entire system.
With the code as it’s written above, this is a laborious change to make - we need to touch every logging line and verify that the appropriate information is being included. If not, we must make the right change to introduce the correlation id.
In our example, we already have a suitable correlation value on the request, so we make the changes:
Did you spot both errors in the above code? One of the messages still lacks the correlation id, and one of the others has parameters around the wrong way. Both problems are easy to miss in the midst of larger code changes. Any time we need to make blanket changes across the code, mistakes can be easy to make and difficult to spot.
There must be a better way.
Let’s start by minimizing our reliance on the singleton by using a member reference for our logger:
We now have a decision to make - how do we get our logger when we need it?
Our constructor might require the desired logger to be explicitly passed as a parameter, delegating the decision to our consumers. This is likely the right choice in most cases.
We might grab the reference from the singleton, effectively caching the value for fast and easy access. This would make the choice of locking algorithm used for the singleton largely irrelevant by dramatically reducing access contention.
Or, we might use a hybrid approach by allowing a chosen logger to be passed as an optional parameter to our constructor, using the singleton as a fallback if our consumer declines to provide. This can be a really good technique for refactoring as it allows you to progressively reduce your use of an existing singleton without breaking the rest of the system.
Let’s rewrite our example using this hybrid approach:
Now we can revisit the idea of adding a correlation id to our output logs - instead of changing every single logging line, we need only introduce a prefixing logger within our constructor:
Note how the logging for
ProcessRequest() is now much simpler.
What did we gain by adopting this approach?
One place to change - instead of a new logging requirement requiring us to find, and change, every reference to the singleton, we only need to make a change in one place. We don’t need to pass around the correlation id and manually include it in every log message - and if there’s already a prefixing logger in place with its own correlation id, we cooperate & interoperate with it predictably.
Improved performance - instead of having to read anew from the singleton every time we need to emit a log message, caching a reference to our logger in a member field gives us immediate and uncontested access. The effect of any contention for access through the locks on the singleton is almost completely eliminated. Admittedly, this is a minor point because the actual effect on logging performance is likely to be swamped by other effects - but this could be important in other scenarios where access to the singleton within a tight loop was needed.
Failing early - if there’s a problem with access to our singleton (say, if the logging configuration is read from a file), we’ll find out about the problem right at the start, instead of waiting until we’re part way through and need to log something. This helps us to find and fix problems much faster.
Support for dependency injection - By accepting a logger to our constructor, we can work without referencing the singleton at all. We now can write and run unit tests without needing to worry about any external configuration or database connections or anything else normally required by our logging framework. If our test doesn’t care about logging, we can implement a
NullLogger that throws away messages. If the test does care about logging, we can implement a
TestLogger that accumulates the messages and allows us to assert on their content.
The key lesson here is an old one, but worth repeating: Maximize your ability to accommodate future changes by minimizing your dependencies (aka strive for low coupling).
Implementing and enforcing the singleton pattern in your code encourages very high coupling and the associated pain that comes with future change.