Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 014 / 100

Singleton, and why DI replaced it: a step-by-step guide

The most famous and most misused pattern.

Expona Admin8 min read

If you've been building software for more than a few years, you've almost certainly written or inherited code that relies on the Singleton pattern—that class with a static instance accessed from anywhere in your application. It's elegant in theory, but it creates a mess in practice. Over the last decade, dependency injection has quietly replaced Singleton as the go-to pattern for managing shared instances, and understanding why matters more than you might think.

The classic version — A static instance accessed globally

The Singleton pattern is deceptively simple. You create a class, hide its constructor, and expose a single static method that returns the same instance every time it's called. Here's what it looks like in TypeScript:

typescript
class DatabaseConnection {
  private static instance: DatabaseConnection;
  private static readonly lock = new Object();

  private constructor() {
    // Initialize connection
    console.log('Initializing database connection');
  }

  static getInstance(): DatabaseConnection {
    if (!DatabaseConnection.instance) {
      DatabaseConnection.instance = new DatabaseConnection();
    }
    return DatabaseConnection.instance;
  }

  execute(query: string): void {
    console.log(`Executing: ${query}`);
  }
}

// Usage across your entire codebase
const db = DatabaseConnection.getInstance();
db.execute('SELECT * FROM users');

The appeal is obvious. You define the resource once, control how it's instantiated, and access it from anywhere without passing it through function parameters. For developers coming from procedural backgrounds, it feels natural and convenient.

In languages like Java or C#, thread-safe implementations use locks or lazy initialization to ensure only one instance exists even under concurrent access. The pattern works exactly as intended: you get a single, globally accessible instance.

But here's where it starts falling apart.

The problems — Hidden dependencies, global state and painful testing

The convenience of Singleton comes at an enormous cost to code quality, testability, and maintainability. Let me walk you through the real issues I've encountered on production platforms.

Hidden dependencies

When a class calls DatabaseConnection.getInstance(), that dependency is invisible. Looking at the class signature tells you nothing about what it needs:

typescript
class UserService {
  createUser(name: string): void {
    // Where does db come from? You have to read the implementation.
    const db = DatabaseConnection.getInstance();
    db.execute(`INSERT INTO users (name) VALUES ('${name}')`);
  }
}

Compare that to explicit dependency injection:

typescript
class UserService {
  constructor(private db: DatabaseConnection) {}

  createUser(name: string): void {
    // Clear: I need a database connection.
    this.db.execute(`INSERT INTO users (name) VALUES ('${name}')`);
  }
}

With explicit injection, your constructor becomes a contract. New team members instantly see what the class depends on. Tools like IDE auto-completion and type checkers can help you wire everything correctly. With Singleton, you're flying blind.

Global state is a nightmare

Singletons are global state in disguise. Once you start relying on a static instance, you've hardcoded coupling across your entire application. If two different services access the same Singleton and one modifies it, the other sees that change. This creates subtle bugs that are hell to debug because the failure point might be far from where the mutation happened.

I've seen this destroy reliability on a ride-hailing platform. A payment service and a billing service both relied on a shared configuration Singleton. During a deployment, one service updated a timeout value for its own use case, but that broke the other service's rate-limiting logic. The bug only surfaced under load, and tracking it down took hours because the coupling was invisible.

Testing becomes a nightmare

Testing code that relies on Singletons is painful because you can't easily isolate the unit under test. Every time you instantiate a class that calls getInstance(), it either grabs the real Singleton (which might connect to a live database, hit an external API, or read disk state) or you have to manually reset the static instance between tests:

typescript
describe('UserService with Singleton', () => {
  afterEach(() => {
    // Hack: reset the static instance after each test.
    // This is brittle and easy to forget.
    (DatabaseConnection as any).instance = null;
  });

  it('should create a user', () => {
    const service = new UserService();
    service.createUser('Alice');
    // Pray the real database isn't hit.
  });
});

With injection, you pass in a mock or stub. Clean, fast, reliable.

Concurrency and initialization order issues

In multi-threaded environments, Singletons introduce synchronization headaches. Double-checked locking, thread-safe initialization, lazy vs. eager loading—all of this complexity is boilerplate that distracts from your actual business logic. And if initialization order matters (resource A depends on resource B), managing that across multiple Singletons becomes fragile.

The DI version — Register as a singleton lifetime and inject it

Dependency injection frameworks (like NestJS's built-in DI, or third-party containers in .NET and Java) let you achieve the Singleton's goal—one instance per application—without any of its downsides. You define the class normally, register it with a container, and the container handles the rest:

typescript
// Plain class, no static tricks
class DatabaseConnection {
  constructor() {
    console.log('Initializing database connection');
  }

  execute(query: string): void {
    console.log(`Executing: ${query}`);
  }
}

// NestJS module: register as singleton
@Module({
  providers: [
    {
      provide: DatabaseConnection,
      useValue: new DatabaseConnection(), // Or useFactory
    },
  ],
  exports: [DatabaseConnection],
})
export class DatabaseModule {}

// Service receives it explicitly
@Injectable()
export class UserService {
  constructor(private db: DatabaseConnection) {}

  createUser(name: string): void {
    this.db.execute(`INSERT INTO users (name) VALUES ('${name}')`);
  }
}

The container ensures only one instance exists. All services that request DatabaseConnection receive the same object. But now your class is pure—no static methods, no hidden state, no coupling to the framework.

Testing is straightforward

typescript
describe('UserService with DI', () => {
  it('should create a user', () => {
    // Easy: provide a mock
    const mockDb = {
      execute: jest.fn(),
    } as unknown as DatabaseConnection;

    const service = new UserService(mockDb);
    service.createUser('Alice');

    expect(mockDb.execute).toHaveBeenCalledWith(
      expect.stringContaining('INSERT INTO users')
    );
  });
});

No reset hooks, no static manipulation, no surprises. The test is declarative and isolated.

Configuration and flexibility

Need different implementations in different environments? Easy:

typescript
// Production: use real connection
@Module({
  providers: [
    {
      provide: DatabaseConnection,
      useFactory: () => new DatabaseConnection(),
    },
  ],
})
export class ProdDatabaseModule {}

// Testing: use mock
@Module({
  providers: [
    {
      provide: DatabaseConnection,
      useValue: new MockDatabaseConnection(),
    },
  ],
})
export class TestDatabaseModule {}

Your code doesn't change. The container switches implementations based on the environment. This is impossible with Singleton without conditionals or reflection hacks.

When it's still fine — Truly stateless, process-wide resources like a connection pool

I want to be fair: there are rare cases where Singleton (or the DI equivalent) genuinely makes sense and causes no harm. These are:

  • Completely stateless utility classes. A logger that writes events asynchronously and has no mutable state. A JSON serializer that doesn't maintain configuration. These can be Singletons without causing issues, though even then, DI is cleaner.
  • Connection pools and resource managers. A database connection pool or HTTP client factory genuinely needs to exist once per process. Multiple instances would waste memory and break connection reuse. Here, Singleton (or DI with singleton lifetime) is not just acceptable—it's necessary.
  • Configuration managers for truly global, immutable settings. If you read from environment variables or a config file once at startup and never change it, a Singleton configuration holder is low-risk. But even this is better off injected.

The key pattern: these resources have no mutable state that can be corrupted by concurrent access or unintended side effects. Even so, I reach for DI first and reserve static Singletons for edge cases where performance or integration constraints demand it.

On a SaaS platform I built, we did use a static Singleton for the AWS S3 client because the SDK already enforces single initialization and the benefit of connection pooling outweighed the downsides. But the rest of our codebase—services, repositories, config readers—all went through the DI container. That hybrid approach worked well.

Common mistakes

  • Over-using Singleton for convenience. If you think "I'll use Singleton because I don't want to pass this parameter around," stop. That's a code smell. Use DI instead.
  • Forgetting to reset Singletons in tests. Tests will leak state into each other, and failures will be unpredictable. If you're using Singleton and seeing flaky tests, this is usually why.
  • Mixing Singletons and DI. A codebase where some services are injected and others grab Singletons is confusing. Pick one pattern and stick with it.
  • Making Singletons with heavy initialization lazy when you should initialize eagerly. Lazy Singletons can hide initialization failures until production, and the first request pays the cost. Consider initializing at container startup instead.
  • Assuming thread-safety without actually ensuring it. The double-checked locking pattern is notoriously tricky. If you must use Singleton, test concurrent access thoroughly.

Key takeaways

  • The classic Singleton pattern hides dependencies, introduces global state, and makes testing difficult. For the cost of convenience, you sacrifice code clarity and reliability.
  • Dependency injection with singleton lifetime achieves the same goal—one shared instance—without any of those downsides. Your classes remain pure and testable.
  • Explicit constructor injection makes dependencies visible, enabling better IDE support, easier reasoning, and simpler testing with mocks.
  • Singleton is still acceptable for truly stateless utilities and process-wide resource pools (like connection pools), but even then, DI is cleaner if your framework supports it.
  • If you see Singletons in a codebase, flaky tests are usually following close behind. Refactor them to DI, and you'll solve the mystery quickly.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles