Singleton Pattern
Overview
Several parts of a program use the same read-only catalog. Instead of constructing it repeatedly, the program can restrict construction and expose one shared object.
Singleton provides a single instance and an access point to it. Here the constructor is private, and callers obtain the object through Instance.
UML
Callers use AppCatalog.Instance. Its constructor is private, and a static Lazy<AppCatalog> creates the object on first access.
History
GoF cataloged Singleton as a creational pattern in Design Patterns in 1994. It restricts a class to one instance and provides access to it.1
Why use it?
Singleton is convenient, but its global access hides dependencies and tightly couples callers to one implementation. Code tied directly to AppCatalog.Instance becomes harder to test with a substitute or configure per user; adding mutable state lets one caller affect another. That convenience turns into an anti-pattern when global state becomes the default way to connect components. Needing to reuse one object does not justify letting every component retrieve it globally.
The scope here is the static field in the same runtime load context. Multiple server processes each have their own instance. This is not a distributed mechanism for preventing duplicate work.
Example
Use AppCatalog, a catalog containing only a version string, as the shared object and compare two accesses by reference.
using System;
using System.Collections.Generic;
using System.Linq;
Console.WriteLine(ReferenceEquals(AppCatalog.Instance, AppCatalog.Instance));
// Own construction so callers do not create separate catalogs.
sealed class AppCatalog
{
private static readonly Lazy<AppCatalog> shared = new(() => new AppCatalog());
private AppCatalog() { }
// Lazy<T> synchronizes initialization; callers using this static field receive the same object.
public static AppCatalog Instance => shared.Value;
public string Version => "v1";
}
TrueLazy<T> synchronizes initialization. That does not automatically make mutable operations added to the object thread-safe.
An extended example
Have several tasks access the instance concurrently. The example catalog remains read-only.
using System;
using System.Collections.Generic;
using System.Linq;
var tasks = Enumerable.Range(0, 8)
.Select(_ => System.Threading.Tasks.Task.Run(() => AppCatalog.Instance)).ToArray();
var values = await System.Threading.Tasks.Task.WhenAll(tasks);
Console.WriteLine(values.All(value => ReferenceEquals(value, values[0])));
Console.WriteLine(values[0].Version);
// Own construction so callers do not create separate catalogs.
sealed class AppCatalog
{
private static readonly Lazy<AppCatalog> shared = new(() => new AppCatalog());
private AppCatalog() { }
// Lazy<T> synchronizes initialization; callers using this static field receive the same object.
public static AppCatalog Instance => shared.Value;
public string Version => "v1";
}
True
v1These calls share one static field and receive one instance. If configuration must vary by user, first check whether that sharing scope fits the application.
TypeScript example
AppCatalog keeps construction and access in one place so callers share the same catalog. They obtain instance instead of constructing another catalog.
// Own construction so callers do not create separate catalogs.
class AppCatalog {
static #shared: AppCatalog | undefined;
readonly version = "v1";
private constructor() { Object.freeze(this); }
// Create on first synchronous access and reuse the object within this module instance.
static get instance(): AppCatalog {
return AppCatalog.#shared ??= new AppCatalog();
}
}
const first = AppCatalog.instance;
const second = AppCatalog.instance;
console.log(first === second);
console.log(first.version);true
v1The sharing scope is the same JavaScript module instance. Object.freeze(this) fixes the example catalog’s version at runtime. The private constructor is a type-checking restriction, not a security boundary against JavaScript callers.
Share the object without global access
Create the shared object once where the program is assembled, then pass it to the function that needs it. Receiving a dependency from outside is dependency injection (DI); it does not require a container. The function below never looks up a global catalog, so a test can supply a different value directly.
C#
using System;
ICatalog shared = new Catalog("v1");
// Receive the dependency instead of looking up a global object.
string Heading(ICatalog catalog) => "catalog " + catalog.Version;
Console.WriteLine(Heading(shared));
Console.WriteLine(Heading(new Catalog("test")));
interface ICatalog { string Version { get; } }
sealed record Catalog(string Version) : ICatalog;TypeScript
type Catalog = Readonly<{ version: string }>;
const shared: Catalog = Object.freeze({ version: "v1" });
// The caller decides whether to reuse the object or supply a test value.
const heading = (catalog: Catalog): string => "catalog " + catalog.version;
console.log(heading(shared));
console.log(heading(Object.freeze({ version: "test" })));catalog v1
catalog testReusing one object is a lifetime choice; retrieving it globally is an access choice. A shared lifetime managed through DI still needs rules for concurrent mutation and resource disposal.
Microsoft: Dependency injection guidelines
Related reading
Each C# example is a separate .NET 10 console program.
Compile the TypeScript examples for ES2022 or later and run the emitted JavaScript with Node.js.
Footnotes
- Gamma, Helm, Johnson, Vlissides. Design Patterns (1994). Publisher record. Creational patterns, Singleton. ↩