📋 Section 20 · Question #27

How to implement Singleton pattern with Record

Technically, the Singleton Pattern can be implemented using a Java record by declaring a static constant instance: public static final AppConfig INSTANCE = new AppConfig(...). W...

Technically, the Singleton Pattern can be implemented using a Java record by declaring a static constant instance: public static final AppConfig INSTANCE = new AppConfig(...). While records provide immutability, thread-safe initialization, and conciseness for configuration sentinels, they are not designed to be service singletons, and enum remains the gold standard in Java.


🟢 Junior Level

Basic Implementation

public record AppConfig(String environment, int timeoutSeconds) {
    // Globally accessible singleton instance
    public static final AppConfig INSTANCE = new AppConfig("production", 30);

    // Optional static accessor method
    public static AppConfig getInstance() {
        return INSTANCE;
    }
}

// Client usage:
AppConfig config = AppConfig.INSTANCE;
System.out.println(config.environment()); // "production"

Why Records Are Attractive for Configuration Sentinels

  • Shallow Immutability: All record components are implicitly final, preventing unintended mutation across concurrent threads.
  • Thread-Safe Initialization: The static field is initialized during class loading, guaranteed thread-safe under the Java Memory Model (JMM, JLS §12.4.2).
  • Zero Boilerplate: The compiler automatically generates accessors, equals(), hashCode(), and toString().

However, records do not provide strict singleton enforcement out of the box.


🟡 Middle Level

Architectural Limitations of Record Singletons

1. Prohibition of Private Constructors in Public Records (JLS §8.10.4)

In a traditional class, you enforce a singleton by making the constructor private: private MySingleton(). In a public record, this is strictly forbidden by the language specification:

public record AppConfig(String url) {
    // ❌ Compilation Error:
    // Access modifier must not be less accessible than the record class
    private AppConfig { ... } 
}

Under JLS §8.10.4, the canonical constructor of a public record must be public. Consequently, any client code can invoke new AppConfig("other"), creating additional instances and breaking the singleton invariant.

2. Runtime Guard via Compact Constructor

To prevent clients from calling new AppConfig(...), you must add an initialization guard:

public record AppConfig(String url) {
    private static final AtomicBoolean INITIALIZED = new AtomicBoolean(false);
    public static final AppConfig INSTANCE = new AppConfig("https://api.prod.com");

    public AppConfig {
        if (INITIALIZED.getAndSet(true)) {
            throw new IllegalStateException("Singleton already initialized!");
        }
    }
}

3. Serialization and Deserialization Hazard

Unlike traditional classes where deserialization bypasses constructors using Unsafe, deserialization of a Record ALWAYS invokes its canonical constructor (JEP 395). Without a readResolve() method, deserializing a record either triggers the IllegalStateException guard or produces duplicate instances on the heap:

public record AppConfig(String url) implements Serializable {
    public static final AppConfig INSTANCE = new AppConfig("https://api.prod.com");

    // Mandatory to preserve singleton reference identity across serialization:
    private Object readResolve() {
        return INSTANCE;
    }
}

🔴 Senior Level

Comparative Matrix: Enum vs. Record vs. Classic Class Singleton

Evaluation Metric Enum Singleton (Bloch) Record Singleton Classic Class (Holder / DCL)
JVM Instance Uniqueness 🟢 Absolute (Enforced by JVM) ⚠️ Soft (Requires guard logic) 🟢 Strict (Via private constructor)
Reflection Attack Immunity 🟢 100% Immune (JVM throws error) 🔴 Vulnerable (Constructors accessible) 🔴 Vulnerable (Bypassed unless guarded)
Serialization Safety 🟢 100% Native (Serialized by name) ⚠️ Requires readResolve() ⚠️ Requires readResolve()
Lazy Loading Support 🔴 No (Eagerly loaded on class touch) 🟢 Yes (Via inner static holder) 🟢 Yes (Bill Pugh Holder idiom)
Architectural Purpose Stateful singletons and services Immutable data carriers / sentinels Complex stateful services

Lazy Initialization via the Bill Pugh Holder Idiom

If constructing the record requires heavy I/O or database initialization, lazy loading can be achieved via a static nested holder:

public record HeavyweightConfig(String databaseUrl, int poolSize) {
    // Nested class is loaded only when getInstance() is first called:
    private static class Holder {
        private static final HeavyweightConfig INSTANCE = 
            new HeavyweightConfig("jdbc:postgresql://localhost:5432/db", 50);
    }

    public static HeavyweightConfig getInstance() {
        return Holder.INSTANCE;
    }
}

Class initialization locks in the JVM (JLS §12.4.2) guarantee thread safety without synchronization overhead.

Architectural Verdict: Data Carriers vs. Services

Software design distinguishes between two primary object roles:

  1. Services / Agents (Behavior & Identity): Manage lifecycles, connections, sockets, and threads.
  2. Data Carriers / Value Objects (State without Behavior): Model domain facts, are shallowly immutable, and are compared by value.

Java records are designed strictly as transparent carriers of immutable data. Using a record as an active service singleton violates the Single Responsibility Principle (SRP). For stateful services, use enum or delegate lifecycle management to a dependency injection container (Spring, Micronaut, Quarkus). Use Record singletons exclusively for immutable domain sentinels (e.g., Money.ZERO_USD, AppConfig.DEFAULT).


4 Tricky Questions

1. Why does JLS §8.10.4 forbid declaring a private canonical constructor in a public record?

Answer: Because of the core design philosophy of records: transparency. A record’s state is transparently defined by its header component list. Its primary contract is that any code with permission to see the record type must be able to construct instances from component data and deconstruct instances back into component data. If a public record could hide its canonical constructor, it would cease to be a transparent data carrier, breaking pattern matching and serialization guarantees. A private constructor is only permitted if the entire record class is declared private inside an enclosing class.

2. How does the JVM handle reflective instantiation against an Enum singleton versus a Record singleton?

Answer:

  • For Enum: The JVM provides built-in immunity. Invoking Constructor.newInstance() on an enum throws an immediate IllegalArgumentException: Cannot reflectively create enum objects. This check is hardcoded into java.lang.reflect.Constructor.
  • For Record: A record’s constructor is an ordinary Java constructor. Calling recordClass.getDeclaredConstructor(...).newInstance(...) will succeed in creating additional instances on the heap unless the record’s compact constructor contains explicit guard logic.

3. Is readResolve() strictly required if a Record singleton implements Serializable?

Answer: Yes, it is mandatory. Under JEP 395, Java Object Serialization deserializes records by reconstructing them through their canonical constructor. Because ObjectInputStream invokes the constructor, a brand-new instance is allocated on the heap during deserialization, breaking reference equality (INSTANCE != deserializedInstance). Declaring private Object readResolve() { return INSTANCE; } forces the deserialization framework to discard the newly allocated record and return the existing static INSTANCE.

4. Can a Record singleton contain mutable state?

Answer: Yes, because records only guarantee shallow immutability. While all component fields in a record are declared private final, if a component points to a mutable reference (such as an AtomicInteger, a ConcurrentHashMap, or a StringBuilder), the internal state of that referenced object can be mutated by concurrent threads. However, creating a mutable singleton on top of a record is considered a severe architectural anti-pattern.


🎯 Interview Cheat Sheet

  • Implementation Pattern: Declare public static final MyRecord INSTANCE = new MyRecord(...).
  • Public Record Limitation: The canonical constructor of a public record cannot be private (JLS §8.10.4). Callers can invoke new MyRecord(...) unless blocked by an initialization guard in the compact constructor.
  • Serialization Caveat: Record deserialization always invokes the canonical constructor. You must declare readResolve() to preserve singleton identity.
  • Reflection Vulnerability: Reflection can create duplicate record instances, whereas enum instances are natively protected by the JVM.
  • Best Alternative: Joshua Bloch’s enum Singleton (enum Service { INSTANCE; }) provides 100% thread safety, reflection protection, and serialization correctness out of the box.
  • Legitimate Record Use Case: Ideal for immutable sentinel values and default configurations (Config.DEFAULT, Point.ORIGIN), not stateful system services.