Open sandboxFocusImprove this doc

Example: The Modern Singleton Pattern

In modern applications, the classic singleton pattern often proves unsuitable. It's even argued that it is an antipattern. Here are two main reasons why:

  • We may want to inject a dependency into the singleton, which requires a public constructor.
  • We may want to isolate tests from each other, with each test having its own instance of each singleton. This is especially useful when unit tests are executed concurrently.

In the modern singleton pattern, the singleton's lifetime is managed by the dependency injection container, typically using the AddSingleton method of IServiceCollection.

In both scenarios, a public constructor is needed, one that accepts dependent services as parameters.

Unlike the classic pattern, modern singletons do not struggle with boilerplate code or consistency issues. However, they do present a significant safety problem: there is nothing preventing someone from creating multiple instances of the class in production code by directly calling the constructor. Let's examine how to mitigate this risk.

Consider a modern version of our PerformanceCounterManager service, which we used in the previous article. It now depends on an IPerformanceCounterUploader interface that could have a production implementation (for instance, to upload to AWS) and a testing one (to store records for later inspection).

1using System.Collections.Concurrent;
2using System.Diagnostics;
3
4[Singleton]
5public class PerformanceCounterManager
6{
7    private readonly Stopwatch _stopwatch = Stopwatch.StartNew();
8
9    private readonly ConcurrentDictionary<string, int> _counters = new();
10
11    private readonly IPerformanceCounterUploader _uploader;
12
13    public PerformanceCounterManager( IPerformanceCounterUploader uploader )
14    {
15        this._uploader = uploader;
16    }
17
18    public void IncrementCounter( string name ) => this._counters.AddOrUpdate( name, 1, ( _, value ) => value + 1 );
19
20    public void UploadAndReset()
21    {
22        Dictionary<string, int> oldCounters;
23        TimeSpan elapsed;
24
25        lock ( this._stopwatch )
26        {
27            oldCounters = this._counters.RemoveAll();
28
29            elapsed = this._stopwatch.Elapsed;
30            this._stopwatch.Restart();
31        }
32
33        foreach ( var counter in oldCounters )
34        {
35            this._uploader.UploadCounter( counter.Key, counter.Value / elapsed.TotalSeconds );
36        }
37    }
38}

Typically, services are added to the IServiceCollection using code like this:

1using Microsoft.Extensions.DependencyInjection;
2
3internal static class Startup
4{
5    public static void ConfigureServices( IServiceCollection serviceCollection )
6    {
7        serviceCollection
8            .AddSingleton<IPerformanceCounterUploader, AwsPerformanceCounterUploader>();
9
10        serviceCollection.AddSingleton<PerformanceCounterManager>();
11    }
12}

Generally, the Startup class is the only production component that should be allowed to create instances of singletons. The only potential exceptions are unit tests.

To validate this constraint, we can use Metalama architecture validation (see Verifying usage of a class, member, or namespace) and permit the constructor to be called only from the Startup class or from a test namespace. This can be accomplished by creating a type-level SingletonAttribute aspect and using the Metalama.Extensions.Architecture package to control authorized references.

1using Metalama.Extensions.Architecture;
2using Metalama.Extensions.Architecture.Predicates;
3using Metalama.Framework.Aspects;
4using Metalama.Framework.Code;
5
6public class SingletonAttribute : TypeAspect
7{
8    public override void BuildAspect( IAspectBuilder<INamedType> builder )
9        => builder.Outbound.SelectMany( t => t.Constructors )
10            .CanOnlyBeUsedFrom(
11                scope => scope.Namespace( "**.Tests" )
12                    .Or()
13                    .Type( typeof(Startup) ),
14                "The class is a [Singleton]." );
15}

Now, if we attempt to instantiate the PerformanceCounterManager from production code, we receive a warning:

1internal class IllegalUse
2{
3    public void SomeMethod()
4        =>
5
6            // This call is illegal and reported.
            Warning LAMA0905: The 'PerformanceCounterManager.PerformanceCounterManager(IPerformanceCounterUploader)' constructor cannot be referenced by the 'IllegalUse' type. The class is a [Singleton].

7            _ = new PerformanceCounterManager( new AwsPerformanceCounterUploader() );
8}