Skip to content

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.

Foundation22 min read

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 idea in one picture
OrderService InvoiceService ReceiptService getInstance() AppConfig the one instance private AppConfig() static getInstance() tax_rate = 0.08 parsed once, read by all new AppConfig() doesn't compile but "one" means one per process 3 servers, 3 "only" instances
Scroll or pinch to zoom, drag to pan
Every service asks the class for its one instance, and nobody can build another. But one means one per process: three servers have three.

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.
The general shape

getInstance()

getInstance()

holds the one instance

ClientA

ClientB

Singleton

-instance$ Singleton

-Singleton()

+getInstance() : Singleton

+operation()

getInstance()

getInstance()

holds the one instance

ClientA

ClientB

Singleton

-instance$ Singleton

-Singleton()

+getInstance() : Singleton

+operation()

Scroll or pinch to zoom, drag to pan
  • 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
Easy

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.

Each service builds the config object it needs:

AppConfig.javareceipt_service.pyAppConfig.csapp_config.goapp-config.ts
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
}
}

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.

Make the class responsible for there being one. Its constructor is private, it holds its own instance, and services ask it for that instance:

Easy: three services, one AppConfig

getInstance()

getInstance()

getInstance()

OrderService

+taxRate()

InvoiceService

+taxRate()

ReceiptService

+taxRate()

AppConfig

-INSTANCE$ AppConfig

-values: Map

-AppConfig()

+getInstance() : AppConfig

+get(key) : String

+reload()

getInstance()

getInstance()

getInstance()

OrderService

+taxRate()

InvoiceService

+taxRate()

ReceiptService

+taxRate()

AppConfig

-INSTANCE$ AppConfig

-values: Map

-AppConfig()

+getInstance() : AppConfig

+get(key) : String

+reload()

Scroll or pinch to zoom, drag to pan
  • 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.08

Times 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.

AppConfig.javaapp_config.pyAppConfig.csapp_config.goapp-config.ts
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
}
}
  • 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 class final or sealed closes 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. reload builds 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. volatile in Java and C#, and atomic.Pointer in 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.
Interviewer

Our services each load the config file and they disagree after a reload. Fix it.

What they are testing

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.

Say this out loud

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.

Medium

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.

Lazy creation looks like one line: if there’s no instance yet, make one.

MetricsRegistry.javametrics_registry.pyMetricsRegistry.csmetrics_registry.gometrics-registry.ts
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
}
}

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.

Run the four designs on the same cold start:

  1. thread 1·
  2. thread 2·
  3. thread 3·
  4. thread 4·
  5. thread 5·
  6. thread 6·
  7. thread 7·
  8. thread 8·
Registries built0
Lock acquisitions0
Exported registry counted–

Each thread asks for the registry, then counts one request in it. Building it takes 900 ms.

Medium: the registry builds itself once, on first use

getInstance()

first call loads it

RequestHandler

MetricsRegistry

-MetricsRegistry()

+getInstance() : MetricsRegistry

+countRequest()

«static nested»

Holder

INSTANCE$ MetricsRegistry

getInstance()

first call loads it

RequestHandler

MetricsRegistry

-MetricsRegistry()

+getInstance() : MetricsRegistry

+countRequest()

«static nested»

Holder

INSTANCE$ MetricsRegistry

Scroll or pinch to zoom, drag to pan
  • 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:

MetricsRegistry.javametrics_registry.pyProgram.csmetrics_registry.gometrics-registry.ts
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
}
}

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.

  • Prefer what the platform gives you. The holder idiom in Java, Lazy<T> in C#, sync.OnceValue in 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 (NoClassDefFoundError on every later call). C#’s Lazy<T>, in its default mode, caches the exception. Go’s sync.OnceValue panics 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.
Interviewer

Make this getInstance() thread-safe. Then make it fast.

What they are testing

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.

Hard

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.

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:

Main.javacheckout_service.pyProgram.cse.gocheckout-service.ts
// 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
}
}

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.

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:

Hard: the composition root creates one gateway and passes it in

creates one

passes it in

passes it in

«composition root»

Main

+main()

CheckoutService

-gateway: PaymentGateway

+checkout(cents)

RefundService

-gateway: PaymentGateway

+refund(chargeId)

«interface»

PaymentGateway

+charge(cents) : chargeId

+refund(chargeId)

HttpPaymentGateway

-apiKey

FakeGateway

+charges

creates one

passes it in

passes it in

«composition root»

Main

+main()

CheckoutService

-gateway: PaymentGateway

+checkout(cents)

RefundService

-gateway: PaymentGateway

+refund(chargeId)

«interface»

PaymentGateway

+charge(cents) : chargeId

+refund(chargeId)

HttpPaymentGateway

-apiKey

FakeGateway

+charges

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
Main.javafake_gateway.pyProgram.csfake_gateway.gofake-gateway.ts
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
}
}

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.

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_leases
SET owner = :me,
expires_at = now() + interval '5 minutes',
token = CASE WHEN owner = :me THEN token ELSE token + 1 END
WHERE 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:

Clock01:59job due at 02:00, takes 3 min
job_leases rowno lease: each server decides alone
Invoice runs0

server-a

waiting for 02:00

server-b

waiting for 02:00

server-c

waiting for 02:00

    The leader pauses mid-run, and its late write is refused
    job_leases + invoice_runsserver-bserver-ajob_leases + invoice_runsserver-bserver-along GC pause from 02:01wakes at 02:07, still thinks it leadsacquire lease (02:00)ok, token 1, expires 02:05acquire lease (02:00)no: held by server-aacquire lease (02:05)ok, token 2, expires 02:10record run for 2026-10-26, token 2acceptedrecord run for 2026-10-26, token 1refused: token 1 is older than 2job_leases + invoice_runsserver-bserver-ajob_leases + invoice_runsserver-bserver-along GC pause from 02:01wakes at 02:07, still thinks it leadsacquire lease (02:00)ok, token 1, expires 02:05acquire lease (02:00)no: held by server-aacquire lease (02:05)ok, token 2, expires 02:10record run for 2026-10-26, token 2acceptedrecord run for 2026-10-26, token 1refused: token 1 is older than 2
    Scroll or pinch to zoom, drag to pan
    • Color key
    • Background worker
    Server.javaserver.pyLeaseStore.cslease_store.goserver.ts
    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
    }
    }
    • 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 UPDATE is atomic in the database, so two servers can’t both get one row. Use the database’s now(), 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 Lease object for controller leader election, or etcd and ZooKeeper. Libraries such as ShedLock (Java) wrap the database-row version for scheduled jobs.
    Interviewer

    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.

    What they are testing

    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.

    Say this out loud

    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.

    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.

    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.

    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.

    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.getLogger and logging.getLogger return 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.

    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
    • 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”