How do you scope dependencies by lifetime (app, session, screen), and what leaks when a longer-lived object captures a shorter-lived one?

15.8.3 · L5 · Interview

Predict first

What stays alive after logout when an app-scoped object captured the session in a closure?

final class Session {
    let user: String
    init(user: String) { self.user = user; print("Session(\(user)) created") }
    deinit { print("Session(\(user)) deinit") }
}
final class Analytics {                       // app-scoped, lives for the whole process
    var onEvent: [() -> Void] = []
}
final class AppContainer {
    let analytics = Analytics()
    private(set) var session: Session?
    func login(_ user: String) { session = Session(user: user) }
    func logout() { session = nil }
}
let app = AppContainer()
app.login("u1")
app.logout()
print("after clean logout, session is", app.session == nil ? "gone" : "alive")

app.login("u2")
weak var weakSession = app.session
app.analytics.onEvent.append { [session = app.session!] in print("event for", session.user) }   // app scope captures session scope
app.logout()
print("after logout with a captured session, alive:", weakSession != nil)
app.analytics.onEvent.removeAll()
print("after removing the closure, alive:", weakSession != nil)
  • clean logout deallocates the session; with the capturing closure, the session is still alive until the closure is removed
  • The session always deinitialises at logout
  • The session never deinitialises

Mental model. A hotel gives each guest a room key (session) and the building’s master services (app). If the front desk keeps a copy of a guest’s key in the safe after checkout, the next guest still opens the old room.

Not every dependency lives as long as the app. App scope: logging, analytics, the HTTP client, feature flags. Session scope: everything tied to a signed-in user (their database, caches, repositories, an authenticated API client): created at login, destroyed at logout, so one user’s data cannot show up for the next. Screen or flow scope: a view model and its helpers, created when the screen appears. The composition root mirrors this with nested containers: an AppContainer creates a SessionContainer on login and drops its reference on logout, and the session’s objects deinitialise (the check prints “created” and “deinit” around a login/logout). Lifetime is therefore a first-class design decision: it determines what must be reset on logout (nothing, if the whole session graph is simply released), what may be cached across users, and what the memory footprint is.

The failure mode is a scope inversion: a longer-lived object holds a shorter-lived one. In the check the app-scope analytics object stores a closure that captured the session; after logout the session is still alive (weak reference non-nil), still holding user two’s data, until the closure is removed. The same shape appears with notification observers, singletons that cache a view model, a global router retaining a finished flow, and Combine subscriptions stored on an app-scoped object. Defences: dependencies point inward and downward (a session object may use app services; app services never hold session objects, they receive values when called); capture weak or pass identifiers rather than objects across a scope boundary; give session objects an explicit teardown() for resources that need it (cancel tasks, close database handles) rather than relying on deinit order; and write a test that logs out and asserts the session is gone using a weak reference, as the check does. Also reset non-object state: Keychain items, files in the user’s directory, and caches on disk need explicit logout cleanup (Q339 covers backups and what survives).

A longer-lived object holding a shorter-lived one keeps it alive.

Common traps

  • App-scoped objects holding session objects. Logout leaves the previous user’s data in memory; pass values in, do not store sessions.
  • Relying on deinit for cleanup of disk or Keychain data. Tear down explicitly on logout; deinit may not run if something still retains the object.

They might follow up with

  • How do I detect a leak? A weak reference in a test, plus the Memory Graph debugger in development.
  • What is screen scope in SwiftUI? A view model owned by @State (or created by the parent) for as long as the view’s identity lives.

Related questions

  • 15.8.4 Why should the object graph not be built eagerly at launch, and how do lazy factories keep the composition…
  • 15.8.1 When is a struct of closures a better dependency than a protocol, and what do you give up?
  • 15.2.2 How do you do dependency injection in Swift, and when do you need a container or framework?
  • 13.6.1 How do you protect data from crashes mid-write, corruption and backups: atomic writes, journaling, exclusion…

← How do you inject a protocol that has an associated type: primary…   Why should the object graph not be built eagerly at launch, and how… →