Low Level DesignDesign patterns
Singleton pattern
The Singleton design pattern through three real examples, from app config to a thread-safe metrics registry to a payment gateway and a nightly job on three servers, with demos and code in five languages.
- Read first
- Dependency inversionFactory
A singleton is a class that makes sure it has exactly one instance, and gives everyone a way to reach it. The configuration loaded at startup, the process’s metrics registry and the client for a payment API are each things a program should have one of. Two copies of the config can disagree, and two metrics registries split the counts between them.
That need is real and common. The classic way of meeting it is a private constructor and a static
getInstance(). It’s the most criticized pattern in the Gang of Four book, because it also makes
the object global. This page builds the pattern properly, and makes it thread-safe. Then it shows
why most production code keeps the “one instance”, and drops the global access.
The shape
Section titled “The shape”The classic singleton has three parts, all inside one class:
- A private constructor, so no other code can create an instance.
- A static field that holds the one instance.
- A static accessor, usually
getInstance(), that returns it, creating it first if needed.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
Underlined members are static. Notice what the diagram doesn’t show: neither client receives the singleton from anyone. Each one reaches for it by class name. That arrow is the global access, and the Hard level is about removing it.
There are four ways to arrange “one instance”, and they differ in who enforces it and when it’s created:
| Form | Who makes sure there’s one | Created | Example on this page |
|---|---|---|---|
| Eager singleton | The class (private constructor, static field) | At startup or first touch of the class | Easy |
| Lazy singleton | The class, plus a lock or a once primitive | On first use | Medium |
| One instance, passed in | The composition root (startup code) or a DI container | At startup | Hard |
| One per system | A lease or leader election in a shared store | Whoever holds the lease | Hard |
Worked examples
Section titled “Worked examples”App configuration
- Private constructor
- One shared instance
- Each language’s usual form
An online shop reads its settings from app.conf when it starts: the currency, the tax rate,
feature flags. The order, invoice and receipt services all need the tax rate. Ops changes the
file a few times a year and asks the app to reload it, without a restart.
Without the pattern
Section titled “Without the pattern”Each service builds the config object it needs:
import java.util.HashMap;import java.util.Map;
// Stands in for the file system so the demo runs anywhere: the text of app.conf, and a// count of how many times anyone has parsed it.class Disk { static String appConf = "currency=USD\ntax_rate=0.08"; static int parses = 0;}
// Reads app.conf into a map. Parsing is the cost: a real config file is bigger, may be YAML,// and may pull secrets from a vault on the way.class AppConfig { private final Map<String, String> values = new HashMap<>();
// Public constructor: any class can build its own copy, and each copy parses the file. AppConfig() { load(); }
void load() { Disk.parses++; System.out.println("parsing app.conf (#" + Disk.parses + ")"); values.clear(); for (String line : Disk.appConf.split("\n")) { String[] kv = line.split("=", 2); values.put(kv[0], kv[1]); } }
String get(String key) { return values.get(key); }}
// Each service builds the config it needs. Three services, three parses, three copies that// nothing keeps in step.class OrderService { private final AppConfig config = new AppConfig(); String taxRate() { return config.get("tax_rate"); }}
class InvoiceService { private final AppConfig config = new AppConfig(); String taxRate() { return config.get("tax_rate"); } // Only this copy learns about a change. The other two keep the old values. void reloadConfig() { config.load(); }}
class ReceiptService { private final AppConfig config = new AppConfig(); String taxRate() { return config.get("tax_rate"); }}
class Main { public static void main(String[] args) { OrderService orders = new OrderService(); // parsing app.conf (#1) InvoiceService invoices = new InvoiceService(); // parsing app.conf (#2) ReceiptService receipts = new ReceiptService(); // parsing app.conf (#3)
// Ops raises the tax rate in the file, and the invoice service reloads. Disk.appConf = "currency=USD\ntax_rate=0.10"; invoices.reloadConfig(); // parsing app.conf (#4)
// One question, two answers: an order is taxed at 8% and invoiced at 10%. System.out.println("orders: " + orders.taxRate() + ", invoices: " + invoices.taxRate() + ", receipts: " + receipts.taxRate()); // orders: 0.08, invoices: 0.10, receipts: 0.08 }}# Stands in for the file system so the demo runs anywhere: the text of app.conf, and a# count of how many times anyone has parsed it.class Disk: app_conf = "currency=USD\ntax_rate=0.08" parses = 0
# Reads app.conf into a dict. Parsing is the cost: a real config file is bigger, may be YAML,# and may pull secrets from a vault on the way.class AppConfig: # Anyone can call AppConfig(), and each call parses the file again. def __init__(self): self._values: dict[str, str] = {} self.load()
def load(self) -> None: Disk.parses += 1 print(f"parsing app.conf (#{Disk.parses})") self._values = dict(line.split("=", 1) for line in Disk.app_conf.split("\n"))
def get(self, key: str) -> str: return self._values[key]
# Each service builds the config it needs. Three services, three parses, three copies that# nothing keeps in step.class OrderService: def __init__(self): self._config = AppConfig()
def tax_rate(self) -> str: return self._config.get("tax_rate")
class InvoiceService: def __init__(self): self._config = AppConfig()
def tax_rate(self) -> str: return self._config.get("tax_rate")
# Only this copy learns about a change. The other two keep the old values. def reload_config(self) -> None: self._config.load()
class ReceiptService: def __init__(self): self._config = AppConfig()
def tax_rate(self) -> str: return self._config.get("tax_rate")
if __name__ == "__main__": orders = OrderService() # parsing app.conf (#1) invoices = InvoiceService() # parsing app.conf (#2) receipts = ReceiptService() # parsing app.conf (#3)
# Ops raises the tax rate in the file, and the invoice service reloads. Disk.app_conf = "currency=USD\ntax_rate=0.10" invoices.reload_config() # parsing app.conf (#4)
# One question, two answers: an order is taxed at 8% and invoiced at 10%. print(f"orders: {orders.tax_rate()}, invoices: {invoices.tax_rate()}, receipts: {receipts.tax_rate()}") # orders: 0.08, invoices: 0.10, receipts: 0.08using System;using System.Collections.Generic;
// Stands in for the file system so the demo runs anywhere: the text of app.conf, and a// count of how many times anyone has parsed it.public static class Disk{ public static string AppConf = "currency=USD\ntax_rate=0.08"; public static int Parses = 0;}
// Reads app.conf into a dictionary. Parsing is the cost: a real config file is bigger, may be// JSON or YAML, and may pull secrets from a vault on the way.public sealed class AppConfig{ private readonly Dictionary<string, string> _values = new();
// Public constructor: any class can build its own copy, and each copy parses the file. public AppConfig() => Load();
public void Load() { Disk.Parses++; Console.WriteLine($"parsing app.conf (#{Disk.Parses})"); _values.Clear(); foreach (var line in Disk.AppConf.Split('\n')) { var kv = line.Split('=', 2); _values[kv[0]] = kv[1]; } }
public string Get(string key) => _values[key];}
// Each service builds the config it needs. Three services, three parses, three copies that// nothing keeps in step.public sealed class OrderService{ private readonly AppConfig _config = new(); public string TaxRate() => _config.Get("tax_rate");}
public sealed class InvoiceService{ private readonly AppConfig _config = new(); public string TaxRate() => _config.Get("tax_rate"); // Only this copy learns about a change. The other two keep the old values. public void ReloadConfig() => _config.Load();}
public sealed class ReceiptService{ private readonly AppConfig _config = new(); public string TaxRate() => _config.Get("tax_rate");}
public static class Program{ public static void Main() { var orders = new OrderService(); // parsing app.conf (#1) var invoices = new InvoiceService(); // parsing app.conf (#2) var receipts = new ReceiptService(); // parsing app.conf (#3)
// Ops raises the tax rate in the file, and the invoice service reloads. Disk.AppConf = "currency=USD\ntax_rate=0.10"; invoices.ReloadConfig(); // parsing app.conf (#4)
// One question, two answers: an order is taxed at 8% and invoiced at 10%. Console.WriteLine($"orders: {orders.TaxRate()}, invoices: {invoices.TaxRate()}, receipts: {receipts.TaxRate()}"); // orders: 0.08, invoices: 0.10, receipts: 0.08 }}package main
import ( "fmt" "strings")
// diskAppConf and diskParses stand in for the file system so the demo runs anywhere: the// text of app.conf, and a count of how many times anyone has parsed it.var diskAppConf = "currency=USD\ntax_rate=0.08"var diskParses = 0
// AppConfig reads app.conf into a map. Parsing is the cost: a real config file is bigger,// may be YAML, and may pull secrets from a vault on the way.type AppConfig struct { values map[string]string}
// NewAppConfig is public: any package can build its own copy, and each copy parses the file.func NewAppConfig() *AppConfig { c := &AppConfig{} c.Load() return c}
func (c *AppConfig) Load() { diskParses++ fmt.Printf("parsing app.conf (#%d)\n", diskParses) c.values = map[string]string{} for _, line := range strings.Split(diskAppConf, "\n") { k, v, _ := strings.Cut(line, "=") c.values[k] = v }}
func (c *AppConfig) Get(key string) string { return c.values[key] }
// Each service builds the config it needs. Three services, three parses, three copies that// nothing keeps in step.type OrderService struct{ config *AppConfig }
func NewOrderService() *OrderService { return &OrderService{NewAppConfig()} }func (s *OrderService) TaxRate() string { return s.config.Get("tax_rate") }
type InvoiceService struct{ config *AppConfig }
func NewInvoiceService() *InvoiceService { return &InvoiceService{NewAppConfig()} }func (s *InvoiceService) TaxRate() string { return s.config.Get("tax_rate") }
// ReloadConfig updates only this copy. The other two keep the old values.func (s *InvoiceService) ReloadConfig() { s.config.Load() }
type ReceiptService struct{ config *AppConfig }
func NewReceiptService() *ReceiptService { return &ReceiptService{NewAppConfig()} }func (s *ReceiptService) TaxRate() string { return s.config.Get("tax_rate") }
func main() { orders := NewOrderService() // parsing app.conf (#1) invoices := NewInvoiceService() // parsing app.conf (#2) receipts := NewReceiptService() // parsing app.conf (#3)
// Ops raises the tax rate in the file, and the invoice service reloads. diskAppConf = "currency=USD\ntax_rate=0.10" invoices.ReloadConfig() // parsing app.conf (#4)
// One question, two answers: an order is taxed at 8% and invoiced at 10%. fmt.Printf("orders: %s, invoices: %s, receipts: %s\n", orders.TaxRate(), invoices.TaxRate(), receipts.TaxRate()) // orders: 0.08, invoices: 0.10, receipts: 0.08}// Stands in for the file system so the demo runs anywhere: the text of app.conf, and a// count of how many times anyone has parsed it.const disk = { appConf: 'currency=USD\ntax_rate=0.08', parses: 0 };
// Reads app.conf into a map. Parsing is the cost: a real config file is bigger, may be JSON// or YAML, and may pull secrets from a vault on the way.class AppConfig { private values = new Map<string, string>();
// Public constructor: any module can build its own copy, and each copy parses the file. constructor() { this.load(); }
load(): void { disk.parses++; console.log(`parsing app.conf (#${disk.parses})`); this.values = new Map(disk.appConf.split('\n').map((line) => line.split('=', 2) as [string, string])); }
get(key: string): string | undefined { return this.values.get(key); }}
// Each service builds the config it needs. Three services, three parses, three copies that// nothing keeps in step.class OrderService { private readonly config = new AppConfig(); taxRate() { return this.config.get('tax_rate'); }}
class InvoiceService { private readonly config = new AppConfig(); taxRate() { return this.config.get('tax_rate'); } // Only this copy learns about a change. The other two keep the old values. reloadConfig() { this.config.load(); }}
class ReceiptService { private readonly config = new AppConfig(); taxRate() { return this.config.get('tax_rate'); }}
const orders = new OrderService(); // parsing app.conf (#1)const invoices = new InvoiceService(); // parsing app.conf (#2)const receipts = new ReceiptService(); // parsing app.conf (#3)
// Ops raises the tax rate in the file, and the invoice service reloads.disk.appConf = 'currency=USD\ntax_rate=0.10';invoices.reloadConfig(); // parsing app.conf (#4)
// One question, two answers: an order is taxed at 8% and invoiced at 10%.console.log(`orders: ${orders.taxRate()}, invoices: ${invoices.taxRate()}, receipts: ${receipts.taxRate()}`);// orders: 0.08, invoices: 0.10, receipts: 0.08
export {};Three services parse the same file three times. That’s wasteful but harmless. The real problem appears on reload: only the service that reloads sees the new tax rate. An order is taxed at 8% at checkout and invoiced at 10%, and nothing in the code looks wrong.
With a singleton
Section titled “With a singleton”Make the class responsible for there being one. Its constructor is private, it holds its own instance, and services ask it for that instance:
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
Start the app with each design, change the file, and reload:
app.conf on disk
currency=USD
tax_rate=0.08Times parsed
0
OrderService
not started
tax on a $100 order: –
InvoiceService
not started
tax on a $100 order: –
ReceiptService
not started
tax on a $100 order: –
Start the app and watch how many times the file is parsed.
import java.util.HashMap;import java.util.Map;
// Stands in for the file system so the demo runs anywhere.class Disk { static String appConf = "currency=USD\ntax_rate=0.08"; static int parses = 0;}
// The classic singleton: the class itself guarantees there is only one.// final, so a subclass can't add a second way to make instances.final class AppConfig { // The one instance. The JVM runs a class's static initializer once, under a lock, the // first time the class is used, so this is lazy and thread-safe with no code of ours. private static final AppConfig INSTANCE = new AppConfig();
// Private: no other class can call new AppConfig(). This is what turns "please share // one" into a guarantee the compiler checks. private AppConfig() { reload(); }
// The only way in. static AppConfig getInstance() { return INSTANCE; }
// An immutable snapshot, replaced whole on reload. volatile makes the new snapshot visible // to other threads at once, and they see either the old map or the new one, never a // half-filled one. private volatile Map<String, String> values = Map.of();
void reload() { Disk.parses++; System.out.println("parsing app.conf (#" + Disk.parses + ")"); Map<String, String> fresh = new HashMap<>(); for (String line : Disk.appConf.split("\n")) { String[] kv = line.split("=", 2); fresh.put(kv[0], kv[1]); } values = Map.copyOf(fresh); }
String get(String key) { return values.get(key); }}
// Services ask the class for its instance. No copies, so nothing to keep in step.// The cost: nothing in these classes' constructors says they need config. The Hard level// shows what that hidden dependency does to tests.class OrderService { String taxRate() { return AppConfig.getInstance().get("tax_rate"); }}
class InvoiceService { String taxRate() { return AppConfig.getInstance().get("tax_rate"); }}
class ReceiptService { String taxRate() { return AppConfig.getInstance().get("tax_rate"); }}
class Main { public static void main(String[] args) { OrderService orders = new OrderService(); InvoiceService invoices = new InvoiceService(); ReceiptService receipts = new ReceiptService(); // new AppConfig(); // does not compile: AppConfig() has private access
System.out.println("orders: " + orders.taxRate() + ", invoices: " + invoices.taxRate() + ", receipts: " + receipts.taxRate()); // parsing app.conf (#1) // orders: 0.08, invoices: 0.08, receipts: 0.08
// Ops raises the tax rate. One reload, and every service sees it. Disk.appConf = "currency=USD\ntax_rate=0.10"; AppConfig.getInstance().reload(); // parsing app.conf (#2) System.out.println("orders: " + orders.taxRate() + ", invoices: " + invoices.taxRate() + ", receipts: " + receipts.taxRate()); // orders: 0.10, invoices: 0.10, receipts: 0.10 System.out.println("parses: " + Disk.parses); // parses: 2 }}# Stands in for the file system so the demo runs anywhere.class Disk: app_conf = "currency=USD\ntax_rate=0.08" parses = 0
class AppConfig: def __init__(self): self._values: dict[str, str] = {} self.reload()
def reload(self) -> None: Disk.parses += 1 print(f"parsing app.conf (#{Disk.parses})") # Build a new dict, then replace the old one in a single assignment. Readers on other # threads see the old dict or the new one, never a half-filled one. self._values = dict(line.split("=", 1) for line in Disk.app_conf.split("\n"))
def get(self, key: str) -> str: return self._values[key]
# The singleton, the Python way: a module-level instance. In a real app this line lives in# config.py, and every `from config import config` gets this same object, because Python runs# a module once and caches it in sys.modules.# Python has no private constructors, so AppConfig() still works elsewhere; the convention is# "import the instance, never build one". Clever __new__ tricks to forbid it add surprise and# buy little.config = AppConfig()
# Services use the shared instance. No copies, so nothing to keep in step.# The cost: nothing in these classes' constructors says they need config. The Hard level# shows what that hidden dependency does to tests.class OrderService: def tax_rate(self) -> str: return config.get("tax_rate")
class InvoiceService: def tax_rate(self) -> str: return config.get("tax_rate")
class ReceiptService: def tax_rate(self) -> str: return config.get("tax_rate")
if __name__ == "__main__": orders, invoices, receipts = OrderService(), InvoiceService(), ReceiptService()
print(f"orders: {orders.tax_rate()}, invoices: {invoices.tax_rate()}, receipts: {receipts.tax_rate()}") # parsing app.conf (#1) (printed when the module loaded) # orders: 0.08, invoices: 0.08, receipts: 0.08
# Ops raises the tax rate. One reload, and every service sees it. Disk.app_conf = "currency=USD\ntax_rate=0.10" config.reload() # parsing app.conf (#2) print(f"orders: {orders.tax_rate()}, invoices: {invoices.tax_rate()}, receipts: {receipts.tax_rate()}") # orders: 0.10, invoices: 0.10, receipts: 0.10 print(f"parses: {Disk.parses}") # parses: 2using System;using System.Collections.Generic;
// Stands in for the file system so the demo runs anywhere.public static class Disk{ public static string AppConf = "currency=USD\ntax_rate=0.08"; public static int Parses = 0;}
// The classic singleton: the class itself guarantees there is only one.// sealed, so a subclass can't add a second way to make instances.public sealed class AppConfig{ // The one instance, in a static read-only property. The runtime runs a type's static // initialization once and thread-safely, so no locking code is needed here. public static AppConfig Instance { get; } = new AppConfig();
// An immutable snapshot, replaced whole on reload. volatile makes the new snapshot // visible to other threads at once, and they see either the old one or the new one, // never a half-filled one. private volatile IReadOnlyDictionary<string, string> _values = new Dictionary<string, string>();
// Private: no other class can call new AppConfig(). This is what turns "please share // one" into a guarantee the compiler checks. private AppConfig() => Reload();
public void Reload() { Disk.Parses++; Console.WriteLine($"parsing app.conf (#{Disk.Parses})"); var fresh = new Dictionary<string, string>(); foreach (var line in Disk.AppConf.Split('\n')) { var kv = line.Split('=', 2); fresh[kv[0]] = kv[1]; } _values = fresh; }
public string Get(string key) => _values[key];}
// Services ask the class for its instance. No copies, so nothing to keep in step.// The cost: nothing in these classes' constructors says they need config. The Hard level// shows what that hidden dependency does to tests.public sealed class OrderService{ public string TaxRate() => AppConfig.Instance.Get("tax_rate");}
public sealed class InvoiceService{ public string TaxRate() => AppConfig.Instance.Get("tax_rate");}
public sealed class ReceiptService{ public string TaxRate() => AppConfig.Instance.Get("tax_rate");}
public static class Program{ public static void Main() { var orders = new OrderService(); var invoices = new InvoiceService(); var receipts = new ReceiptService(); // new AppConfig(); // does not compile: the constructor is inaccessible
Console.WriteLine($"orders: {orders.TaxRate()}, invoices: {invoices.TaxRate()}, receipts: {receipts.TaxRate()}"); // parsing app.conf (#1) // orders: 0.08, invoices: 0.08, receipts: 0.08
// Ops raises the tax rate. One reload, and every service sees it. Disk.AppConf = "currency=USD\ntax_rate=0.10"; AppConfig.Instance.Reload(); // parsing app.conf (#2) Console.WriteLine($"orders: {orders.TaxRate()}, invoices: {invoices.TaxRate()}, receipts: {receipts.TaxRate()}"); // orders: 0.10, invoices: 0.10, receipts: 0.10 Console.WriteLine($"parses: {Disk.Parses}"); // parses: 2 }}package main
import ( "fmt" "strings" "sync/atomic")
// Stand-ins for the file system so the demo runs anywhere.var diskAppConf = "currency=USD\ntax_rate=0.08"var diskParses = 0
// appConfig is unexported, and so is its constructor. In a real app this lives in package// config, and other packages can't build a second one; they can only call config.Get().type appConfig struct { // An immutable snapshot, replaced whole on reload. The atomic pointer makes the new // snapshot visible to other goroutines at once, and they see either the old map or the // new one, never a half-filled one. values atomic.Pointer[map[string]string]}
func newAppConfig() *appConfig { c := &appConfig{} c.Reload() return c}
func (c *appConfig) Reload() { diskParses++ fmt.Printf("parsing app.conf (#%d)\n", diskParses) fresh := map[string]string{} for _, line := range strings.Split(diskAppConf, "\n") { k, v, _ := strings.Cut(line, "=") fresh[k] = v } c.values.Store(&fresh)}
func (c *appConfig) Get(key string) string { return (*c.values.Load())[key] }
// The singleton, the Go way: a package-level variable. Go initializes package variables// once, before main runs, on a single goroutine, so this needs no locking. Lowercase, so no// other package can replace it.var config = newAppConfig()
// Config is the only way in from outside the package.func Config() *appConfig { return config }
// Services use the shared instance. No copies, so nothing to keep in step.// The cost: nothing in these types says they need config. The Hard level shows what that// hidden dependency does to tests.type OrderService struct{}
func (OrderService) TaxRate() string { return Config().Get("tax_rate") }
type InvoiceService struct{}
func (InvoiceService) TaxRate() string { return Config().Get("tax_rate") }
type ReceiptService struct{}
func (ReceiptService) TaxRate() string { return Config().Get("tax_rate") }
func main() { orders, invoices, receipts := OrderService{}, InvoiceService{}, ReceiptService{}
fmt.Printf("orders: %s, invoices: %s, receipts: %s\n", orders.TaxRate(), invoices.TaxRate(), receipts.TaxRate()) // parsing app.conf (#1) (printed before main started) // orders: 0.08, invoices: 0.08, receipts: 0.08
// Ops raises the tax rate. One reload, and every service sees it. diskAppConf = "currency=USD\ntax_rate=0.10" Config().Reload() // parsing app.conf (#2) fmt.Printf("orders: %s, invoices: %s, receipts: %s\n", orders.TaxRate(), invoices.TaxRate(), receipts.TaxRate()) // orders: 0.10, invoices: 0.10, receipts: 0.10 fmt.Printf("parses: %d\n", diskParses) // parses: 2}// Stands in for the file system so the demo runs anywhere.const disk = { appConf: 'currency=USD\ntax_rate=0.08', parses: 0 };
// In a real app this class lives in config.ts and is not exported, so no other module can// build a second one. They import the instance below instead.class AppConfig { // An immutable snapshot, replaced whole on reload, so a reader never sees a half-built one. private values: ReadonlyMap<string, string> = new Map();
constructor() { this.reload(); }
reload(): void { disk.parses++; console.log(`parsing app.conf (#${disk.parses})`); this.values = new Map(disk.appConf.split('\n').map((line) => line.split('=', 2) as [string, string])); }
get(key: string): string | undefined { return this.values.get(key); }}
// The singleton, the TypeScript way: a module-level instance. In config.ts this is// `export const config = new AppConfig();`. A module is evaluated once and cached, so every// `import { config }` gets this same object.// A class with a private constructor and a static instance also works, but adds nothing the// module doesn't already give.const config = new AppConfig();
// Services use the shared instance. No copies, so nothing to keep in step.// The cost: nothing in these classes' constructors says they need config. The Hard level// shows what that hidden dependency does to tests.class OrderService { taxRate() { return config.get('tax_rate'); }}
class InvoiceService { taxRate() { return config.get('tax_rate'); }}
class ReceiptService { taxRate() { return config.get('tax_rate'); }}
const orders = new OrderService();const invoices = new InvoiceService();const receipts = new ReceiptService();
console.log(`orders: ${orders.taxRate()}, invoices: ${invoices.taxRate()}, receipts: ${receipts.taxRate()}`);// parsing app.conf (#1) (printed when the module loaded)// orders: 0.08, invoices: 0.08, receipts: 0.08
// Ops raises the tax rate. One reload, and every service sees it.disk.appConf = 'currency=USD\ntax_rate=0.10';config.reload(); // parsing app.conf (#2)console.log(`orders: ${orders.taxRate()}, invoices: ${invoices.taxRate()}, receipts: ${receipts.taxRate()}`);// orders: 0.10, invoices: 0.10, receipts: 0.10console.log(`parses: ${disk.parses}`); // parses: 2
export {};What to notice
Section titled “What to notice”- The private constructor is the pattern. Without it, “one instance” is a convention that
the next developer can break with
new AppConfig(). With it, the compiler refuses. Making the classfinalorsealedcloses the other door, a subclass with its own constructor. - Each language has a usual form. Java and C# use a static field in the class. Python and
TypeScript use a module-level instance, because a module runs once and every import shares
it. Go uses a package-level variable behind an unexported type. Writing the Java shape in
Python (a
__new__override that returns a cached instance) works, but it surprises readers and buys little. - The runtime already makes this thread-safe. Java and C# run a class’s static
initialization once, under a lock. Go initializes package variables before
main, on one goroutine. Python runs a module’s top-level code once, under the import lock. None of these need locking code of ours. Only lazy creation does, which is the Medium level. - Reload swaps a whole snapshot.
reloadbuilds a new map, and replaces the old one in one assignment. So a reader on another thread sees all-old or all-new values, never a mix.volatilein Java and C#, andatomic.Pointerin Go, make the new map visible to other threads straight away. - The dependency is hidden. Nothing in
OrderService’s constructor says it needs config. The call is buried in a method. Here that’s tolerable, because config is read-only and the same in tests. For an object with side effects, it isn’t. That’s the Hard level.
Our services each load the config file and they disagree after a reload. Fix it.
Whether you make one config object the only one. That’s a private constructor and a static instance, or the language’s module-level equivalent. And whether you reload it by swapping an immutable snapshot. The red flag is a “reload everywhere” call that has to find every copy. So is a reload that changes the map while other threads read it.
Config should exist once. I’ll make the constructor private and hand out one instance, so every service reads the same values. Reload builds a new immutable snapshot and swaps it in, so readers see old or new, never half of each.
A metrics registry, created lazily
- Lazy creation
- The first-use race
- Double-checked locking and volatile
- Each language’s once primitive
Every request handler in a web service records metrics in a process-wide registry, and an exporter serves them to the monitoring system. Creating the registry is expensive and has side effects: it binds the exporter’s port and starts a background flush thread. The same codebase also ships command-line tools that never record a metric, so the registry should be created on first use, not at startup.
Without protection
Section titled “Without protection”Lazy creation looks like one line: if there’s no instance yet, make one.
import java.util.ArrayList;import java.util.List;import java.util.concurrent.CountDownLatch;import java.util.concurrent.atomic.AtomicInteger;
// The process-wide metrics registry. Creating it is expensive and has side effects: it binds// the exporter's port and starts a flush thread. So it's created lazily, on first use, and// command-line tools that never record a metric never pay for it.final class MetricsRegistry { static final AtomicInteger created = new AtomicInteger();
// Not volatile, no lock. Fine with one thread; wrong with two. private static MetricsRegistry instance;
private final AtomicInteger requests = new AtomicInteger();
private MetricsRegistry() { created.incrementAndGet(); sleep(50); // binding the port and starting the flush thread }
static MetricsRegistry getInstance() { // The race: every thread that arrives during those 50 ms still sees null, so each one // builds its own registry. The last assignment wins; the others become orphans whose // counts are never exported. if (instance == null) { instance = new MetricsRegistry(); } return instance; }
void countRequest() { requests.incrementAndGet(); } int requests() { return requests.get(); }
static void sleep(int ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }}
class Main { public static void main(String[] args) throws InterruptedException { System.out.println("registries created before first use: " + MetricsRegistry.created.get()); // 0
// A cold start: eight request threads record their first metric at the same moment. CountDownLatch go = new CountDownLatch(1); List<Thread> threads = new ArrayList<>(); for (int i = 0; i < 8; i++) { Thread t = new Thread(() -> { try { go.await(); } catch (InterruptedException e) { return; } MetricsRegistry.getInstance().countRequest(); }); t.start(); threads.add(t); } go.countDown(); for (Thread t : threads) t.join();
System.out.println("more than one registry: " + (MetricsRegistry.created.get() > 1)); // true // Requests counted in the orphans are lost: the dashboard under-reports traffic. System.out.println("exported registry saw all 8 requests: " + (MetricsRegistry.getInstance().requests() == 8)); // false }}import threadingimport time
# The process-wide metrics registry. Creating it is expensive and has side effects: it binds# the exporter's port and starts a flush thread. So it's created lazily, on first use, and# command-line tools that never record a metric never pay for it.class MetricsRegistry: created = 0 # No lock. Fine with one thread; wrong with two. _instance: "MetricsRegistry | None" = None
def __init__(self): MetricsRegistry.created += 1 self._requests = 0 self._lock = threading.Lock() time.sleep(0.05) # binding the port and starting the flush thread
@classmethod def get_instance(cls) -> "MetricsRegistry": # The race: every thread that arrives during those 50 ms still sees None, so each one # builds its own registry. The last assignment wins; the others become orphans whose # counts are never exported. The GIL doesn't help: it protects single bytecodes, not # a check followed by a create. if cls._instance is None: cls._instance = MetricsRegistry() return cls._instance
def count_request(self) -> None: with self._lock: self._requests += 1
def requests(self) -> int: return self._requests
if __name__ == "__main__": print(f"registries created before first use: {MetricsRegistry.created}") # 0
# A cold start: eight request threads record their first metric at the same moment. go = threading.Barrier(8)
def handle_request() -> None: go.wait() MetricsRegistry.get_instance().count_request()
threads = [threading.Thread(target=handle_request) for _ in range(8)] for t in threads: t.start() for t in threads: t.join()
print(f"more than one registry: {str(MetricsRegistry.created > 1).lower()}") # true # Requests counted in the orphans are lost: the dashboard under-reports traffic. saw_all = MetricsRegistry.get_instance().requests() == 8 print(f"exported registry saw all 8 requests: {str(saw_all).lower()}") # falseusing System;using System.Collections.Generic;using System.Threading;
// The process-wide metrics registry. Creating it is expensive and has side effects: it binds// the exporter's port and starts a flush thread. So it's created lazily, on first use, and// command-line tools that never record a metric never pay for it.public sealed class MetricsRegistry{ public static int Created;
// Not volatile, no lock. Fine with one thread; wrong with two. private static MetricsRegistry? _instance;
private int _requests;
private MetricsRegistry() { Interlocked.Increment(ref Created); Thread.Sleep(50); // binding the port and starting the flush thread }
public static MetricsRegistry Instance { get { // The race: every thread that arrives during those 50 ms still sees null, so each // one builds its own registry. The last assignment wins; the others become orphans // whose counts are never exported. if (_instance == null) { _instance = new MetricsRegistry(); } return _instance; } }
public void CountRequest() => Interlocked.Increment(ref _requests); public int Requests => Volatile.Read(ref _requests);}
public static class Program{ public static void Main() { Console.WriteLine($"registries created before first use: {MetricsRegistry.Created}"); // 0
// A cold start: eight request threads record their first metric at the same moment. var go = new ManualResetEventSlim(false); var threads = new List<Thread>(); for (var i = 0; i < 8; i++) { var t = new Thread(() => { go.Wait(); MetricsRegistry.Instance.CountRequest(); }); t.Start(); threads.Add(t); } go.Set(); foreach (var t in threads) t.Join();
Console.WriteLine($"more than one registry: {(MetricsRegistry.Created > 1 ? "true" : "false")}"); // true // Requests counted in the orphans are lost: the dashboard under-reports traffic. Console.WriteLine($"exported registry saw all 8 requests: {(MetricsRegistry.Instance.Requests == 8 ? "true" : "false")}"); // false }}package main
import ( "fmt" "sync" "sync/atomic" "time")
// MetricsRegistry is the process-wide metrics registry. Creating it is expensive and has// side effects: it binds the exporter's port and starts a flush goroutine. So it's created// lazily, on first use, and command-line tools that never record a metric never pay for it.type MetricsRegistry struct { requests atomic.Int64}
var created atomic.Int64
// No lock. Fine with one goroutine; wrong with two (and `go run -race` reports it).var instance *MetricsRegistry
func newMetricsRegistry() *MetricsRegistry { created.Add(1) time.Sleep(50 * time.Millisecond) // binding the port and starting the flush goroutine return &MetricsRegistry{}}
func Metrics() *MetricsRegistry { // The race: every goroutine that arrives during those 50 ms still sees nil, so each one // builds its own registry. The last assignment wins; the others become orphans whose // counts are never exported. if instance == nil { instance = newMetricsRegistry() } return instance}
func (m *MetricsRegistry) CountRequest() { m.requests.Add(1) }func (m *MetricsRegistry) Requests() int64 { return m.requests.Load() }
func main() { fmt.Println("registries created before first use:", created.Load()) // 0
// A cold start: eight request goroutines record their first metric at the same moment. gate := make(chan struct{}) var wg sync.WaitGroup for range 8 { wg.Add(1) go func() { defer wg.Done() <-gate Metrics().CountRequest() }() } close(gate) wg.Wait()
fmt.Println("more than one registry:", created.Load() > 1) // true // Requests counted in the orphans are lost: the dashboard under-reports traffic. fmt.Println("exported registry saw all 8 requests:", Metrics().Requests() == 8) // false}const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// The process-wide metrics registry. Creating it is expensive and has side effects: it binds// the exporter's port and starts a flush timer. So it's created lazily, on first use, and// command-line tools that never record a metric never pay for it.class MetricsRegistry { static created = 0; private requests = 0;
private constructor() { MetricsRegistry.created++; }
// Starting is async: binding the port takes a round trip to the OS. static async start(): Promise<MetricsRegistry> { const registry = new MetricsRegistry(); await sleep(50); // binding the port and starting the flush timer return registry; }
countRequest() { this.requests++; } get requestCount() { return this.requests; }}
// Cached only once it's finished starting.let instance: MetricsRegistry | undefined;
async function metrics(): Promise<MetricsRegistry> { // The race, with one thread: every call that arrives during the await still sees // undefined, so each one starts its own registry. The last assignment wins; the others // become orphans whose counts are never exported. if (!instance) { instance = await MetricsRegistry.start(); } return instance;}
console.log(`registries created before first use: ${MetricsRegistry.created}`); // 0
// A cold start: eight requests record their first metric at the same moment.await Promise.all(Array.from({ length: 8 }, async () => (await metrics()).countRequest()));
console.log(`more than one registry: ${MetricsRegistry.created > 1}`); // true// Requests counted in the orphans are lost: the dashboard under-reports traffic.console.log(`exported registry saw all 8 requests: ${(await metrics()).requestCount === 8}`); // false
export {};On a cold start, many request threads ask at once. Every thread that arrives while the first
constructor is still running also sees null, and builds its own. Each thread counts its request
in the registry it built. The static field ends up holding whichever was assigned last. The others
are orphans. Their counts are never exported, and each holds a port and a thread that nothing will
close. Python’s GIL doesn’t prevent this, and neither does TypeScript’s single thread. The check and
the creation are separated by a slow step, and other callers run in between.
With safe lazy initialization
Section titled “With safe lazy initialization”Run the four designs on the same cold start:
- thread 1·
- thread 2·
- thread 3·
- thread 4·
- thread 5·
- thread 6·
- thread 7·
- thread 8·
Each thread asks for the registry, then counts one request in it. Building it takes 900 ms.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
Each language has a way to run a piece of code exactly once, even when many threads ask at the same moment. Use it rather than writing the locking yourself:
import java.util.ArrayList;import java.util.List;import java.util.concurrent.CountDownLatch;import java.util.concurrent.atomic.AtomicInteger;
// The process-wide metrics registry, created lazily and safely.final class MetricsRegistry { static final AtomicInteger created = new AtomicInteger();
private final AtomicInteger requests = new AtomicInteger();
private MetricsRegistry() { created.incrementAndGet(); sleep(50); // binding the port and starting the flush thread }
// The holder idiom. The JVM initializes a class once, on its first use, and makes every // other thread that needs it wait until it's done. Holder is used only inside // getInstance(), so the registry is built on the first call and never twice. // No volatile, no synchronized, and no cost after the first call: the JVM does the locking. private static final class Holder { static final MetricsRegistry INSTANCE = new MetricsRegistry(); }
static MetricsRegistry getInstance() { return Holder.INSTANCE; }
// A failure is permanent: if the constructor throws, the first caller gets // ExceptionInInitializerError and every later caller NoClassDefFoundError. Fine for a // registry; for something that can fail transiently, use double-checked locking instead.
void countRequest() { requests.incrementAndGet(); } int requests() { return requests.get(); }
static void sleep(int ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }}
class Main { public static void main(String[] args) throws InterruptedException { // Reading MetricsRegistry.created initializes MetricsRegistry, but not Holder. System.out.println("registries created before first use: " + MetricsRegistry.created.get()); // 0
// A cold start: eight request threads record their first metric at the same moment. CountDownLatch go = new CountDownLatch(1); List<Thread> threads = new ArrayList<>(); for (int i = 0; i < 8; i++) { Thread t = new Thread(() -> { try { go.await(); } catch (InterruptedException e) { return; } MetricsRegistry.getInstance().countRequest(); }); t.start(); threads.add(t); } go.countDown(); for (Thread t : threads) t.join();
System.out.println("registries created: " + MetricsRegistry.created.get()); // 1 System.out.println("exported registry saw all 8 requests: " + (MetricsRegistry.getInstance().requests() == 8)); // true }}import threadingimport time
# The process-wide metrics registry, created lazily and safely.class MetricsRegistry: created = 0 _instance: "MetricsRegistry | None" = None # One lock just for creating the instance. _instance_lock = threading.Lock()
def __init__(self): MetricsRegistry.created += 1 self._requests = 0 self._lock = threading.Lock() time.sleep(0.05) # binding the port and starting the flush thread
@classmethod def get_instance(cls) -> "MetricsRegistry": # Double-checked locking. The first check skips the lock once the instance exists, # so the common path costs one attribute read. if cls._instance is None: with cls._instance_lock: # Check again: another thread may have created it while we waited for the lock. if cls._instance is None: # Assigned only after __init__ finishes, so no thread sees a half-built one. # If __init__ raises, nothing is stored and the next call tries again. cls._instance = MetricsRegistry() return cls._instance
def count_request(self) -> None: with self._lock: self._requests += 1
def requests(self) -> int: return self._requests
if __name__ == "__main__": print(f"registries created before first use: {MetricsRegistry.created}") # 0
# A cold start: eight request threads record their first metric at the same moment. go = threading.Barrier(8)
def handle_request() -> None: go.wait() MetricsRegistry.get_instance().count_request()
threads = [threading.Thread(target=handle_request) for _ in range(8)] for t in threads: t.start() for t in threads: t.join()
print(f"registries created: {MetricsRegistry.created}") # 1 saw_all = MetricsRegistry.get_instance().requests() == 8 print(f"exported registry saw all 8 requests: {str(saw_all).lower()}") # trueusing System;using System.Collections.Generic;using System.Threading;
// The process-wide metrics registry, created lazily and safely.public sealed class MetricsRegistry{ public static int Created;
// Lazy<T> runs the factory once, on first .Value, and makes other threads wait for it. // The default mode (ExecutionAndPublication) is the thread-safe one; no locking code here. // A failure is cached in this mode: if the constructor throws, every later .Value throws // the same exception. Fine for a registry; for something that can fail transiently, // use LazyThreadSafetyMode.PublicationOnly or a lock you control. private static readonly Lazy<MetricsRegistry> _instance = new(() => new MetricsRegistry());
public static MetricsRegistry Instance => _instance.Value;
private int _requests;
private MetricsRegistry() { Interlocked.Increment(ref Created); Thread.Sleep(50); // binding the port and starting the flush thread }
public void CountRequest() => Interlocked.Increment(ref _requests); public int Requests => Volatile.Read(ref _requests);}
public static class Program{ public static void Main() { Console.WriteLine($"registries created before first use: {MetricsRegistry.Created}"); // 0
// A cold start: eight request threads record their first metric at the same moment. var go = new ManualResetEventSlim(false); var threads = new List<Thread>(); for (var i = 0; i < 8; i++) { var t = new Thread(() => { go.Wait(); MetricsRegistry.Instance.CountRequest(); }); t.Start(); threads.Add(t); } go.Set(); foreach (var t in threads) t.Join();
Console.WriteLine($"registries created: {MetricsRegistry.Created}"); // 1 Console.WriteLine($"exported registry saw all 8 requests: {(MetricsRegistry.Instance.Requests == 8 ? "true" : "false")}"); // true }}package main
import ( "fmt" "sync" "sync/atomic" "time")
// MetricsRegistry is the process-wide metrics registry, created lazily and safely.type MetricsRegistry struct { requests atomic.Int64}
var created atomic.Int64
func newMetricsRegistry() *MetricsRegistry { created.Add(1) time.Sleep(50 * time.Millisecond) // binding the port and starting the flush goroutine return &MetricsRegistry{}}
// Metrics returns the registry. sync.OnceValue (Go 1.21) runs newMetricsRegistry once, on// the first call; goroutines that call it meanwhile wait, then all get the same value. Later// calls are a fast atomic check, with no lock.// If the function panics, every later call panics with the same value: the failure is// permanent. Fine for a registry; for something that can fail transiently, use a mutex.var Metrics = sync.OnceValue(newMetricsRegistry)
func (m *MetricsRegistry) CountRequest() { m.requests.Add(1) }func (m *MetricsRegistry) Requests() int64 { return m.requests.Load() }
func main() { fmt.Println("registries created before first use:", created.Load()) // 0
// A cold start: eight request goroutines record their first metric at the same moment. gate := make(chan struct{}) var wg sync.WaitGroup for range 8 { wg.Add(1) go func() { defer wg.Done() <-gate Metrics().CountRequest() }() } close(gate) wg.Wait()
fmt.Println("registries created:", created.Load()) // 1 fmt.Println("exported registry saw all 8 requests:", Metrics().Requests() == 8) // true}const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// The process-wide metrics registry, created lazily and safely.class MetricsRegistry { static created = 0; private requests = 0;
private constructor() { MetricsRegistry.created++; }
static async start(): Promise<MetricsRegistry> { const registry = new MetricsRegistry(); await sleep(50); // binding the port and starting the flush timer return registry; }
countRequest() { this.requests++; } get requestCount() { return this.requests; }}
// Cache the promise, not the registry. JavaScript has one thread, so the check and the// assignment below can't be interleaved; the promise exists before the first await, and// every call that arrives while it's starting gets the same one.let instance: Promise<MetricsRegistry> | undefined;
function metrics(): Promise<MetricsRegistry> { if (!instance) { instance = MetricsRegistry.start(); // A rejected promise would be cached forever. Forget it, so the next call tries again. instance.catch(() => (instance = undefined)); } return instance;}
console.log(`registries created before first use: ${MetricsRegistry.created}`); // 0
// A cold start: eight requests record their first metric at the same moment.await Promise.all(Array.from({ length: 8 }, async () => (await metrics()).countRequest()));
console.log(`registries created: ${MetricsRegistry.created}`); // 1console.log(`exported registry saw all 8 requests: ${(await metrics()).requestCount === 8}`); // true
export {};Double-checked locking, and why volatile
Section titled “Double-checked locking, and why volatile”Locking every call to getInstance() is correct but takes the lock forever, long after the
instance exists. Double-checked locking takes the lock only while the instance is missing:
private static volatile MetricsRegistry instance; // volatile is not optional
static MetricsRegistry getInstance() { MetricsRegistry r = instance; // fast path: one read, no lock if (r == null) { synchronized (MetricsRegistry.class) { r = instance; // check again: someone may have won the race if (r == null) instance = r = new MetricsRegistry(); } } return r;}Without volatile, Java allows a surprising thing. Another thread may see the reference to the new
object before it sees what the constructor wrote into the object’s fields. That thread takes the
fast path, skips the lock, and uses an object that looks half-built. Declaring the field volatile
(Java 5 and later) forbids this. Any thread that reads the non-null reference also sees everything
the constructor wrote. In C#, Lazy<T> saves you from thinking about this at all.
What to notice
Section titled “What to notice”- Prefer what the platform gives you. The holder idiom in Java,
Lazy<T>in C#,sync.OnceValuein Go, and a cached promise in TypeScript all work the same way. They run the creation once, make other callers wait, and cost a plain read afterwards. Double-checked locking is worth knowing, because it’s asked about, and because Python needs it. It’s not the first thing to write in Java. - A Java enum is the other safe form.
enum Registry { INSTANCE; ... }is created once by the JVM. It survives serialization without creating a copy, and reflection can’t create another. Its limits: it’s created as soon as the enum is first used, and it can’t extend a class. - Decide what happens when creation fails. Say the constructor throws. Python’s version stores
nothing, and the next call tries again. Java’s holder idiom fails for good (
NoClassDefFoundErroron every later call). C#’sLazy<T>, in its default mode, caches the exception. Go’ssync.OnceValuepanics again on every call. That’s fine for a registry that fails only on a bug. For something that can fail on a network blip, use a lock you control, so the next call can retry. - In TypeScript, cache the promise. Caching the finished registry leaves a gap during the
await. Caching the promise closes it, because the promise exists before the first await. Forget a rejected promise, or every later call gets the same old error. - Eager is often simpler. If the program always needs the object, create it at startup and the race disappears. Laziness earns its complexity only when some runs never use it.
Make this
getInstance()thread-safe. Then make it fast.
Whether you spot the check-then-create race, and fix it with the language’s once primitive or a
lock. Then whether you remove the lock from the hot path (holder idiom, Lazy<T>, sync.Once,
or double-checked locking with volatile). The red flag is double-checked locking without
volatile. So is a lock on every call, presented as the final answer.
A payment gateway and a nightly job
- The cost of global access
- One instance, passed in
- One per process, not per system
- Leases and fencing tokens
A checkout service charges cards through a payment API. The gateway client should exist once per process: it holds a pool of HTTPS connections and an API key. The same app runs on three servers behind a load balancer, and it has a nightly job that emails invoices at 02:00.
Both were written as singletons. Each causes a different problem, and the fixes are the two halves of this level.
The cost of global access
Section titled “The cost of global access”CheckoutService calls PaymentGateway.getInstance() inside its method. A unit test can’t
give it anything else, so it charges the real API. The usual workaround is a static
setInstanceForTesting, and that’s global state too:
// The payment gateway client as a classic singleton. One HTTP client and one API key for the// whole process, which is right. Reaching it through a static method is what goes wrong.class PaymentGateway { private static PaymentGateway instance = new PaymentGateway();
static PaymentGateway getInstance() { return instance; }
// The workaround every codebase with singletons grows: a static door for tests. It's // global state, so whatever one test puts here, the next test inherits. static void setInstanceForTesting(PaymentGateway fake) { instance = fake; }
String charge(int cents) { System.out.println("POST https://api.payments.example/charges " + cents); return "ch_live_1"; }}
class DecliningGateway extends PaymentGateway { @Override String charge(int cents) { throw new IllegalStateException("card declined"); }}
// Nothing in the constructor says this class needs a payment gateway. The dependency is// hidden inside a method body, so a reader, a reviewer and a test all have to go looking.class CheckoutService { String checkout(int cents) { return PaymentGateway.getInstance().charge(cents); }}
class Main { static void test(String name, Runnable body) { try { body.run(); System.out.println("test " + name + ": ok"); } catch (RuntimeException e) { System.out.println("test " + name + ": FAILED, " + e.getMessage()); } }
public static void main(String[] args) { // No way to pass a fake, so the first test makes a real network call. test("charges card", () -> new CheckoutService().checkout(4999)); // POST https://api.payments.example/charges 4999 // test charges card: ok
test("declined card", () -> { PaymentGateway.setInstanceForTesting(new DecliningGateway()); try { new CheckoutService().checkout(4999); throw new RuntimeException("expected a decline"); } catch (IllegalStateException expected) { } // Forgot to put the real one back. Nothing forces anyone to. }); // test declined card: ok
// Same test as the first, now failing because of the test that ran before it. // Run them in a different order and it passes: a flaky suite. test("charges card again", () -> new CheckoutService().checkout(4999)); // test charges card again: FAILED, card declined }}from typing import Callable
# The payment gateway client as a classic singleton. One HTTP client and one API key for the# whole process, which is right. Reaching it through a global is what goes wrong.class PaymentGateway: _instance: "PaymentGateway | None" = None
@classmethod def get_instance(cls) -> "PaymentGateway": if cls._instance is None: cls._instance = PaymentGateway() return cls._instance
# The workaround every codebase with singletons grows: a back door for tests. It's # global state, so whatever one test puts here, the next test inherits. @classmethod def set_instance_for_testing(cls, fake: "PaymentGateway") -> None: cls._instance = fake
def charge(self, cents: int) -> str: print(f"POST https://api.payments.example/charges {cents}") return "ch_live_1"
class DecliningGateway(PaymentGateway): def charge(self, cents: int) -> str: raise RuntimeError("card declined")
# Nothing in the constructor says this class needs a payment gateway. The dependency is# hidden inside a method body, so a reader, a reviewer and a test all have to go looking.class CheckoutService: def checkout(self, cents: int) -> str: return PaymentGateway.get_instance().charge(cents)
def test(name: str, body: Callable[[], None]) -> None: try: body() print(f"test {name}: ok") except Exception as e: print(f"test {name}: FAILED, {e}")
def test_charges_card() -> None: CheckoutService().checkout(4999)
def test_declined() -> None: PaymentGateway.set_instance_for_testing(DecliningGateway()) try: CheckoutService().checkout(4999) raise AssertionError("expected a decline") except RuntimeError: pass # Forgot to put the real one back. Nothing forces anyone to.
if __name__ == "__main__": # No way to pass a fake, so the first test makes a real network call. test("charges card", test_charges_card) # POST https://api.payments.example/charges 4999 # test charges card: ok
test("declined card", test_declined) # test declined card: ok
# Same test as the first, now failing because of the test that ran before it. # Run them in a different order and it passes: a flaky suite. test("charges card again", test_charges_card) # test charges card again: FAILED, card declinedusing System;
// The payment gateway client as a classic singleton. One HTTP client and one API key for the// whole process, which is right. Reaching it through a static property is what goes wrong.public class PaymentGateway{ public static PaymentGateway Instance { get; private set; } = new PaymentGateway();
// The workaround every codebase with singletons grows: a static door for tests. It's // global state, so whatever one test puts here, the next test inherits. public static void SetInstanceForTesting(PaymentGateway fake) => Instance = fake;
public virtual string Charge(int cents) { Console.WriteLine($"POST https://api.payments.example/charges {cents}"); return "ch_live_1"; }}
public sealed class DecliningGateway : PaymentGateway{ public override string Charge(int cents) => throw new InvalidOperationException("card declined");}
// Nothing in the constructor says this class needs a payment gateway. The dependency is// hidden inside a method body, so a reader, a reviewer and a test all have to go looking.public sealed class CheckoutService{ public string Checkout(int cents) => PaymentGateway.Instance.Charge(cents);}
public static class Program{ static void Test(string name, Action body) { try { body(); Console.WriteLine($"test {name}: ok"); } catch (Exception e) { Console.WriteLine($"test {name}: FAILED, {e.Message}"); } }
public static void Main() { // No way to pass a fake, so the first test makes a real network call. Test("charges card", () => new CheckoutService().Checkout(4999)); // POST https://api.payments.example/charges 4999 // test charges card: ok
Test("declined card", () => { PaymentGateway.SetInstanceForTesting(new DecliningGateway()); try { new CheckoutService().Checkout(4999); throw new Exception("expected a decline"); } catch (InvalidOperationException) { } // Forgot to put the real one back. Nothing forces anyone to. }); // test declined card: ok
// Same test as the first, now failing because of the test that ran before it. // Run them in a different order and it passes: a flaky suite. Test("charges card again", () => new CheckoutService().Checkout(4999)); // test charges card again: FAILED, card declined }}package main
import ( "errors" "fmt")
// PaymentGateway is the payment client. One HTTP client and one API key for the whole// process, which is right. Reaching it through a package variable is what goes wrong.type PaymentGateway interface { Charge(cents int) (string, error)}
type httpGateway struct{}
func (httpGateway) Charge(cents int) (string, error) { fmt.Println("POST https://api.payments.example/charges", cents) return "ch_live_1", nil}
// Gateway is the singleton, as a package-level variable. Exported and assignable, because// that is the only way tests can swap it. It's global state, so whatever one test puts here,// the next test inherits.var Gateway PaymentGateway = httpGateway{}
type decliningGateway struct{}
func (decliningGateway) Charge(int) (string, error) { return "", errors.New("card declined") }
// CheckoutService doesn't say it needs a payment gateway. The dependency is hidden inside a// method body, so a reader, a reviewer and a test all have to go looking.type CheckoutService struct{}
func (CheckoutService) Checkout(cents int) (string, error) { return Gateway.Charge(cents) }
func test(name string, body func() error) { if err := body(); err != nil { fmt.Printf("test %s: FAILED, %v\n", name, err) return } fmt.Printf("test %s: ok\n", name)}
func main() { chargesCard := func() error { _, err := CheckoutService{}.Checkout(4999) return err }
// No way to pass a fake, so the first test makes a real network call. test("charges card", chargesCard) // POST https://api.payments.example/charges 4999 // test charges card: ok
test("declined card", func() error { Gateway = decliningGateway{} if _, err := (CheckoutService{}).Checkout(4999); err == nil { return errors.New("expected a decline") } // Forgot to put the real one back. Nothing forces anyone to. return nil }) // test declined card: ok
// Same test as the first, now failing because of the test that ran before it. // Run them in a different order and it passes: a flaky suite. test("charges card again", chargesCard) // test charges card again: FAILED, card declined}// The payment gateway client as a classic singleton. One HTTP client and one API key for the// whole process, which is right. Reaching it through a static method is what goes wrong.class PaymentGateway { private static instance: PaymentGateway = new PaymentGateway();
static getInstance(): PaymentGateway { return PaymentGateway.instance; }
// The workaround every codebase with singletons grows: a static door for tests. It's // global state, so whatever one test puts here, the next test inherits. static setInstanceForTesting(fake: PaymentGateway): void { PaymentGateway.instance = fake; }
charge(cents: number): string { console.log(`POST https://api.payments.example/charges ${cents}`); return 'ch_live_1'; }}
class DecliningGateway extends PaymentGateway { override charge(): string { throw new Error('card declined'); }}
// Nothing in the constructor says this class needs a payment gateway. The dependency is// hidden inside a method body, so a reader, a reviewer and a test all have to go looking.class CheckoutService { checkout(cents: number): string { return PaymentGateway.getInstance().charge(cents); }}
function test(name: string, body: () => void): void { try { body(); console.log(`test ${name}: ok`); } catch (e) { console.log(`test ${name}: FAILED, ${(e as Error).message}`); }}
// No way to pass a fake, so the first test makes a real network call.test('charges card', () => void new CheckoutService().checkout(4999));// POST https://api.payments.example/charges 4999// test charges card: ok
test('declined card', () => { PaymentGateway.setInstanceForTesting(new DecliningGateway()); let declined = false; try { new CheckoutService().checkout(4999); } catch { declined = true; } if (!declined) throw new Error('expected a decline'); // Forgot to put the real one back. Nothing forces anyone to.}); // test declined card: ok
// Same test as the first, now failing because of the test that ran before it.// Run them in a different order and it passes: a flaky suite.test('charges card again', () => void new CheckoutService().checkout(4999));// test charges card again: FAILED, card declined
export {};The third test fails only because the second one didn’t clean up. Run them in another order,
or in parallel, and the results change. Every class that calls getInstance() has a
dependency that its constructor doesn’t mention, so the only way to find them is to read
every method.
One instance, passed in
Section titled “One instance, passed in”Keep the “exactly one”, and drop the global access. The gateway becomes an ordinary class behind an interface. The composition root (the startup code that wires the app) creates one and passes the same object to every service that needs it. Tests create their own fakes:
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
import java.util.ArrayList;import java.util.List;
// What callers depend on: an interface, not a class with a static getInstance().interface PaymentGateway { String charge(int cents); void refund(String chargeId);}
// The real client. An ordinary class with a public constructor: nothing in it enforces "one".// That decision moved to the composition root, which is the only code that builds it.class HttpPaymentGateway implements PaymentGateway { static int created = 0; private final String apiKey;
HttpPaymentGateway(String apiKey) { this.apiKey = apiKey; created++; }
public String charge(int cents) { System.out.println("POST https://api.payments.example/charges " + cents); return "ch_live_1"; }
public void refund(String chargeId) { System.out.println("POST https://api.payments.example/refunds " + chargeId); }}
// The dependency is in the constructor, where a reader, a reviewer and a test can all see it.class CheckoutService { private final PaymentGateway gateway; CheckoutService(PaymentGateway gateway) { this.gateway = gateway; } String checkout(int cents) { return gateway.charge(cents); }}
class RefundService { private final PaymentGateway gateway; RefundService(PaymentGateway gateway) { this.gateway = gateway; } void refund(String chargeId) { gateway.refund(chargeId); }}
// A test double: records what it was asked to do, or declines. Each test makes its own,// so no test can see another's.class FakeGateway implements PaymentGateway { final List<Integer> charges = new ArrayList<>(); private final boolean decline; FakeGateway(boolean decline) { this.decline = decline; }
public String charge(int cents) { if (decline) throw new IllegalStateException("card declined"); charges.add(cents); return "ch_fake_" + charges.size(); }
public void refund(String chargeId) { }}
class Main { static void test(String name, Runnable body) { try { body.run(); System.out.println("test " + name + ": ok"); } catch (RuntimeException e) { System.out.println("test " + name + ": FAILED, " + e.getMessage()); } }
static void chargesCard() { FakeGateway fake = new FakeGateway(false); new CheckoutService(fake).checkout(4999); if (!fake.charges.equals(List.of(4999))) throw new RuntimeException("expected one charge of 4999"); }
public static void main(String[] args) { // Composition root: the startup code. It creates ONE gateway and hands the same object // to everyone who needs it. Still exactly one instance in production, by decision // rather than by a private constructor. DI containers call this "singleton scope". PaymentGateway gateway = new HttpPaymentGateway("live-key"); CheckoutService checkout = new CheckoutService(gateway); RefundService refunds = new RefundService(gateway);
String id = checkout.checkout(4999); // POST https://api.payments.example/charges 4999 refunds.refund(id); // POST https://api.payments.example/refunds ch_live_1 System.out.println("gateways created: " + HttpPaymentGateway.created); // gateways created: 1
// Tests build their own objects. No network, nothing global, nothing to reset, and // they pass in any order. test("charges card", Main::chargesCard); // test charges card: ok test("declined card", () -> { try { new CheckoutService(new FakeGateway(true)).checkout(4999); throw new RuntimeException("expected a decline"); } catch (IllegalStateException expected) { } }); // test declined card: ok test("charges card again", Main::chargesCard); // test charges card again: ok }}from typing import Callable, Protocol
# What callers depend on: a protocol, not a class with a get_instance().class PaymentGateway(Protocol): def charge(self, cents: int) -> str: ... def refund(self, charge_id: str) -> None: ...
# The real client. An ordinary class: nothing in it enforces "one". That decision moved to# the composition root, which is the only code that builds it.class HttpPaymentGateway: created = 0
def __init__(self, api_key: str): self._api_key = api_key HttpPaymentGateway.created += 1
def charge(self, cents: int) -> str: print(f"POST https://api.payments.example/charges {cents}") return "ch_live_1"
def refund(self, charge_id: str) -> None: print(f"POST https://api.payments.example/refunds {charge_id}")
# The dependency is in the constructor, where a reader, a reviewer and a test can all see it.class CheckoutService: def __init__(self, gateway: PaymentGateway): self._gateway = gateway
def checkout(self, cents: int) -> str: return self._gateway.charge(cents)
class RefundService: def __init__(self, gateway: PaymentGateway): self._gateway = gateway
def refund(self, charge_id: str) -> None: self._gateway.refund(charge_id)
# A test double: records what it was asked to do, or declines. Each test makes its own,# so no test can see another's.class FakeGateway: def __init__(self, decline: bool): self.charges: list[int] = [] self._decline = decline
def charge(self, cents: int) -> str: if self._decline: raise RuntimeError("card declined") self.charges.append(cents) return f"ch_fake_{len(self.charges)}"
def refund(self, charge_id: str) -> None: pass
def test(name: str, body: Callable[[], None]) -> None: try: body() print(f"test {name}: ok") except Exception as e: print(f"test {name}: FAILED, {e}")
def test_charges_card() -> None: fake = FakeGateway(decline=False) CheckoutService(fake).checkout(4999) assert fake.charges == [4999], "expected one charge of 4999"
def test_declined() -> None: try: CheckoutService(FakeGateway(decline=True)).checkout(4999) raise AssertionError("expected a decline") except RuntimeError: pass
if __name__ == "__main__": # Composition root: the startup code. It creates ONE gateway and hands the same object to # everyone who needs it. Still exactly one instance in production, by decision rather # than by enforcement. DI containers call this "singleton scope". gateway = HttpPaymentGateway("live-key") checkout = CheckoutService(gateway) refunds = RefundService(gateway)
charge_id = checkout.checkout(4999) # POST https://api.payments.example/charges 4999 refunds.refund(charge_id) # POST https://api.payments.example/refunds ch_live_1 print(f"gateways created: {HttpPaymentGateway.created}") # gateways created: 1
# Tests build their own objects. No network, nothing global, nothing to reset, and they # pass in any order. test("charges card", test_charges_card) # test charges card: ok test("declined card", test_declined) # test declined card: ok test("charges card again", test_charges_card) # test charges card again: okusing System;using System.Collections.Generic;using System.Linq;
// What callers depend on: an interface, not a class with a static Instance.public interface IPaymentGateway{ string Charge(int cents); void Refund(string chargeId);}
// The real client. An ordinary class with a public constructor: nothing in it enforces "one".// That decision moved to the composition root, which is the only code that builds it.public sealed class HttpPaymentGateway : IPaymentGateway{ public static int Created; private readonly string _apiKey;
public HttpPaymentGateway(string apiKey) { _apiKey = apiKey; Created++; }
public string Charge(int cents) { Console.WriteLine($"POST https://api.payments.example/charges {cents}"); return "ch_live_1"; }
public void Refund(string chargeId) => Console.WriteLine($"POST https://api.payments.example/refunds {chargeId}");}
// The dependency is in the constructor, where a reader, a reviewer and a test can all see it.public sealed class CheckoutService(IPaymentGateway gateway){ public string Checkout(int cents) => gateway.Charge(cents);}
public sealed class RefundService(IPaymentGateway gateway){ public void Refund(string chargeId) => gateway.Refund(chargeId);}
// A test double: records what it was asked to do, or declines. Each test makes its own,// so no test can see another's.public sealed class FakeGateway(bool decline) : IPaymentGateway{ public List<int> Charges { get; } = new();
public string Charge(int cents) { if (decline) throw new InvalidOperationException("card declined"); Charges.Add(cents); return $"ch_fake_{Charges.Count}"; }
public void Refund(string chargeId) { }}
public static class Program{ static void Test(string name, Action body) { try { body(); Console.WriteLine($"test {name}: ok"); } catch (Exception e) { Console.WriteLine($"test {name}: FAILED, {e.Message}"); } }
static void ChargesCard() { var fake = new FakeGateway(decline: false); new CheckoutService(fake).Checkout(4999); if (!fake.Charges.SequenceEqual(new[] { 4999 })) throw new Exception("expected one charge of 4999"); }
public static void Main() { // Composition root: the startup code. It creates ONE gateway and hands the same object // to everyone who needs it. Still exactly one instance in production, by decision // rather than by a private constructor. With Microsoft.Extensions.DependencyInjection // this is services.AddSingleton<IPaymentGateway>(...): "singleton scope". IPaymentGateway gateway = new HttpPaymentGateway("live-key"); var checkout = new CheckoutService(gateway); var refunds = new RefundService(gateway);
var id = checkout.Checkout(4999); // POST https://api.payments.example/charges 4999 refunds.Refund(id); // POST https://api.payments.example/refunds ch_live_1 Console.WriteLine($"gateways created: {HttpPaymentGateway.Created}"); // gateways created: 1
// Tests build their own objects. No network, nothing global, nothing to reset, and // they pass in any order. Test("charges card", ChargesCard); // test charges card: ok Test("declined card", () => { try { new CheckoutService(new FakeGateway(decline: true)).Checkout(4999); throw new Exception("expected a decline"); } catch (InvalidOperationException) { } }); // test declined card: ok Test("charges card again", ChargesCard); // test charges card again: ok }}package main
import ( "errors" "fmt" "slices")
// PaymentGateway is what callers depend on: a small interface, not a package variable.type PaymentGateway interface { Charge(cents int) (string, error) Refund(chargeID string) error}
var gatewaysCreated = 0
// HTTPGateway is the real client. An ordinary type with an ordinary constructor: nothing in// it enforces "one". That decision moved to main, the only code that builds it.type HTTPGateway struct{ apiKey string }
func NewHTTPGateway(apiKey string) *HTTPGateway { gatewaysCreated++ return &HTTPGateway{apiKey}}
func (g *HTTPGateway) Charge(cents int) (string, error) { fmt.Println("POST https://api.payments.example/charges", cents) return "ch_live_1", nil}
func (g *HTTPGateway) Refund(chargeID string) error { fmt.Println("POST https://api.payments.example/refunds", chargeID) return nil}
// CheckoutService takes its gateway in its constructor, where a reader, a reviewer and a// test can all see it.type CheckoutService struct{ gateway PaymentGateway }
func NewCheckoutService(g PaymentGateway) *CheckoutService { return &CheckoutService{g} }func (s *CheckoutService) Checkout(cents int) (string, error) { return s.gateway.Charge(cents)}
type RefundService struct{ gateway PaymentGateway }
func NewRefundService(g PaymentGateway) *RefundService { return &RefundService{g} }func (s *RefundService) Refund(chargeID string) error { return s.gateway.Refund(chargeID) }
// fakeGateway is a test double: it records what it was asked to do, or declines. Each test// makes its own, so no test can see another's.type fakeGateway struct { charges []int decline bool}
func (f *fakeGateway) Charge(cents int) (string, error) { if f.decline { return "", errors.New("card declined") } f.charges = append(f.charges, cents) return fmt.Sprintf("ch_fake_%d", len(f.charges)), nil}
func (f *fakeGateway) Refund(string) error { return nil }
func test(name string, body func() error) { if err := body(); err != nil { fmt.Printf("test %s: FAILED, %v\n", name, err) return } fmt.Printf("test %s: ok\n", name)}
func chargesCard() error { fake := &fakeGateway{} if _, err := NewCheckoutService(fake).Checkout(4999); err != nil { return err } if !slices.Equal(fake.charges, []int{4999}) { return errors.New("expected one charge of 4999") } return nil}
func main() { // Composition root: main creates ONE gateway and hands the same value to everyone who // needs it. Still exactly one instance in production, by decision rather than by a // package variable. DI containers call this "singleton scope". gateway := NewHTTPGateway("live-key") checkout := NewCheckoutService(gateway) refunds := NewRefundService(gateway)
id, _ := checkout.Checkout(4999) // POST https://api.payments.example/charges 4999 refunds.Refund(id) // POST https://api.payments.example/refunds ch_live_1 fmt.Println("gateways created:", gatewaysCreated) // gateways created: 1
// Tests build their own values. No network, nothing global, nothing to reset, and they // pass in any order. test("charges card", chargesCard) // test charges card: ok test("declined card", func() error { if _, err := NewCheckoutService(&fakeGateway{decline: true}).Checkout(4999); err == nil { return errors.New("expected a decline") } return nil }) // test declined card: ok test("charges card again", chargesCard) // test charges card again: ok}// What callers depend on: an interface, not a class with a static getInstance().interface PaymentGateway { charge(cents: number): string; refund(chargeId: string): void;}
// The real client. An ordinary class with a public constructor: nothing in it enforces "one".// That decision moved to the composition root, which is the only code that builds it.class HttpPaymentGateway implements PaymentGateway { static created = 0;
constructor(private readonly apiKey: string) { HttpPaymentGateway.created++; }
charge(cents: number): string { console.log(`POST https://api.payments.example/charges ${cents}`); return 'ch_live_1'; }
refund(chargeId: string): void { console.log(`POST https://api.payments.example/refunds ${chargeId}`); }}
// The dependency is in the constructor, where a reader, a reviewer and a test can all see it.class CheckoutService { constructor(private readonly gateway: PaymentGateway) {} checkout(cents: number): string { return this.gateway.charge(cents); }}
class RefundService { constructor(private readonly gateway: PaymentGateway) {} refund(chargeId: string): void { this.gateway.refund(chargeId); }}
// A test double: records what it was asked to do, or declines. Each test makes its own,// so no test can see another's.class FakeGateway implements PaymentGateway { readonly charges: number[] = []; constructor(private readonly decline: boolean) {}
charge(cents: number): string { if (this.decline) throw new Error('card declined'); this.charges.push(cents); return `ch_fake_${this.charges.length}`; }
refund(): void {}}
function test(name: string, body: () => void): void { try { body(); console.log(`test ${name}: ok`); } catch (e) { console.log(`test ${name}: FAILED, ${(e as Error).message}`); }}
function chargesCard(): void { const fake = new FakeGateway(false); new CheckoutService(fake).checkout(4999); if (fake.charges.join() !== '4999') throw new Error('expected one charge of 4999');}
// Composition root: the startup code. It creates ONE gateway and hands the same object to// everyone who needs it. Still exactly one instance in production, by decision rather than// by enforcement. DI containers call this "singleton scope".const gateway: PaymentGateway = new HttpPaymentGateway('live-key');const checkout = new CheckoutService(gateway);const refunds = new RefundService(gateway);
const id = checkout.checkout(4999); // POST https://api.payments.example/charges 4999refunds.refund(id); // POST https://api.payments.example/refunds ch_live_1console.log(`gateways created: ${HttpPaymentGateway.created}`); // gateways created: 1
// Tests build their own objects. No network, nothing global, nothing to reset, and they// pass in any order.test('charges card', chargesCard); // test charges card: oktest('declined card', () => { let declined = false; try { new CheckoutService(new FakeGateway(true)).checkout(4999); } catch { declined = true; } if (!declined) throw new Error('expected a decline');}); // test declined card: oktest('charges card again', chargesCard); // test charges card again: ok
export {};There is still exactly one HttpPaymentGateway in production. The difference is who decides. It’s
the startup code, in one place, instead of the class, everywhere. DI containers call this
singleton scope. Spring beans are singletons by default. .NET has AddSingleton, and Guice has
@Singleton. The container creates one and injects it. The class itself has a public constructor,
and knows nothing about it.
One per process is not one per system
Section titled “One per process is not one per system”The nightly invoice job was also a singleton: one scheduler per process, so it can’t run twice. But with three servers, there are three processes and three schedulers. At 02:00, every customer gets three invoices. The same can happen inside one process, when the class is loaded twice. Two class loaders in a Java app server can do it. So can two copies of a module under different paths in Node. Each copy has its own static field.
Making something happen once across servers needs a shared place to agree, and a database the
servers already use is the simplest one. A job_leases row says who may run the job and until
when. A server takes the lease with one conditional update, renews it while it runs, and
another server can take it only after it expires:
-- One row per job, inserted once by a migration.UPDATE job_leasesSET owner = :me, expires_at = now() + interval '5 minutes', token = CASE WHEN owner = :me THEN token ELSE token + 1 ENDWHERE job = 'nightly-invoices' AND (owner = :me OR expires_at <= now());-- 1 row updated: we hold the lease. 0 rows: someone else does.Run the job on three servers, with and without the lease. Then stop the leader in the middle of its run, let the lease expire, and wake it up again:
no lease: each server decides aloneserver-a
waiting for 02:00
server-b
waiting for 02:00
server-c
waiting for 02:00
- Color key
- Background worker
import java.util.HashSet;import java.util.Set;
// One row in a shared database table. owner and expiresAt say who may run the job and until// when. token goes up every time a different server takes over, so writes can be fenced.record Lease(String owner, int expiresAt, long token) {}
// The shared database. Each method stands for one atomic SQL statement; synchronized plays the// part of the database's row lock, so two servers can't both win.class LeaseStore { private Lease lease = new Lease("nobody", 0, 0); private final Set<String> doneDates = new HashSet<>(); int runs = 0;
// UPDATE job_leases SET owner = :me, expires_at = :now + :ttl, token = ... // WHERE job = 'nightly-invoices' AND (owner = :me OR expires_at <= :now) // Succeeds if we already hold it (a renewal) or the holder's time is up. synchronized Long tryAcquire(String me, int now, int ttl) { boolean mine = lease.owner().equals(me); if (!mine && lease.expiresAt() > now) return null; long token = mine ? lease.token() : lease.token() + 1; lease = new Lease(me, now + ttl, token); return token; }
synchronized Lease current() { return lease; } synchronized boolean isDone(String date) { return doneDates.contains(date); }
// The fenced write: it carries the token its writer was granted, and the store refuses // a token older than the current lease's. A server that lost its lease while it was // paused can't write, however sure it is that it's still the leader. synchronized boolean recordRun(String date, long token) { if (token < lease.token() || doneDates.contains(date)) return false; doneDates.add(date); runs++; return true; }}
// One app server. In production each is a separate process, so each has its own copy of// every "singleton", including the scheduler. The lease is what makes the job run once.class Server { static final int TTL = 5; // minutes; the leader renews well before this while it runs final String name; private final LeaseStore store;
Server(String name, LeaseStore store) { this.name = name; this.store = store; }
static String clock(int minute) { return String.format("02:%02d", minute); } static void log(int minute, String text) { System.out.println(clock(minute) + " " + text); }
// Called by this server's scheduler every minute from 02:00. Long tryStart(int now, String date) { if (store.isDone(date)) { log(now, name + " skips: already done for " + date); return null; } Long token = store.tryAcquire(name, now, TTL); if (token == null) { Lease held = store.current(); log(now, name + " skips: held by " + held.owner() + " until " + clock(held.expiresAt())); return null; } log(now, name + " holds the lease (token " + token + ")"); return token; }
void finish(int now, String date, long token) { if (store.recordRun(date, token)) { log(now, name + " sends invoices for " + date); } else { log(now, name + " is rejected: token " + token + " is stale (current " + store.current().token() + ")"); } }}
class Main { public static void main(String[] args) { String date = "2026-10-26";
System.out.println("-- without a lease"); // Each process's scheduler is "the only one" in its process, so all three fire. for (String name : new String[] {"server-a", "server-b", "server-c"}) { Server.log(0, name + " sends invoices for " + date); } // 02:00 server-a sends invoices for 2026-10-26 // 02:00 server-b sends invoices for 2026-10-26 // 02:00 server-c sends invoices for 2026-10-26
System.out.println("-- with a lease"); LeaseStore db = new LeaseStore(); Server a = new Server("server-a", db), b = new Server("server-b", db), c = new Server("server-c", db);
Long tokenA = a.tryStart(0, date); // 02:00 server-a holds the lease (token 1) b.tryStart(0, date); // 02:00 server-b skips: held by server-a until 02:05 c.tryStart(0, date); // 02:00 server-c skips: held by server-a until 02:05
// A long GC pause, a frozen VM or a dead host: from outside they look the same. Server.log(1, "server-a stops responding mid-run"); b.tryStart(3, date); // 02:03 server-b skips: held by server-a until 02:05
// The lease runs out, and the next server to ask takes over with a higher token. Long tokenB = b.tryStart(5, date); // 02:05 server-b holds the lease (token 2) b.finish(5, date, tokenB); // 02:05 server-b sends invoices for 2026-10-26 c.tryStart(6, date); // 02:06 server-c skips: already done for 2026-10-26
// server-a was only paused. It wakes up still believing it's the leader. a.finish(7, date, tokenA); // 02:07 server-a is rejected: token 1 is stale (current 2) System.out.println("invoice runs: " + db.runs); // invoice runs: 1 }}import threadingfrom dataclasses import dataclass
# One row in a shared database table. owner and expires_at say who may run the job and until# when. token goes up every time a different server takes over, so writes can be fenced.@dataclass(frozen=True)class Lease: owner: str expires_at: int token: int
# The shared database. Each method stands for one atomic SQL statement; the lock plays the# part of the database's row lock, so two servers can't both win.class LeaseStore: def __init__(self): self._lock = threading.Lock() self._lease = Lease("nobody", 0, 0) self._done_dates: set[str] = set() self.runs = 0
# UPDATE job_leases SET owner = :me, expires_at = :now + :ttl, token = ... # WHERE job = 'nightly-invoices' AND (owner = :me OR expires_at <= :now) # Succeeds if we already hold it (a renewal) or the holder's time is up. def try_acquire(self, me: str, now: int, ttl: int) -> int | None: with self._lock: mine = self._lease.owner == me if not mine and self._lease.expires_at > now: return None token = self._lease.token if mine else self._lease.token + 1 self._lease = Lease(me, now + ttl, token) return token
def current(self) -> Lease: with self._lock: return self._lease
def is_done(self, date: str) -> bool: with self._lock: return date in self._done_dates
# The fenced write: it carries the token its writer was granted, and the store refuses # a token older than the current lease's. A server that lost its lease while it was # paused can't write, however sure it is that it's still the leader. def record_run(self, date: str, token: int) -> bool: with self._lock: if token < self._lease.token or date in self._done_dates: return False self._done_dates.add(date) self.runs += 1 return True
def clock(minute: int) -> str: return f"02:{minute:02d}"
def log(minute: int, text: str) -> None: print(f"{clock(minute)} {text}")
# One app server. In production each is a separate process, so each has its own copy of# every "singleton", including the scheduler. The lease is what makes the job run once.class Server: TTL = 5 # minutes; the leader renews well before this while it runs
def __init__(self, name: str, store: LeaseStore): self.name = name self._store = store
# Called by this server's scheduler every minute from 02:00. def try_start(self, now: int, date: str) -> int | None: if self._store.is_done(date): log(now, f"{self.name} skips: already done for {date}") return None token = self._store.try_acquire(self.name, now, Server.TTL) if token is None: held = self._store.current() log(now, f"{self.name} skips: held by {held.owner} until {clock(held.expires_at)}") return None log(now, f"{self.name} holds the lease (token {token})") return token
def finish(self, now: int, date: str, token: int) -> None: if self._store.record_run(date, token): log(now, f"{self.name} sends invoices for {date}") else: log(now, f"{self.name} is rejected: token {token} is stale (current {self._store.current().token})")
if __name__ == "__main__": date = "2026-10-26"
print("-- without a lease") # Each process's scheduler is "the only one" in its process, so all three fire. for name in ["server-a", "server-b", "server-c"]: log(0, f"{name} sends invoices for {date}") # 02:00 server-a sends invoices for 2026-10-26 # 02:00 server-b sends invoices for 2026-10-26 # 02:00 server-c sends invoices for 2026-10-26
print("-- with a lease") db = LeaseStore() a, b, c = Server("server-a", db), Server("server-b", db), Server("server-c", db)
token_a = a.try_start(0, date) # 02:00 server-a holds the lease (token 1) b.try_start(0, date) # 02:00 server-b skips: held by server-a until 02:05 c.try_start(0, date) # 02:00 server-c skips: held by server-a until 02:05
# A long GC pause, a frozen VM or a dead host: from outside they look the same. log(1, "server-a stops responding mid-run") b.try_start(3, date) # 02:03 server-b skips: held by server-a until 02:05
# The lease runs out, and the next server to ask takes over with a higher token. token_b = b.try_start(5, date) # 02:05 server-b holds the lease (token 2) assert token_b is not None b.finish(5, date, token_b) # 02:05 server-b sends invoices for 2026-10-26 c.try_start(6, date) # 02:06 server-c skips: already done for 2026-10-26
# server-a was only paused. It wakes up still believing it's the leader. assert token_a is not None a.finish(7, date, token_a) # 02:07 server-a is rejected: token 1 is stale (current 2) print(f"invoice runs: {db.runs}") # invoice runs: 1using System;using System.Collections.Generic;
// One row in a shared database table. Owner and ExpiresAt say who may run the job and until// when. Token goes up every time a different server takes over, so writes can be fenced.public sealed record Lease(string Owner, int ExpiresAt, long Token);
// The shared database. Each method stands for one atomic SQL statement; the lock plays the// part of the database's row lock, so two servers can't both win.public sealed class LeaseStore{ private readonly object _gate = new(); private Lease _lease = new("nobody", 0, 0); private readonly HashSet<string> _doneDates = new(); public int Runs { get; private set; }
// UPDATE job_leases SET owner = :me, expires_at = :now + :ttl, token = ... // WHERE job = 'nightly-invoices' AND (owner = :me OR expires_at <= :now) // Succeeds if we already hold it (a renewal) or the holder's time is up. public long? TryAcquire(string me, int now, int ttl) { lock (_gate) { var mine = _lease.Owner == me; if (!mine && _lease.ExpiresAt > now) return null; var token = mine ? _lease.Token : _lease.Token + 1; _lease = new Lease(me, now + ttl, token); return token; } }
public Lease Current() { lock (_gate) return _lease; } public bool IsDone(string date) { lock (_gate) return _doneDates.Contains(date); }
// The fenced write: it carries the token its writer was granted, and the store refuses // a token older than the current lease's. A server that lost its lease while it was // paused can't write, however sure it is that it's still the leader. public bool RecordRun(string date, long token) { lock (_gate) { if (token < _lease.Token || _doneDates.Contains(date)) return false; _doneDates.Add(date); Runs++; return true; } }}
// One app server. In production each is a separate process, so each has its own copy of// every "singleton", including the scheduler. The lease is what makes the job run once.public sealed class Server(string name, LeaseStore store){ public const int Ttl = 5; // minutes; the leader renews well before this while it runs
public static string Clock(int minute) => $"02:{minute:D2}"; public static void Log(int minute, string text) => Console.WriteLine($"{Clock(minute)} {text}");
// Called by this server's scheduler every minute from 02:00. public long? TryStart(int now, string date) { if (store.IsDone(date)) { Log(now, $"{name} skips: already done for {date}"); return null; } var token = store.TryAcquire(name, now, Ttl); if (token is null) { var held = store.Current(); Log(now, $"{name} skips: held by {held.Owner} until {Clock(held.ExpiresAt)}"); return null; } Log(now, $"{name} holds the lease (token {token})"); return token; }
public void Finish(int now, string date, long token) { if (store.RecordRun(date, token)) Log(now, $"{name} sends invoices for {date}"); else Log(now, $"{name} is rejected: token {token} is stale (current {store.Current().Token})"); }}
public static class Program{ public static void Main() { var date = "2026-10-26";
Console.WriteLine("-- without a lease"); // Each process's scheduler is "the only one" in its process, so all three fire. foreach (var name in new[] { "server-a", "server-b", "server-c" }) Server.Log(0, $"{name} sends invoices for {date}"); // 02:00 server-a sends invoices for 2026-10-26 // 02:00 server-b sends invoices for 2026-10-26 // 02:00 server-c sends invoices for 2026-10-26
Console.WriteLine("-- with a lease"); var db = new LeaseStore(); var a = new Server("server-a", db); var b = new Server("server-b", db); var c = new Server("server-c", db);
var tokenA = a.TryStart(0, date); // 02:00 server-a holds the lease (token 1) b.TryStart(0, date); // 02:00 server-b skips: held by server-a until 02:05 c.TryStart(0, date); // 02:00 server-c skips: held by server-a until 02:05
// A long GC pause, a frozen VM or a dead host: from outside they look the same. Server.Log(1, "server-a stops responding mid-run"); b.TryStart(3, date); // 02:03 server-b skips: held by server-a until 02:05
// The lease runs out, and the next server to ask takes over with a higher token. var tokenB = b.TryStart(5, date); // 02:05 server-b holds the lease (token 2) b.Finish(5, date, tokenB!.Value); // 02:05 server-b sends invoices for 2026-10-26 c.TryStart(6, date); // 02:06 server-c skips: already done for 2026-10-26
// server-a was only paused. It wakes up still believing it's the leader. a.Finish(7, date, tokenA!.Value); // 02:07 server-a is rejected: token 1 is stale (current 2) Console.WriteLine($"invoice runs: {db.Runs}"); // invoice runs: 1 }}package main
import ( "fmt" "sync")
// Lease is one row in a shared database table. Owner and ExpiresAt say who may run the job// and until when. Token goes up every time a different server takes over, so writes can be// fenced.type Lease struct { Owner string ExpiresAt int Token int64}
// LeaseStore is the shared database. Each method stands for one atomic SQL statement; the// mutex plays the part of the database's row lock, so two servers can't both win.type LeaseStore struct { mu sync.Mutex lease Lease doneDates map[string]bool Runs int}
func NewLeaseStore() *LeaseStore { return &LeaseStore{lease: Lease{Owner: "nobody"}, doneDates: map[string]bool{}}}
// TryAcquire is//// UPDATE job_leases SET owner = :me, expires_at = :now + :ttl, token = ...// WHERE job = 'nightly-invoices' AND (owner = :me OR expires_at <= :now)//// It succeeds if we already hold it (a renewal) or the holder's time is up.func (s *LeaseStore) TryAcquire(me string, now, ttl int) (int64, bool) { s.mu.Lock() defer s.mu.Unlock() mine := s.lease.Owner == me if !mine && s.lease.ExpiresAt > now { return 0, false } token := s.lease.Token if !mine { token++ } s.lease = Lease{me, now + ttl, token} return token, true}
func (s *LeaseStore) Current() Lease { s.mu.Lock() defer s.mu.Unlock() return s.lease}
func (s *LeaseStore) IsDone(date string) bool { s.mu.Lock() defer s.mu.Unlock() return s.doneDates[date]}
// RecordRun is the fenced write: it carries the token its writer was granted, and the store// refuses a token older than the current lease's. A server that lost its lease while it was// paused can't write, however sure it is that it's still the leader.func (s *LeaseStore) RecordRun(date string, token int64) bool { s.mu.Lock() defer s.mu.Unlock() if token < s.lease.Token || s.doneDates[date] { return false } s.doneDates[date] = true s.Runs++ return true}
func clock(minute int) string { return fmt.Sprintf("02:%02d", minute) }func logAt(minute int, text string) { fmt.Println(clock(minute), text) }
// ttl is in minutes; the leader renews well before this while it runs.const ttl = 5
// Server is one app server. In production each is a separate process, so each has its own// copy of every "singleton", including the scheduler. The lease is what makes the job run// once.type Server struct { name string store *LeaseStore}
// TryStart is called by this server's scheduler every minute from 02:00.func (s *Server) TryStart(now int, date string) (int64, bool) { if s.store.IsDone(date) { logAt(now, s.name+" skips: already done for "+date) return 0, false } token, ok := s.store.TryAcquire(s.name, now, ttl) if !ok { held := s.store.Current() logAt(now, fmt.Sprintf("%s skips: held by %s until %s", s.name, held.Owner, clock(held.ExpiresAt))) return 0, false } logAt(now, fmt.Sprintf("%s holds the lease (token %d)", s.name, token)) return token, true}
func (s *Server) Finish(now int, date string, token int64) { if s.store.RecordRun(date, token) { logAt(now, s.name+" sends invoices for "+date) return } logAt(now, fmt.Sprintf("%s is rejected: token %d is stale (current %d)", s.name, token, s.store.Current().Token))}
func main() { date := "2026-10-26"
fmt.Println("-- without a lease") // Each process's scheduler is "the only one" in its process, so all three fire. for _, name := range []string{"server-a", "server-b", "server-c"} { logAt(0, name+" sends invoices for "+date) } // 02:00 server-a sends invoices for 2026-10-26 // 02:00 server-b sends invoices for 2026-10-26 // 02:00 server-c sends invoices for 2026-10-26
fmt.Println("-- with a lease") db := NewLeaseStore() a, b, c := &Server{"server-a", db}, &Server{"server-b", db}, &Server{"server-c", db}
tokenA, _ := a.TryStart(0, date) // 02:00 server-a holds the lease (token 1) b.TryStart(0, date) // 02:00 server-b skips: held by server-a until 02:05 c.TryStart(0, date) // 02:00 server-c skips: held by server-a until 02:05
// A long GC pause, a frozen VM or a dead host: from outside they look the same. logAt(1, "server-a stops responding mid-run") b.TryStart(3, date) // 02:03 server-b skips: held by server-a until 02:05
// The lease runs out, and the next server to ask takes over with a higher token. tokenB, _ := b.TryStart(5, date) // 02:05 server-b holds the lease (token 2) b.Finish(5, date, tokenB) // 02:05 server-b sends invoices for 2026-10-26 c.TryStart(6, date) // 02:06 server-c skips: already done for 2026-10-26
// server-a was only paused. It wakes up still believing it's the leader. a.Finish(7, date, tokenA) // 02:07 server-a is rejected: token 1 is stale (current 2) fmt.Println("invoice runs:", db.Runs) // invoice runs: 1}// One row in a shared database table. owner and expiresAt say who may run the job and until// when. token goes up every time a different server takes over, so writes can be fenced.interface Lease { readonly owner: string; readonly expiresAt: number; readonly token: number;}
// The shared database. Each method stands for one atomic SQL statement. JavaScript runs one// call at a time, which plays the part of the database's row lock here; across real// processes, only the database can give that guarantee.class LeaseStore { private lease: Lease = { owner: 'nobody', expiresAt: 0, token: 0 }; private readonly doneDates = new Set<string>(); runs = 0;
// UPDATE job_leases SET owner = :me, expires_at = :now + :ttl, token = ... // WHERE job = 'nightly-invoices' AND (owner = :me OR expires_at <= :now) // Succeeds if we already hold it (a renewal) or the holder's time is up. tryAcquire(me: string, now: number, ttl: number): number | undefined { const mine = this.lease.owner === me; if (!mine && this.lease.expiresAt > now) return undefined; const token = mine ? this.lease.token : this.lease.token + 1; this.lease = { owner: me, expiresAt: now + ttl, token }; return token; }
current(): Lease { return this.lease; }
isDone(date: string): boolean { return this.doneDates.has(date); }
// The fenced write: it carries the token its writer was granted, and the store refuses // a token older than the current lease's. A server that lost its lease while it was // paused can't write, however sure it is that it's still the leader. recordRun(date: string, token: number): boolean { if (token < this.lease.token || this.doneDates.has(date)) return false; this.doneDates.add(date); this.runs++; return true; }}
const clock = (minute: number) => `02:${String(minute).padStart(2, '0')}`;const log = (minute: number, text: string) => console.log(`${clock(minute)} ${text}`);
// One app server. In production each is a separate process, so each has its own copy of// every "singleton", including the scheduler. The lease is what makes the job run once.class Server { static readonly TTL = 5; // minutes; the leader renews well before this while it runs
constructor( readonly name: string, private readonly store: LeaseStore, ) {}
// Called by this server's scheduler every minute from 02:00. tryStart(now: number, date: string): number | undefined { if (this.store.isDone(date)) { log(now, `${this.name} skips: already done for ${date}`); return undefined; } const token = this.store.tryAcquire(this.name, now, Server.TTL); if (token === undefined) { const held = this.store.current(); log(now, `${this.name} skips: held by ${held.owner} until ${clock(held.expiresAt)}`); return undefined; } log(now, `${this.name} holds the lease (token ${token})`); return token; }
finish(now: number, date: string, token: number): void { if (this.store.recordRun(date, token)) { log(now, `${this.name} sends invoices for ${date}`); } else { log(now, `${this.name} is rejected: token ${token} is stale (current ${this.store.current().token})`); } }}
const date = '2026-10-26';
console.log('-- without a lease');// Each process's scheduler is "the only one" in its process, so all three fire.for (const name of ['server-a', 'server-b', 'server-c']) log(0, `${name} sends invoices for ${date}`);// 02:00 server-a sends invoices for 2026-10-26// 02:00 server-b sends invoices for 2026-10-26// 02:00 server-c sends invoices for 2026-10-26
console.log('-- with a lease');const db = new LeaseStore();const [a, b, c] = ['server-a', 'server-b', 'server-c'].map((n) => new Server(n, db));
const tokenA = a.tryStart(0, date)!; // 02:00 server-a holds the lease (token 1)b.tryStart(0, date); // 02:00 server-b skips: held by server-a until 02:05c.tryStart(0, date); // 02:00 server-c skips: held by server-a until 02:05
// A long GC pause, a frozen VM or a dead host: from outside they look the same.log(1, 'server-a stops responding mid-run');b.tryStart(3, date); // 02:03 server-b skips: held by server-a until 02:05
// The lease runs out, and the next server to ask takes over with a higher token.const tokenB = b.tryStart(5, date)!; // 02:05 server-b holds the lease (token 2)b.finish(5, date, tokenB); // 02:05 server-b sends invoices for 2026-10-26c.tryStart(6, date); // 02:06 server-c skips: already done for 2026-10-26
// server-a was only paused. It wakes up still believing it's the leader.a.finish(7, date, tokenA); // 02:07 server-a is rejected: token 1 is stale (current 2)console.log(`invoice runs: ${db.runs}`); // invoice runs: 1
export {};What to notice
Section titled “What to notice”- Global access, not “one instance”, was the problem. The refactor keeps one gateway in production. It moves the decision to the composition root, and makes the dependency visible in the constructor. That’s the dependency inversion principle applied to a shared object.
- Tests stop sharing state. Each test builds its own fake, so nothing needs resetting and
the suite passes in any order and in parallel. No
setInstanceForTesting, no reflection to clear a private static field. - The lease turns “one per process” into “one at a time”. The conditional
UPDATEis atomic in the database, so two servers can’t both get one row. Use the database’snow(), not each server’s clock. Then clock drift between servers doesn’t change who holds it. - Expiry is the failover, and the TTL is a tradeoff. If the leader dies, nobody runs the job until its lease expires. A short TTL means faster failover but more renewals, and more false takeovers when the leader is merely slow. The leader renews well before expiry (every minute here, for a 5 minute lease).
- A lease can’t stop a paused leader. A fencing token can. After a long GC pause, the old leader
doesn’t know its lease expired. The token goes up every time ownership changes. The write that
matters checks it, so the late write from token 1 is refused. Without a token, make the job
idempotent. For example, put a unique key on
(job, date), so a second run’s insert fails. - Larger systems use the same idea with other tools: Postgres advisory locks, a Kubernetes
Leaseobject for controller leader election, or etcd and ZooKeeper. Libraries such as ShedLock (Java) wrap the database-row version for scheduled jobs.
Our nightly billing job is a singleton. We scaled to three servers and customers got three invoices. Fix it, and tell me what happens when the server running it dies.
Whether you see that a singleton is one per process. Whether you move coordination to a shared
store (a lease row with expiry, an advisory lock, or leader election). And whether you handle
the leader dying (expiry) and the leader pausing (fencing token or idempotent writes). The red
flag is a synchronized block, or a bigger singleton. So is a lock with no expiry. It stops the
job forever when its holder dies.
A singleton is one per process, so three servers run three jobs. I’ll keep a lease row in the database. A server takes it with a conditional update, and renews it while running. Another server takes over when it expires. Each takeover bumps a token, and the job’s write checks it. So a leader that wakes from a pause can’t double-bill.
Tradeoffs
Section titled “Tradeoffs”Use a classic singleton when three things hold. The object is truly process-wide. It has no meaningful alternative in tests. And it’s needed from places that can’t take parameters. Examples are a logger, a process-wide registry, or a handle to one piece of hardware. For anything with side effects that tests would want to replace, prefer one instance created at startup and passed in. Examples are payment clients, database pools, email senders and clocks. For “once across servers”, neither works. Use a lease.
In each language
Section titled “In each language”| Language | Usual form | Lazy and thread-safe | Built in |
|---|---|---|---|
| Java | private static final field, or enum with one INSTANCE |
Holder idiom, or double-checked locking with volatile |
Runtime.getRuntime(), Desktop.getDesktop() |
| Python | A module-level instance, imported where needed | Module import (runs once), or a threading.Lock with double check |
None, True, False; modules themselves |
| C# | static readonly field or { get; } = new() with a private constructor |
Lazy<T> (default mode is thread-safe) |
services.AddSingleton<T>(), Comparer<T>.Default |
| Go | Package-level variable behind an unexported type | sync.Once, sync.OnceValue (Go 1.21) |
http.DefaultClient, slog.Default() |
| TypeScript | export const instance = new Thing() from a module |
Module evaluation runs once; cache a promise for async creation | ES modules themselves |
Go’s http.DefaultClient shows both sides at once. It’s a convenient process-wide default. But it
has no timeout, and any package can change it. So production code usually creates its own client,
and passes it in.
Commonly confused with
Section titled “Commonly confused with”| Pattern | Looks like Singleton because | The difference |
|---|---|---|
| Static class | Both give global access without new |
A static class has no instance at all: it can’t implement an interface, be passed as a parameter, or be replaced in a test. A singleton is an object, so it can do all three. |
| Factory | getInstance() is a creation method, and a caching factory returns the same object each time |
A factory caches one instance per key (one client per database URL) and is itself an ordinary object. A singleton is one instance of one class, enforced by the class. |
| DI singleton scope | Both mean “exactly one instance” | The container creates one and injects it; the class has a public constructor and doesn’t know. This is the Hard level’s fix, and what “singleton” means in most modern codebases. |
| Monostate | Every instance behaves the same | Monostate allows many instances that all share static fields (Python’s Borg idiom is one). Callers use new and can’t tell, which makes the shared state even better hidden. |
| Service locator | Both hand out shared objects from a global place | A locator is a global registry of many services. It has the same hidden-dependency problem as getInstance(), multiplied by every service it holds. |
Where it shows up
Section titled “Where it shows up”On this site:
- Logging framework: the logger registry is one of the places a process-wide instance is right, because logging calls come from everywhere.
- Rate limiter: one limiter per process works for one server. More servers need the counts in a shared store. It’s the same jump as the Hard level.
- Factory: the Hard level’s store factory caches one client per URL and is passed in, the usual replacement for a singleton per resource.
In real systems, you’ll find singletons in:
- Loggers.
LoggerFactory.getLoggerandlogging.getLoggerreturn the same logger for the same name. - Runtime handles (
Runtime.getRuntime()). - Process-wide metrics registries. Prometheus clients have a default registry.
- Handles to a single device, such as a printer spooler or a GPU context.
- Every DI container’s singleton scope.
Leader election for scheduled jobs shows up in Kubernetes controllers, Quartz clustering and ShedLock.
Interview questions
Section titled “Interview questions”Interviewer asksWhy is Singleton called an anti-pattern?
Because the classic form ties two things together. “Exactly one instance” is often right. “Reachable from anywhere through a static method” makes it a global variable. Dependencies hide in method bodies. Tests can’t swap it, and leak state into each other. And the class can’t be replaced. Keep the single instance, create it at startup, and pass it in. Then the criticism goes away.
Interviewer asksHow do you make a singleton thread-safe in Java?
Eager creation in a static final field is already thread-safe. For lazy creation, use the holder
idiom (a nested class whose static field holds the instance), or an enum. Double-checked locking
also works, but only with the field declared volatile. Without it, another thread can see the
reference before the constructor’s writes. Then make the instance’s own mutable state thread-safe
too. That’s a separate problem.
Interviewer asksHow do you test code that uses a singleton?
Best: change the code so the object is passed in through the constructor, and pass a fake in the
test. If you can’t change callers yet, extract an interface, and have getInstance() return it.
Add a reset hook used only by tests, called after every test. That’s a stepping stone, not a
solution. Tests still share global state, and can’t run in parallel.
Interviewer asksCan a singleton end up with two instances?
Yes, in several ways. Lazy creation without a lock. Reflection calling the private constructor.
Java deserialization (unless readResolve returns the instance, or it’s an enum). Cloning. Two
class loaders, each loading the class. And, most often, two processes. Each process and each class
loader has its own static fields. Across servers, “one” needs a lease or leader election in a
shared store.
Interviewer asksWhen is a singleton the right choice?
When the thing is truly one per process, has no useful alternative in tests, and is needed where parameters can’t reach. Examples are a logger, a process-wide registry, or a handle to one hardware device. Even then, prefer read-only or thread-safe state. And let tests replace what the singleton writes to (the log output), not the singleton itself.
| Decision | We chose | We gave up |
|---|---|---|
| How many configs | One instance, private constructor | Callers can’t build a custom one, even in tests |
| Reloading | Swap an immutable snapshot | A little memory for the new copy |
| When to create | Lazily, with the language’s once primitive | A slow first call that can fail mid-request |
| Hot path | No lock after creation (holder, Lazy<T>, sync.Once, volatile double check) |
Nothing; it’s the fix |
| Who enforces “one” | The composition root, passing one instance in | The type system’s guarantee; wiring to maintain |
| Once across servers | A lease row with expiry, renewed by the leader | Failover waits for expiry; a write per renewal |
| Paused leaders | A fencing token checked on write | A token to carry and check |
Further reading
Section titled “Further reading”- Factory, where a caching factory replaces a singleton per resource
- Dependency inversion, the principle behind passing the instance in
- Thread safety basics, for check-then-act races
- Martin Kleppmann, “How to do distributed locking” (2016), on leases, pauses and fencing tokens
- Joshua Bloch, Effective Java, Item 3: “Enforce the singleton property with a private constructor or an enum type”
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.