MetalamaCommented examplesSingletonVersion 2: Modern singleton
Open sandboxFocusImprove this doc

Example: The Modern Singleton Pattern

In many modern applications, the classic singleton pattern proves unsuitable. This is typically because we either want to inject a dependency into the singleton, or we want to isolate tests from each other, with each test having its own instance of each singleton. 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 that prevents someone from creating multiple instances of the class in production code by directly calling the constructor. Let's examine how we can 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 interface (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( IPerformanceCounterUploader uploader )
6{
7    private readonly Stopwatch _stopwatch = Stopwatch.StartNew();
8    private ConcurrentDictionary<string, int> _counters = new();
9
10    public void IncrementCounter( string name )
11        => this._counters.AddOrUpdate( name, 1, ( _, value ) => value + 1 );
12
13    public void UploadAndReset()
14    {
15        var oldCounters = this._counters;
16        var elapsedMilliseconds = this._stopwatch.ElapsedMilliseconds;
17
18        this._counters = new ConcurrentDictionary<string, int>();
19        this._stopwatch.Reset();
20
21        foreach ( var counter in oldCounters )
22        {
23            uploader.UploadCounter( counter.Key,  1000d * counter.Value/elapsedMilliseconds);
24        }
25    }
26}

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.AddSingleton<IPerformanceCounterUploader>( _ => new AwsPerformanceCounterUploader() );
8        serviceCollection.AddSingleton<PerformanceCounterManager>();
9    }
10}

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 deriving the SingletonAttribute class from the CanOnlyBeUsedFromAttribute aspect and by setting the Namespaces and Types collections to their allowed values.

1using Metalama.Extensions.Architecture.Aspects;
2using Metalama.Framework.Aspects;
3
4[CompileTime]
5public class SingletonAttribute : CanOnlyBeUsedFromAttribute
6{
7    public SingletonAttribute()
8    {
9        // Allow from test namespaces.
10        this.Namespaces = ["**.Tests"];
11                
12        // Allow from the Startup class.
13        this.Types = [typeof(Startup)];
14                
15        // Justification.
16        this.Description = $"The class is a [Singleton].";
17    }
18}
19

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        // This call is illegal and reported.
        Warning LAMA0905: The 'PerformanceCounterManager' type cannot be referenced by the 'IllegalUse' type. The class is a [Singleton].

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