Singleton Pattern — FixIt Pro Series #05
One JobCardRegistry to rule them all. Learn how the Singleton pattern ensures a single, globally consistent store of active job cards — and why thread safety matters more than you think — in C# and TypeScript.
Series: Design Patterns with FixIt Pro · Episode 05 / 22 · Creational Pattern
Previous: #04 — Prototype Pattern
The Scenario
FixIt Pro is growing. The dispatcher creates job cards. The scheduler assigns handymen. The billing system tracks completions. The reporting module queries active jobs.
Every one of these components needs access to the same list of active job cards. If each one creates its own registry instance, you end up with four separate lists that diverge the moment any of them is updated. A job marked complete in the dispatcher's registry is still "active" in the billing system's copy.
You need one registry. One instance. Shared across the entire application. And it needs to be safe to access from multiple parts of the system simultaneously.
That's the Singleton pattern.
What Is the Singleton Pattern?
Ensure a class has only one instance, and provide a global point of access to it.
Two guarantees:
- Only one instance ever exists — no matter how many times you ask for it
- Global access — any component can reach it without being passed a reference explicitly
The one participant
| Role | FixIt Pro equivalent |
|---|---|
| Singleton | JobCardRegistry — one instance, globally accessible |
Simple concept. The implementation details are where it gets interesting — especially around thread safety.
C# Implementation
C# gives us several ways to implement the Singleton. We'll cover the naive version, explain why it fails under concurrency, and land on the modern idiomatic approach.
// ── The JobCard (simplified for this episode) ──────────────
public class JobCard
{
public string JobId { get; init; } = Guid.NewGuid().ToString()[..8];
public string Title { get; init; } = string.Empty;
public string Category { get; init; } = string.Empty;
public string Status { get; set; } = "Active";
}
// ── ❌ Naive Singleton — NOT thread-safe ───────────────────
public class JobCardRegistryNaive
{
private static JobCardRegistryNaive? _instance;
private readonly List<JobCard> _jobs = new();
private JobCardRegistryNaive() { } // private constructor
public static JobCardRegistryNaive Instance
{
get
{
// ❌ Two threads can both pass this check simultaneously
// and each create their own instance
if (_instance == null)
_instance = new JobCardRegistryNaive();
return _instance;
}
}
public void Register(JobCard card) => _jobs.Add(card);
public IReadOnlyList<JobCard> GetAll() => _jobs.AsReadOnly();
}
// ── ✅ Thread-safe Singleton — Lazy<T> (recommended) ───────
// Lazy<T> guarantees thread-safe, deferred initialisation.
// The instance is created only when first accessed, not at startup.
public sealed class JobCardRegistry
{
private static readonly Lazy<JobCardRegistry> _lazy =
new(() => new JobCardRegistry());
public static JobCardRegistry Instance => _lazy.Value;
private readonly List<JobCard> _jobs = new();
private readonly object _lock = new();
private JobCardRegistry() { } // private constructor
public void Register(JobCard card)
{
lock (_lock) { _jobs.Add(card); }
}
public void UpdateStatus(string jobId, string status)
{
lock (_lock)
{
var job = _jobs.FirstOrDefault(j => j.JobId == jobId);
if (job is not null) job.Status = status;
}
}
public IReadOnlyList<JobCard> GetAll()
{
lock (_lock) { return _jobs.ToList().AsReadOnly(); }
}
public IReadOnlyList<JobCard> GetByStatus(string status)
{
lock (_lock)
{
return _jobs.Where(j => j.Status == status).ToList().AsReadOnly();
}
}
public int Count
{
get { lock (_lock) { return _jobs.Count; } }
}
public void PrintSummary()
{
lock (_lock)
{
Console.WriteLine($"\n=== Job Card Registry ({_jobs.Count} jobs) ===");
foreach (var job in _jobs)
Console.WriteLine($" [{job.Status}] #{job.JobId} — {job.Title} ({job.Category})");
}
}
}
// ── Client Code ────────────────────────────────────────────
class Program
{
static void Main()
{
// All components access the same instance
var dispatcherRegistry = JobCardRegistry.Instance;
var billingRegistry = JobCardRegistry.Instance;
var reportingRegistry = JobCardRegistry.Instance;
// Prove it's the same object
Console.WriteLine($"Same instance: {ReferenceEquals(dispatcherRegistry, billingRegistry)}");
// Output: Same instance: True
// Dispatcher registers new jobs
dispatcherRegistry.Register(new JobCard
{
Title = "Burst pipe in kitchen",
Category = "Plumbing"
});
dispatcherRegistry.Register(new JobCard
{
Title = "Faulty circuit breaker",
Category = "Electrical"
});
dispatcherRegistry.Register(new JobCard
{
Title = "Broken door frame",
Category = "Carpentry"
});
// Billing sees all jobs immediately — same instance
Console.WriteLine($"\nBilling sees {billingRegistry.Count} jobs.");
// Dispatcher completes a job
var jobId = dispatcherRegistry.GetAll()[0].JobId;
dispatcherRegistry.UpdateStatus(jobId, "Completed");
// Reporting queries by status — reflects the update immediately
var active = reportingRegistry.GetByStatus("Active");
var completed = reportingRegistry.GetByStatus("Completed");
Console.WriteLine($"Reporting: {active.Count} active, {completed.Count} completed.");
reportingRegistry.PrintSummary();
}
}
Output:
Same instance: True
Billing sees 3 jobs.
Reporting: 2 active, 1 completed.
=== Job Card Registry (3 jobs) ===
[Completed] #f3a12b9c — Burst pipe in kitchen (Plumbing)
[Active] #a7d45e01 — Faulty circuit breaker (Electrical)
[Active] #c2f89d33 — Broken door frame (Carpentry)
TypeScript Implementation
TypeScript runs in a single-threaded event loop, so the concurrency concern is less acute than in C#. However, the module system gives us a clean, idiomatic Singleton for free.
// ── Product/JobCard.ts ─────────────────────────────────────
const shortId = () => Math.random().toString(36).slice(2, 10);
export class JobCard {
readonly jobId: string = shortId();
readonly title: string;
readonly category: string;
status: string = "Active";
constructor(config: { title: string; category: string }) {
this.title = config.title;
this.category = config.category;
}
}
// ── Registry/JobCardRegistry.ts ────────────────────────────
// TypeScript modules are singletons by nature — a module is
// evaluated once and cached. Exporting an instance is the
// idiomatic approach.
import { JobCard } from "../Product/JobCard";
class JobCardRegistryClass {
private jobs: JobCard[] = [];
register(card: JobCard): void {
this.jobs.push(card);
}
updateStatus(jobId: string, status: string): void {
const job = this.jobs.find(j => j.jobId === jobId);
if (job) job.status = status;
}
getAll(): ReadonlyArray<JobCard> {
return this.jobs;
}
getByStatus(status: string): JobCard[] {
return this.jobs.filter(j => j.status === status);
}
get count(): number {
return this.jobs.length;
}
printSummary(): void {
console.log(`\n=== Job Card Registry (${this.jobs.length} jobs) ===`);
for (const job of this.jobs)
console.log(` [${job.status}] #${job.jobId} — ${job.title} (${job.category})`);
}
}
// The module-level instance — created once, cached by the module system
export const JobCardRegistry = new JobCardRegistryClass();
// ── App.ts ─────────────────────────────────────────────────
import { JobCard } from "./Product/JobCard";
import { JobCardRegistry } from "./Registry/JobCardRegistry";
// All imports of JobCardRegistry point to the same instance
const dispatcherRegistry = JobCardRegistry;
const billingRegistry = JobCardRegistry;
const reportingRegistry = JobCardRegistry;
// Prove it's the same object
console.log(`Same instance: ${dispatcherRegistry === billingRegistry}`);
// Output: Same instance: true
// Dispatcher registers jobs
dispatcherRegistry.register(new JobCard({ title: "Burst pipe in kitchen", category: "Plumbing" }));
dispatcherRegistry.register(new JobCard({ title: "Faulty circuit breaker", category: "Electrical" }));
dispatcherRegistry.register(new JobCard({ title: "Broken door frame", category: "Carpentry" }));
// Billing sees all jobs immediately
console.log(`\nBilling sees ${billingRegistry.count} jobs.`);
// Dispatcher completes a job
const jobId = dispatcherRegistry.getAll()[0].jobId;
dispatcherRegistry.updateStatus(jobId, "Completed");
// Reporting reflects the update immediately
const active = reportingRegistry.getByStatus("Active");
const completed = reportingRegistry.getByStatus("Completed");
console.log(`Reporting: ${active.length} active, ${completed.length} completed.`);
reportingRegistry.printSummary();
C# vs TypeScript — Key Differences
| Aspect | C# | TypeScript |
|---|---|---|
| Singleton mechanism | Lazy<T> for deferred, thread-safe init |
Module-level export const — cached by the module system |
| Thread safety | Required — lock on mutations and reads |
Not required — single-threaded event loop |
| Private constructor | private JobCardRegistry() |
Not needed — class isn't exported, only the instance is |
| Immutability enforcement | sealed class prevents inheritance |
Export only the instance, not the class |
| Verification | ReferenceEquals(a, b) |
a === b strict equality |
The TypeScript approach leverages the module system rather than replicating the C# Lazy<T> pattern. Both achieve the same guarantee — one instance, always.
The Singleton Controversy
The Singleton is one of the most debated patterns in software engineering. Here's a balanced view:
Arguments against:
- It introduces global state, which makes code harder to test — you can't easily inject a different registry in a unit test
- It creates hidden dependencies — components that use
JobCardRegistry.Instanceare implicitly coupled to it - It can become a God Object if you're not careful — a dumping ground for unrelated state
Arguments for:
- Some things genuinely are global — a configuration store, a logger, a connection pool
- In practice, the alternative (passing the registry through every constructor) is often worse
- Dependency injection frameworks (ASP.NET Core's DI, NestJS providers) implement Singleton lifetime themselves — so you're using the pattern whether you know it or not
The modern approach: rather than calling JobCardRegistry.Instance directly, register the Singleton with your DI container and inject it. You get the single-instance guarantee without the hidden coupling — and tests can inject a mock.
When to Use the Singleton
Use it when:
- Exactly one instance must coordinate actions across the system
- You need a shared resource that's expensive to create (connection pool, configuration loader)
- You're building a logger, event bus, or cache that must be globally consistent
Avoid it when:
- You just want convenient global access — that's a code smell, not a Singleton use case
- You need multiple instances in tests — prefer DI over direct
Instanceaccess - The "single" constraint isn't actually required — if two instances would work fine, don't add the constraint
Real-World Takeaway
ASP.NET Core's IServiceCollection.AddSingleton<T>() registers a class as a Singleton within the DI container — one instance per application lifetime. NestJS providers are Singleton-scoped by default. JavaScript's module system is itself a Singleton cache — every import of the same module returns the same exports object.
In FixIt Pro, the JobCardRegistry Singleton means the dispatcher, billing system, and reporting module are always looking at the same data. No synchronisation headaches. No stale copies. One source of truth — which is exactly what a registry should be.
Repo Structure for This Episode
github.com/antonlungameni/fixit-pro-design-patterns
fixit-pro-design-patterns/
├── csharp/Creational/05-Singleton/
│ ├── Product/JobCard.cs
│ ├── Registry/JobCardRegistry.cs
│ └── Program.cs
└── typescript/Creational/05-Singleton/
├── Product/JobCard.ts
├── Registry/JobCardRegistry.ts
└── App.ts
Previous: #04 — Prototype Pattern
Next up: #06 — Adapter Pattern
This wraps up the Creational patterns. Next we move into Structural patterns — starting with the Adapter, where FixIt Pro needs to plug a legacy SMS notification service into a modern INotifier interface without touching the legacy code.