Available for work
Ankit Ranjan
Back to Deep Dives

The Singleton Pattern in Dart — One Instance, and the Price You Pay for It

Some things should exist exactly once: a logger, an app config, a database handle. The singleton guarantees that single shared instance — and Dart makes it a one-liner. But a global by any other name is still a global, and this episode is honest about what that costs you.

June 4, 2026 7 topics 7 quiz questions
Share:
1

The problem — some things should exist exactly once

Some objects are meant to be the only one of their kind. A logger writing to a single file. An app config loaded once at startup. A database handle holding one connection pool. An HTTP client reusing one set of sockets. Spin up a second of any of these and you get two log buffers fighting over a file, two copies of config drifting out of sync, two connection pools where you budgeted for one.

So the question is simple. How do you guarantee that everyone in the app shares the exact same instance?

The naive answer is "just be careful — only call the constructor once". But an app has hundreds of call sites, and nothing stops the next one from typing Logger() again.

class Logger {
  final List<String> _buffer = [];
  void log(String msg) => _buffer.add(msg);
}

final a = Logger();
final b = Logger();        // a second, separate logger

a.log('saved');
print(b._buffer.length);   // 0 — b never saw it
Two objects, two buffers, and a bug that only shows up when one part of the app logs and another reads. We need the type itself to enforce the rule, not the discipline of whoever calls it.

Three calls, three objects — or three calls, one objectPlain constructorLogger()Logger()Logger()#a1b2#c3d4#e5f6Three separate objects on the heap.Three log buffers, three config copies.identical(a, b) == falseSingletonLogger()Logger()Logger()_instance#a1b2Every call hands back the same object.One buffer, one config, shared.identical(a, b) == true

That's the singleton's whole job: make the right-hand side true. Every Logger() in the codebase resolves to one object. The pattern is famous, it's everywhere, and — fair warning — it's also one of the most argued-over patterns there is. We'll build it first, then be honest about why.

2

The Dart idiom — private constructor, static instance, factory gate

Most languages reach for a static getInstance() method. Dart has something cleaner. Because Dart lets a constructor be a factory, you can intercept Logger() itself and hand back a stored object. The call site looks like an ordinary constructor; behind it, there's a gate.

Three pieces make it work.
A private constructorLogger._(). The underscore makes it library-private, so no outside code can ever call new and mint a fresh object.
A static final instance — built exactly once and held on the class.
A factory constructor — the public Logger(), which returns that stored instance instead of building anything.

class Logger {
  // 1. private constructor — only this library can call it
  Logger._();

  // 2. the one and only instance, built once
  static final Logger _instance = Logger._();

  // 3. the public gate — always returns the same object
  factory Logger() => _instance;

  final List<String> _buffer = [];
  void log(String msg) => _buffer.add(msg);
}
Three parts lock the count to one1. Private constructorLogger._();the underscore hides it — no outside code can call new2. Static final instancestatic final Logger _instance = Logger._();built once, lazily, on first access3. Factory constructorfactory Logger() => _instance;Logger() returns the field, never a new objectLogger()_instanceThe factory is the gate. Every call walks back to the same instance.

Now the call site can't break the rule even if it tries.

final a = Logger();
final b = Logger();

a.log('saved');
print(b._buffer.length);     // 1 — same buffer
print(identical(a, b));      // true — same object
a and b are not two loggers that happen to agree. They are one logger with two names. That's the guarantee the type now enforces, and it took three short lines to get it.

You'll also see the same shape written with a static getter — static Logger get instance => _instance, called as Logger.instance. It's the same idea; the factory version just keeps the familiar Logger() call site.

3

Lazy vs eager — when does the instance actually get built?

Here's a detail Dart hands you for free. That static final field is not built when the program starts. It's built the first time something reads it — and never again. Dart static fields are lazily initialised.

Why care? Because the instance might be expensive. A database handle opens a connection. A config loader reads a file. If you eagerly built every singleton at startup, your app would pay for things it might never use. Lazy means you pay only on first touch.

class Database {
  Database._() {
    print('opening connection...');   // the expensive bit
  }
  static final Database _instance = Database._();
  factory Database() => _instance;
}

void main() {
  print('app started');
  final db = Database();    // 'opening connection...' prints HERE
  Database();               // nothing — already built
}
// app started
// opening connection...
Notice the order. 'app started' prints first. The connection only opens when Database() is first called, not when main begins. The second call builds nothing — it walks straight to the stored instance.

Want eager instead? If you need the instance ready before anything asks for it — say, to fail fast on a bad config at launch — just touch it on purpose during startup.

void main() {
  Database();         // force the lazy field now
  runApp(MyApp());
}
Either way the construction runs once. Lazy is the default and usually what you want; eager is a deliberate nudge for the rare case where you'd rather crash at launch than mid-request.

4

The service locator — the grown-up global

Hand-writing the private-constructor dance for every shared object gets old. And it bakes a hard decision into each class: the class itself decides it's a singleton. Sometimes you'd rather decide that from outside — register one shared instance in one place, and let the rest of the app look it up.

That's a service locator. In Flutter the popular one is the get_it package. You register your services once at startup, then resolve them anywhere by type.

final getIt = GetIt.instance;

void setupLocator() {
  // one shared instance, created lazily on first lookup
  getIt.registerLazySingleton<Api>(() => HttpApi());
  getIt.registerLazySingleton<Logger>(() => FileLogger());
}

// anywhere in the app
final api = getIt<Api>();        // same Api every time
final logger = getIt<Logger>();  // same Logger every time
Look at what changed. The class no longer forces itself to be a singleton — HttpApi is an ordinary class with an ordinary constructor. The locator decides there's one shared instance, and it registers against the Api interface rather than the concrete type. That last part matters more than it looks, and we'll come back to it.

get_it gives you a few registration modes worth knowing.
registerLazySingleton — one instance, built on first lookup. The common choice.
registerSingleton — one instance, built eagerly at registration.
registerFactory — a fresh instance on every lookup. Not a singleton at all — the same container, opposite behaviour.

It's a tidy upgrade on a bare global variable: typed, central, and swappable in one place. But make no mistake about what it is. A service locator is still global access with better manners. Everything that reaches into getIt still depends on a global — it's just spelled more politely. Which brings us to the part nobody likes.

5

The dangers — global state, hidden dependencies, and tests

A singleton is a global variable wearing a class as a disguise. Everything you've ever heard about why globals are dangerous applies here, in full.

Global mutable state. One shared, writable object that any code can reach means any code can change it. A value set in one corner of the app surfaces as a surprise in another. With no single owner, you can't reason locally — you have to hold the whole app in your head to know what state the singleton is in right now.

Hidden dependencies. Look at this constructor and tell me what it needs to run.

class CheckoutController {
  CheckoutController();   // depends on... nothing?

  void submit(Cart cart) {
    Api().post('/checkout', cart);   // surprise: it needs Api
    Logger().log('checkout done');   // and Logger
  }
}
The signature lies. It claims no dependencies, then reaches out to two globals inside a method. Nothing in the type tells you this class can't work without a live Api. The dependencies are real, but invisible — you only find them by reading every line.

The testability problem. This is the one that bites hardest. You want to test submit without hitting the network. But Api() is hard-wired inside the method — there is no seam, no parameter, no way to slip a fake in. The test is forced to use the real thing.

Can the test hand it a fake?Reaches for the globalCartWidgetApi().checkout()hard-wired inside build()real Api (global)FakeApino way inTest hits the live network. No seam.Takes it as a parameterCartWidget(this.api)api passed inreal ApiFakeApiprod passes real, test passes fakeCartWidget(FakeApi())Test swaps in a fake. Fast and offline.

And there's a second sting in the tail: state leaks between tests. Because the singleton lives for the whole process, whatever test one writes into it is still there when test two runs. Your suite passes or fails depending on the order it ran in — the worst kind of flake. None of this means singletons are forbidden. It means you've taken on a real cost, and you should know you're paying it.

6

Singleton vs dependency injection — why Flutter passes things in

Every danger in the last topic has the same root: the dependency is fetched from a global instead of handed in. Flip that one decision and the problems dissolve. That flip has a name — dependency injection — and it's the idiom Flutter leans on.

Injection just means a class receives what it needs from outside, rather than reaching out to grab it. Compare the two shapes directly.

// Singleton: reaches out for the global
class Checkout {
  void submit() => Api().post('/checkout');   // grabs it
}

// Injection: takes it in
class Checkout {
  Checkout(this.api);
  final Api api;
  void submit() => api.post('/checkout');      // uses what it was given
}
The injected version has an honest signature. You cannot construct a Checkout without handing it an Api, so the dependency is visible, the test can pass a fake, and two checkouts can use two different APIs if you ever need that. Same behaviour, none of the hidden coupling.

Reach sideways into a global, or pass it down the treeGlobal accessApi()CartProfileFeedSettingsEvery widget knows the global by name.Hidden dependencies, hard to redirect.Coupled to one concrete ApiDependency injectionProvider(api)CartProfileFeedSettingsOne source at the top, flowing down.Swap the provider, swap it everywhere.Each widget takes what it needs

Flutter has machinery built for exactly this flow. The framework's InheritedWidget lets a value placed high in the tree be read by any descendant below it — and packages like Provider and Riverpod wrap that into a clean API. You put one instance near the top, and every widget under it reads the same object. You still get "one shared instance", which is what you wanted from the singleton — but it flows down the tree instead of sitting in a global, so a test can wrap the widget in a provider holding a fake.

// One shared Api, placed above the app
Provider<Api>(
  create: (_) => HttpApi(),
  child: MyApp(),
);

// any descendant reads the same instance
final api = context.read<Api>();
Same single instance, opposite plumbing. That's why "prefer injection over a singleton" is the standard Flutter advice — not because shared state is bad, but because how it reaches the code that uses it makes all the difference.

7

When a singleton is genuinely fine, and how to keep it testable

After all that, it would be easy to swear off singletons entirely. Don't. There are cases where one is the right call, and the goal isn't to ban the pattern — it's to use it without inheriting its worst traits.

A singleton earns its place when the thing it wraps is naturally one-of-a-kind and mostly stateless. A logger. A read-only config loaded once at boot. A wrapper over a resource the operating system already gives you exactly one of. The trouble was never "one instance" — it was global mutable state and hidden coupling. Remove those two, and a singleton is just a convenient shared object.

The trick is to program to an interface and inject the instance. Define the capability as an interface. Let the concrete class be a singleton if you like — but have your code depend on the interface, and receive it as a parameter.

// the contract code depends on
abstract interface class Logger {
  void log(String msg);
}

// the concrete singleton — fine to keep one instance
class FileLogger implements Logger {
  FileLogger._();
  static final FileLogger _instance = FileLogger._();
  factory FileLogger() => _instance;

  @override
  void log(String msg) => print('[file] \$msg');
}

// code depends on the INTERFACE and takes it in
class Checkout {
  Checkout(this.logger);
  final Logger logger;
  void submit() => logger.log('checkout done');
}
In production you wire the real one: Checkout(FileLogger()). The single shared file logger flows in. In a test you hand over a fake that records calls instead of writing a file.

class FakeLogger implements Logger {
  final messages = <String>[];
  @override
  void log(String msg) => messages.add(msg);
}

test('checkout logs once', () {
  final logger = FakeLogger();
  Checkout(logger).submit();
  expect(logger.messages, ['checkout done']);   // no file, no global
});
That's the whole reconciliation. Keep the single instance where it helps, but never let your code reach for it — depend on an interface and inject the instance. You get the shared object without the global, and the seam for tests stays open. The singleton stops being a trap and goes back to being what it should have been all along: just one object, shared on purpose.

Test your understanding

7 questions

Seven questions on the singleton pattern — the problem it solves, the Dart idiom, lazy initialisation, service locators, the dangers, and how injection keeps shared state testable.

Search

Loading search...