Singleton Pattern: When There Can Be Only One

java java21 scala scala2 scala3 kotlin design-patterns creational-patterns

Imagine you’re building a logging system for your application. You need exactly one logger instance—not two, not three, one. You want every part of your code to write to the same file, maintain consistent formatting, and never accidentally create duplicate loggers that step on each other’s toes.

That’s a singleton. It’s a class that ensures only one instance exists throughout your application’s lifetime, and provides a global point of access to that instance.

In this post, we’ll explore how to implement singletons correctly in Java, Kotlin, Scala 2, and Scala 3. We’ll see that some languages make it embarrassingly simple, while others require careful attention to thread safety and serialization.

The Problem: One Logger to Rule Them All

Let’s say you need a database connection manager that:

  • Must have exactly one instance (connections are expensive)
  • Is accessed from multiple threads simultaneously
  • Should be initialized lazily (only when first needed)
  • Survives serialization and deserialization correctly

This is precisely what singletons solve.

Key Concepts

Thread Safety

When multiple threads might access the singleton simultaneously, we need guarantees that only one instance is ever created:

Approach Thread-Safe Lazy Complexity
Eager static initialization Low
Double-checked locking High
Holder pattern Medium
Language keyword (Kotlin/Scala) Low

Initialization Strategies

  • Eager: Instance created when the class loads (simple, but wastes resources if never used)
  • Lazy: Instance created only when first requested (saves resources, but needs synchronization)
  • Language-level: Built into the language (like Kotlin’s object or Scala’s object)

The Solution: Singleton Implementations Across Languages

Java 21: Eager Initialization

The simplest and most recommended approach in Java. The instance is created when the class is loaded—the class loader guarantees thread safety, so no synchronization is needed.

public class DatabaseConnection {
    private static final DatabaseConnection instance =
        new DatabaseConnection();

    // Private constructor prevents external instantiation
    private DatabaseConnection() {
        System.out.println("DatabaseConnection instance created");
    }

    public static DatabaseConnection getInstance() {
        return instance;
    }

    public void connect() {
        System.out.println("Connected to database");
    }
}

View in repository

object DatabaseConnection {
    init {
        println("DatabaseConnection instance created")
    }

    fun connect() {
        println("Connected to database")
    }
}

// Usage: DatabaseConnection.connect()

View in repository

object DatabaseConnection {
  println("DatabaseConnection instance created")

  def connect(): Unit = println("Connected to database")
}

// Usage: DatabaseConnection.connect()

View in repository

object DatabaseConnection:
  println("DatabaseConnection instance created")

  def connect(): Unit = println("Connected to database")

// Usage: DatabaseConnection.connect()

View in repository

Key differences:

  • Java: Class loading guarantees thread safety. No synchronization needed, but instance is created immediately.
  • Kotlin: The object keyword handles everything. Lazy initialization and thread safety are automatic.
  • Scala 2/3: Like Kotlin, object is the idiomatic way. Scala handles all the synchronization and lazy initialization.

When Lazy Initialization Matters

Sometimes you don’t want to create the singleton immediately. Imagine a configuration loader that’s never used in your tests—why create it?

The holder pattern combines lazy initialization with the simplicity of eager initialization. The inner class is only loaded when you call getInstance().

public class HolderDatabaseConnection {
    private HolderDatabaseConnection() { }

    private static class ConnectionHolder {
        static final HolderDatabaseConnection instance =
            new HolderDatabaseConnection();
    }

    public static HolderDatabaseConnection getInstance() {
        return ConnectionHolder.instance;
    }
}

This is the modern Java best practice: thread-safe, lazy, and simple.

Kotlin/Scala: Automatic

Kotlin’s object and Scala’s object both provide lazy initialization by default. No special pattern needed.

// Kotlin - lazy by default
object LazyLogger {
    init { println("Logger initialized") }
    fun log(msg: String) = println(msg)
}

// Only creates instance when LazyLogger.log() is first called

Comparison: Language Implementations

Language Syntax Boilerplate Thread-Safe Lazy Comments
Java 21 static final + private constructor High ✓ (by default) Use holder pattern for lazy
Kotlin object keyword Minimal Idiomatic, automatic serialization
Scala 2 object keyword Minimal Idiomatic, similar to Kotlin
Scala 3 object keyword Minimal Same as Scala 2, cleaner syntax

The Pitfalls: How to Break (and Fix) a Singleton

Java Pitfall 1: Reflection Attacks

Even a private constructor can be bypassed with reflection:

// This BREAKS your singleton!
Constructor<DatabaseConnection> constructor =
    DatabaseConnection.class.getDeclaredConstructor();
constructor.setAccessible(true);
DatabaseConnection fake = constructor.newInstance(); // ❌ New instance!

Fix: Add a check in the constructor:

private DatabaseConnection() {
    if (instance != null) {
        throw new IllegalStateException("Singleton already instantiated");
    }
}

Java Pitfall 2: Serialization Creates Copies

Deserializing a singleton creates a new instance:

DatabaseConnection original = DatabaseConnection.getInstance();
byte[] serialized = serialize(original);
DatabaseConnection deserialized = deserialize(serialized);

// ❌ deserialized != original (new instance!)

Fix: Implement readResolve():

protected Object readResolve() {
    return getInstance();
}

Kotlin/Scala: No Pitfalls (Built-In Safety)

Because object is a language keyword, the compiler and runtime handle these issues. You simply can’t accidentally create two instances.


When NOT to Use Singletons

Singletons are often misused as “convenient global state.” Before reaching for a singleton, ask:

  1. Do I really need exactly one instance? (Or just want to avoid passing parameters?)
  2. Is this testable? (Singletons make mocking difficult)
  3. Can I use dependency injection instead? (Usually yes, and it’s better)

Common anti-pattern:

// ❌ DON'T DO THIS
public class BadConfig {
    public static final BadConfig instance = new BadConfig();
    public String apiKey; // Mutable global state!
}

// Someone changes it unexpectedly:
BadConfig.instance.apiKey = "wrong-key"; // 😱

Better approach:

// ✓ Use dependency injection
public class AppService {
    private final Config config;
    
    public AppService(Config config) { // Injected, testable
        this.config = config;
    }
}

Real-World Use Cases (Where Singletons Actually Make Sense)

Logger

Logger.getInstance().log("User logged in");

There should genuinely be one logger writing to one file.

Database Connection Pool

ConnectionPool pool = ConnectionPool.getInstance();
Connection conn = pool.getConnection();

A pool manages a fixed set of connections—exactly what a singleton should manage.

Configuration (Immutable)

AppConfig config = AppConfig.getInstance();
String apiKey = config.getApiKey();

If the configuration is immutable and read-only, a singleton is reasonable.


Comparison Table: Quick Reference

Question Java 21 Kotlin Scala
How do I write it? Static field + private constructor object keyword object keyword
How many lines of code? ~10–20 ~2–5 ~2–5
Is it thread-safe? Yes Yes Yes
Is it lazy-initialized? No (but use holder pattern) Yes Yes
Can I test it? Difficult Difficult Difficult
Can I serialize it? Yes (with readResolve()) Yes Yes

Testing Singletons: Proving It Works

To verify that our singleton implementations are truly creating only one instance across concurrent access, here are test examples for each language. These tests demonstrate thread safety using CountDownLatch to coordinate 100 concurrent threads:

@Test
@DisplayName("Should return the same instance")
void testSingletonInstances() {
    DatabaseConnection conn1 =
        DatabaseConnection.getInstance();
    DatabaseConnection conn2 =
        DatabaseConnection.getInstance();

    assertSame(conn1, conn2);
}

View full test file

@Test
@DisplayName("Should handle concurrent access")
fun testConcurrentAccess() {
    val numThreads = 100
    val executor =
        Executors.newFixedThreadPool(10)

    repeat(numThreads) {
        executor.submit {
            DatabaseConnection.connect()
        }
    }
}

View full test file

@Test
@DisplayName("Should handle concurrent access")
def testConcurrentAccess(): Unit = {
  val numThreads = 100
  val executor =
    Executors.newFixedThreadPool(10)

  for (_ <- 1 to numThreads) {
    executor.submit(...)
  }
}

View full test file

@Test
@DisplayName("Should handle concurrent access")
def testConcurrentAccess(): Unit =
  val numThreads = 100
  val executor =
    Executors.newFixedThreadPool(10)

  for _ <- 1 to numThreads do
    executor.submit(...)

View full test file

All tests verify:

  • Single instance: Multiple calls to getInstance() or direct access returns the same object (using assertSame)
  • Thread safety: 100 concurrent threads all access the singleton without creating duplicates
  • No exceptions: Connect/disconnect operations complete without errors under concurrent load
  • Lazy initialization: Tests run without blocking even with 100 threads

Code Samples

All examples in this post are available in the repository:

Implementation files:

Test files:


Key Takeaways

  1. In Java 21: Use the holder pattern for lazy initialization, or eager initialization if resources aren’t a concern. Remember readResolve() for serialization.

  2. In Kotlin/Scala: Use the object keyword. It’s idiomatic, thread-safe by default, and handles lazy initialization automatically.

  3. Singletons are often overused: Consider dependency injection first—it’s usually more testable and flexible.

  4. When a singleton makes sense: Loggers, connection pools, and immutable configuration objects are legitimate use cases.

  5. Thread safety and serialization are non-negotiable: If you implement a singleton, handle both correctly.


This is part of our Design Patterns in JVM Languages series. Check out the full design patterns guide for more patterns and interview preparation.