The ticket says “checkout tests flake after 14:00 UTC” and “do not touch payment.” You find OrderProcessor asking AppContext.getInstance().clock(), .db(), and .currentUser(). Yesterday someone froze the clock on the singleton to test a Black Friday DiscountPolicy. Today a parallel test charged against yesterday’s date and wrote the other test’s user into the audit row. You also cannot substitute a PaymentGateway without mutating a global that the next test will inherit.

That is not Singleton. That is a global bag with a Gang of Four name. From the Design Patterns Roadmap: exactly one instance must exist — and you can name why.

This post stays on that “why.” The three families and the shared Order / PaymentGateway / DiscountPolicy lab live on the hub. Here we contrast getInstance() as a service locator with one honest process-wide meter, and we spend most of the page on when not to bother.

The bag every class can see

AppContext started as one database pool, then absorbed the clock for tests and the current user because the processor “just needed it.” Constructors got shorter; dependencies got harder to see:

public final class AppContext {

    private static final AppContext INSTANCE = new AppContext();

    private DataSource db;
    private Clock clock = Clock.systemUTC();
    private User currentUser;
    private PaymentGateway gateway;
    private DiscountPolicy discount;

    private AppContext() {}

    public static AppContext getInstance() {
        return INSTANCE;
    }

    public Clock clock() {
        return clock;
    }

    public void clock(Clock clock) {
        this.clock = clock;
    }

    // db, currentUser, gateway, discount — same pattern
}

OrderProcessor looks small. It is not. Every collaborator is an ambient lookup:

public class OrderProcessor {

    public void process(Order order) {
        AppContext ctx = AppContext.getInstance();
        if (ctx.clock().instant().isAfter(CHECKOUT_CUTOFF)) {
            throw new CheckoutClosedException(order.id());
        }
        BigDecimal payable = ctx.discount().payable(order);
        PaymentResult result = ctx.gateway().charge(order, payable);
        if (!result.approved()) {
            throw new PaymentDeclinedException(order.id(), result.failureReason());
        }
        ctx.db().markPaid(order.id(), result.reference(), ctx.currentUser());
    }
}

Now price a change that should have been local:

Freeze clock for a promo test     -> mutate AppContext, hope you unfreeze it
Run two tests in parallel         -> one clock, one currentUser, cross-talk
Swap PaymentGateway in a test     -> ctx.gateway(fake) races the next class
See what process() needs          -> read the method body, not the constructor

Four costs, and none of them are “we accidentally constructed two datasources.” You have not limited instances. You have hidden every dependency behind a static.

Note: Framework “singleton” scope is a different sentence. A Spring bean that is one instance per container, injected through a constructor, is ordinary Dependency Inversion. AppContext.getInstance() is a service locator. The word overlap is how this pattern got a worse reputation than it earned.

What an honest Singleton is

Two questions, both required:

QuestionIf you cannot answer
Why must there be one?You want convenient access, not a uniqueness constraint. Inject it.
What breaks if there are two?Nothing. Then two is allowed, and tests will thank you.

Singleton is a uniqueness constraint with a global access path. Drop the uniqueness and you wanted injection. Drop a reason that survives a design review and you wanted a static utility — or nothing.

Things that sometimes pass the test: a process-wide metrics meter that must add to one counter; a JVM-wide intern table; a hardware handle of which the process may have only one. Things that do not: Clock, DiscountPolicy, PaymentGateway, OrderRepository, the current user. Those vary per test, per request, or per tenant.

Java’s least surprising implementation for a true one-off is an enum. The language guarantees one INSTANCE, serialization does not sprout a second, and you skip the private-constructor plus getInstance ceremony:

public enum ProcessChargeMeter {
    INSTANCE;

    private final AtomicLong approved = new AtomicLong();
    private final AtomicLong declined = new AtomicLong();

    public void record(PaymentResult result) {
        if (result.approved()) {
            approved.incrementAndGet();
        } else {
            declined.incrementAndGet();
        }
    }

    public long approved() {
        return approved.get();
    }

    public long declined() {
        return declined.get();
    }
}

Why one? Because the question “how many charges has this process approved since boot?” is undefined if two meters exist. That is a real uniqueness constraint. It is also a reason to keep the type tiny. The moment ProcessChargeMeter grows a Clock or a DataSource, it is AppContext again.

The initialization-on-demand holder is the other honest form if you cannot use an enum (the type must extend something else). It is still one instance, still a reason you can name — not a bag:

public final class ProcessChargeMeter {

    private ProcessChargeMeter() {}

    private static class Holder {
        static final ProcessChargeMeter INSTANCE = new ProcessChargeMeter();
    }

    public static ProcessChargeMeter instance() {
        return Holder.INSTANCE;
    }
}

Double-checked locking with a volatile field is a 2000s interview. Do not write it in checkout code. Enum or holder. Stop.

Prefer a constructor you can read

OrderProcessor should name Clock, PaymentGateway, DiscountPolicy, and the repository the same way the Strategy post already named the gateway — as constructor arguments. Request-scoped data such as the current user is a method argument, not a singleton field:

public class OrderProcessor {

    private final PaymentGateway gateway;
    private final DiscountPolicy discount;
    private final OrderRepository orders;
    private final Clock clock;

    public OrderProcessor(
            PaymentGateway gateway,
            DiscountPolicy discount,
            OrderRepository orders,
            Clock clock) {
        this.gateway = gateway;
        this.discount = discount;
        this.orders = orders;
        this.clock = clock;
    }

    public void process(Order order, User cashier) {
        if (clock.instant().isAfter(CHECKOUT_CUTOFF)) {
            throw new CheckoutClosedException(order.id());
        }
        BigDecimal payable = discount.payable(order);
        PaymentResult result = gateway.charge(order, payable);
        if (!result.approved()) {
            throw new PaymentDeclinedException(order.id(), result.failureReason());
        }
        orders.markPaid(order.id(), result.reference(), cashier);
        ProcessChargeMeter.INSTANCE.record(result);
    }
}

ProcessChargeMeter is the one lookup that remains, and it is optional. If you later need two meters in a test (reset isolation, or a fake that records calls), pass ChargeMeter as an interface through the constructor and keep the enum as the production implementation. Uniqueness at the edge, substitution in tests — same rule as PaymentGateway.

A unit test now freezes time without mutating a process global:

@Test
void closedCheckoutDoesNotCharge() {
    Clock frozen = Clock.fixed(CHECKOUT_CUTOFF.plusSeconds(1), ZoneOffset.UTC);
    FakePaymentGateway gateway = FakePaymentGateway.recording();
    OrderProcessor processor =
            new OrderProcessor(gateway, new NoDiscount(), new RecordingRepository(), frozen);

    assertThrows(CheckoutClosedException.class, () -> processor.process(order(), cashier()));
    assertTrue(gateway.charges().isEmpty());
}

That test was a race against AppContext. It is now data. If a collaborator varies in tests, it was never a Singleton.

Wiring at the edge is not the pattern

Production main (or a Spring @Bean method) still constructs one OrderProcessor, one gateway, one clock — one instance happened, which is not the GoF pattern. The pattern is enforcing one instance plus Something.getInstance() from arbitrary code:

public static void main(String[] args) {
    PaymentGateway gateway = new StripeGateway(new StripeClient(env("STRIPE_KEY")));
    DiscountPolicy discount = DiscountPolicies.from(env("PROMO"), false);
    OrderRepository orders = new JdbcOrderRepository(dataSource());
    OrderProcessor processor =
            new OrderProcessor(gateway, discount, orders, Clock.systemUTC());
    // hand processor to the HTTP layer — do not store it on AppContext
}

Spring’s default scope will give you one OrderProcessor per container if you ask for a bean. Callers still receive it through a constructor. Tests still replace it with @MockBean or a parameter. You get “one in production” without a global.

Note: A static DiscountPolicies.from mapper is a function, not a Singleton. It holds no instance state. Do not “upgrade” it to DiscountPolicies.getInstance().from(...) for symmetry with AppContext.

When Singleton is the wrong move

This is the default. Skip getInstance when:

  • The type is a Clock, config, feature flag, or environment. Tests freeze, override, and parallelize these. Inject them.
  • The type is a collaborator in the lab. PaymentGateway, DiscountPolicy, OrderRepository, OrderProcessor — one each in production is a wiring choice. Two in a test is a requirement.
  • The type is request- or tenant-scoped. Current user, cart, locale, per-customer DiscountPolicy. A JVM-wide user is a security bug, not a pattern.
  • You want shorter constructors. That is a code-smell argument for a parameter object, not for a global. ChargeRequest from the Builder post is the way to shorten a call; AppContext is the way to hide it.
  • You have one implementation today. An enum PaymentGateway.INSTANCE that always talks to Stripe is ceremony plus an untestable processor. Wait for a second reason to change — then use an interface, not a singleton.
  • The real need is lazy construction. Lazy can be a local Supplier, a holder class, or a framework @Lazy bean. Lazy plus global plus mutable is the flake factory from the opener.

The healthy trigger is a uniqueness constraint you can say out loud in a review. “Two meters would double-count process stats.” “Two handles would contend for the same device.” If the sentence is “I don’t want to pass this through six constructors,” that is a wiring problem. Fix the graph; do not punch a hole in it.

Singleton also is not a cache. A cache can have one instance, but its job is eviction and hits, and tests need a fresh one. Inject OrderCache. If you truly need process-wide memoization, still expose it as a type you can replace; do not force OrderCache.getInstance().get(id) inside DiscountPolicy.

Cheat sheet

Honest     enum INSTANCE / holder    one instance, tiny type, uniqueness you can name
Ambient    AppContext.getInstance    hidden Clock, db, user, gateway — a locator
Wiring     main / @Bean              one OrderProcessor in prod; still injected
Lab        gateway, discount, clock  constructor args; never static lookups

Trigger to apply: two instances would be incorrect, not merely inconvenient
Trigger to stop:  tests need a substitute, or a request needs its own value
Scoreboard:       process() dependencies are visible in the constructor
Not Spring scope: container singleton + injection ≠ GoF Singleton

Do:

  • Use an enum (or a holder) for a tiny, process-true resource, and keep it out of business policy when you can inject it instead.
  • Pass Clock, PaymentGateway, DiscountPolicy, and repositories into OrderProcessor.
  • Pass User (and other request data) into process, not into a static field.
  • Treat “I can replace it in a test without mutating a global” as the acceptance test.

Don’t:

  • Put a database, a clock, and the current user on AppContext.getInstance().
  • Write double-checked locking for checkout.
  • Make PaymentGateway or DiscountPolicy a Singleton because production only constructs one.
  • Freeze a global clock in one test and assume the next test starts clean.

Wrap-up

Singleton is a uniqueness constraint, not a convenience API. AppContext.getInstance() made OrderProcessor look simple and made time, identity, and payment global — which is why the afternoon tests lied. An enum meter that must be process-wide can still be a Singleton; Clock and PaymentGateway cannot. Name why there must be one, or inject the collaborator and let main construct it once.

If the next pain is capture / refund / void sharing a sequence but differing in the middle, that is Template Method.

Next optional step in the series Freeze validate → call provider → persist; vary only the verb in the middle. Template Method: Freeze the Skeleton, Vary the Steps