Why should the object graph not be built eagerly at launch, and how do lazy factories keep the composition root cheap?

15.8.4 · L5 · Interview

Predict first

What does each root build at launch, and what after opening the search feature twice?

nonisolated(unsafe) var built: [String] = []
final class Feature { let name: String; init(_ n: String) { name = n; built.append(n) } }
final class EagerRoot {
    let search = Feature("search"), profile = Feature("profile"), checkout = Feature("checkout")
}
final class LazyRoot {
    private let makeSearch = { Feature("search") }
    private let makeProfile = { Feature("profile") }
    private let makeCheckout = { Feature("checkout") }
    lazy var search = makeSearch()
    lazy var profile = makeProfile()
    lazy var checkout = makeCheckout()
}
_ = EagerRoot()
print("eager root built at launch:", built); built = []
let lazyRoot = LazyRoot()
print("lazy root built at launch:", built)
_ = lazyRoot.search; _ = lazyRoot.search
print("after opening search twice:", built)
  • eager: all three / lazy at launch: none / after opening search twice: just search, once
  • Both build everything at launch
  • The lazy root builds search twice

Mental model. A restaurant that cooks every dish on the menu before opening wastes time and food. A kitchen that preps the basics and cooks to order opens sooner, though the first order of an elaborate dish takes a bit longer.

A composition root that constructs every feature’s dependencies in its initialiser runs all that work before the first frame: databases opened, SDKs configured, view models allocated for screens the user may never visit. Launch time is a product metric and a review gate, and the work is also a source of ordering bugs. In the check an eager root builds ["search", "profile", "checkout"] at launch; a lazy root builds nothing at launch and creates search only on first use, exactly once even if it is asked twice (a lazy var backed by a factory closure). The pattern: the root stores factories (closures or small builder types), not instances; a feature is created when the user navigates to it; heavy infrastructure (database, image cache) hides behind a lazily created holder; and anything that must run at launch (crash reporter, flag fetch) is listed explicitly instead of happening as a side effect of construction.

Trade-offs and traps. Laziness moves cost to first use, so the first tap on a heavy feature can hitch; warm things up in the background after launch if that matters, using a prioritised startup task list rather than initialisers. Lazy singletons can hide cycles: A needs B lazily and B needs A lazily compiles fine and recurses at run time; keep the graph acyclic and break the cycle with a protocol or an event rather than a lazy reference. A lazy var is not thread safe in Swift, so a root accessed from several threads needs an actor, a lock, or main-actor isolation. Prefer explicit factories to a reflection-based service locator: an unresolved dependency should fail at compile time or in the first test, not when a user opens a screen. And measure: Instruments’ App Launch template and signposts around the root’s creation show how much the eager version really costs before you restructure.

A lazy var runs its factory once on first access.

Common traps

  • Hiding startup work in initialisers or static properties. It runs in an unpredictable order; list launch tasks explicitly.
  • Assuming lazy var is thread safe. It is not; guard access or confine it to one actor.

They might follow up with

  • What belongs in the launch path? Only what the first screen and crash reporting need.
  • How do I find launch cost? Instruments (App Launch), MetricKit launch histograms, and signposts around the root.

Related questions

  • 15.8.3 How do you scope dependencies by lifetime (app, session, screen), and what leaks when a longer-lived object…
  • 15.2.2 How do you do dependency injection in Swift, and when do you need a container or framework?
  • 15.3.1 What is a feature module, how do you draw module boundaries, and how does access control enforce them?

← How do you scope dependencies by lifetime (app, session, screen), and…