Home

Replacing singletons with the environment

An app I worked on had twelve singletons. AnalyticsManager.shared, NetworkClient.shared, UserDefaults.standard wrapped in SettingsManager.shared, and so on down the list.

Each one had been the obvious choice at the time, and together they made about half the codebase impossible to test without launching the whole app.

What is actually wrong with them

Not global state as an abstract sin. Three concrete problems:

Dependencies are invisible. A view’s signature says nothing about what it touches. You cannot tell from ProfileView(user: user) that it also calls analytics, the network client and the settings store — you find out when the test crashes.

You cannot substitute them. Testing a view that calls AnalyticsManager.shared.track() means either sending real analytics from a test, or reaching into the singleton to swap something out, which is shared mutable state between tests.

Initialisation order is implicit. Twelve singletons with lazy initialisation, some of which touch others, produces an ordering that works by accident and breaks when someone adds a thirteenth.

The replacement

An environment key per dependency, with a harmless default:

extension EnvironmentValues {
    @Entry var analytics: Analytics = .noOp
    @Entry var api: APIClient = .live
}

Injected once at the root:

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.analytics, LiveAnalytics())
                .environment(\.api, APIClient.live)
        }
    }
}

Read where needed:

struct ProfileView: View {
    @Environment(\.analytics) private var analytics

    var body: some View {
        ProfileContent()
            .onAppear { analytics.track(.profileViewed) }
    }
}

And in a test or a preview, substituted:

#Preview {
    ProfileView()
        .environment(\.analytics, .noOp)
        .environment(\.api, .stub(profile: .sample))
}

Tip

Make the default a no-op, never a fatalError. It is what previews get, what tests get, and what runs if somebody forgets the injection. A crashing default turns every new preview into a puzzle, and the whole point is to make the substituted case easy.

Protocol or struct of closures

Two ways to make a dependency substitutable. I have used both and now prefer the second.

A protocol is the familiar shape:

protocol Analytics {
    func track(_ event: Event)
}

It needs an existential (any Analytics) in the environment, and a full conforming type per stub.

A struct of closures is lighter:

struct Analytics {
    var track: (Event) -> Void

    static let noOp = Analytics(track: { _ in })
    static let live = Analytics(track: { LiveClient.send($0) })
}

// in a test
var recorded: [Event] = []
let spy = Analytics(track: { recorded.append($0) })

No protocol, no conforming type, no existential — and a one-line spy at the point of use. For dependencies with a handful of operations this is much less ceremony, and it is the shape I default to now.

The two I kept

I want to be honest that the answer is not “no singletons”.

UserDefaults.standard stayed. It is a system singleton, it is already substitutable via UserDefaults(suiteName:), and wrapping it added a layer without adding a capability.

The image cache stayed, because it is genuinely one shared cache by design — two instances would defeat the point — and it has no behaviour worth stubbing in a test.

The distinction that survived: a singleton is fine when there genuinely can only be one, and it is a problem when it is a global variable that happened to be convenient. Analytics, networking and settings are all things you want a second, fake version of. A cache is not.

What it cost

More typing at the root. Four .environment lines in the app entry point instead of nothing.

Previews need injection. Every preview of a view below the injection point needs the dependencies too. This is the real cost, and the no-op defaults mostly cover it.

A runtime crash instead of a compile error if an @Environment(SomeType.self) object is not injected. Key-path environment values with defaults avoid this, which is one more reason to prefer them.

What it bought

The thing I did not expect: a view’s dependencies became visible in the file. Three @Environment lines at the top of a view tell you exactly what it touches, which a .shared call buried two hundred lines down never did.

That turned out to be more valuable than the testability, because it made a whole class of design problem obvious. A view reading five dependencies is a view doing too much, and that was invisible while every dependency was a global.