Critique `lateinit var` for dependency injection: what are the design and safety trade-offs versus constructor injection?
answer
- Field injection moves guarantee to runtime
- Constructor injection: val, explicit, no half-built state, easy tests
- lateinit must be var → reassignable, hidden deps
- Justified: cycles, no-arg/proxy frameworks, Android lifecycle
- Many lateinit fields = design smell
basics
~10 slateinit for injection is convenient but moves the 'is it set?' guarantee from compile time to runtime. Constructor injection keeps dependencies non-null and verified at construction, which is safer and easier to test.
solid answer
~50 sField injection via `lateinit var` trades a compile-time guarantee for convenience. With constructor injection, dependencies are `val` parameters: the object is never in a half-built state, immutability is preserved, the required collaborators are explicit in the signature, and tests construct the object directly with fakes. `lateinit` injection allows an object to exist before its dependencies are wired, so reads risk `UninitializedPropertyAccessException`; the dependency set is hidden (not in the constructor), it must be a mutable `var` (so it can be reassigned, even accidentally), and tests must replicate the framework's assignment. The main reasons people still use it: breaking circular dependencies, frameworks/proxies that need a no-arg constructor, or platform constraints (Android components instantiated by the system). The principled guidance is: prefer constructor injection for first-class business collaborators; reserve `lateinit` for framework-owned lifecycles where you don't control construction.
go deeper
Can say constructor injection sets dependencies up front while lateinit sets them later via a framework.
Names key constructor-injection benefits (val, non-null, testable) and that lateinit risks UninitializedPropertyAccessException.
Lays out the full trade-off set — runtime vs compile-time, mutability, hidden deps, test coupling, temporal coupling — and when lateinit is justified.
Sets architectural policy: constructor injection as default, lateinit as a surgical escape hatch for framework-owned lifecycles, and flags lateinit-heavy classes as a design smell with refactoring guidance.
## Framing `lateinit var` is frequently used for **field injection**: a DI container assigns the dependency after constructing the object (`@Autowired lateinit var repo: Repo`). This works, but it's worth understanding what you give up versus **constructor injection**. ## Constructor injection (the baseline) ```kotlin class OrderService( private val repo: OrderRepository, // val, non-null, required private val mailer: Mailer, ) ``` Properties: - **No half-built state.** Once constructed, all dependencies are present. - **Immutability.** `val` dependencies can't be reassigned. - **Explicit contract.** The constructor signature lists exactly what the class needs. - **Trivial testing.** `OrderService(fakeRepo, fakeMailer)` — no framework, no reflection. - **Compile-time guarantee** that the collaborators are non-null. ## `lateinit var` field injection — the trade-offs ```kotlin class OrderService { @Autowired lateinit var repo: OrderRepository @Autowired lateinit var mailer: Mailer } ``` What you lose: - **Compile-time → runtime.** "Is it wired?" is now only checked when read; a misconfiguration surfaces as `UninitializedPropertyAccessException` at runtime instead of a construction failure. - **Mutability.** It must be `var`, so the dependency can be reassigned — by the container, a test, or a bug. - **Hidden dependencies.** Collaborators aren't in the constructor, so the class's needs are less discoverable; God-classes accrete fields quietly. - **Harder, framework-coupled tests.** You must reproduce the container's field assignment (reflection, `@InjectMocks`, or manual `service.repo = fake`). - **Temporal coupling.** Objects can briefly exist unwired; any code touching the field early breaks. ## Why `lateinit` injection still appears - **Circular dependencies** that constructor injection can't express directly (often a smell, but real). - **Frameworks/proxies** that require a no-arg constructor or instantiate via reflection. - **Platform-instantiated objects**: Android `Activity`/`Fragment`/`View`, where the system calls the constructor and you can only inject later. - **Test fixtures** assigned in `@BeforeEach` — a legitimate, idiomatic use of `lateinit` outside DI. ## Principled guidance 1. **Default to constructor injection** for business collaborators — maximal compile-time safety, immutability, and testability. 2. **Reserve `lateinit`** for cases where you genuinely don't control construction (platform/framework lifecycles) or for test setup fields. 3. If you must field-inject, **guard early reads** with `::prop.isInitialized` only where a legitimately-optional/late path exists — not as a substitute for correct wiring. 4. Treat a class accumulating many `lateinit` injected fields as a **design smell** (too many responsibilities, hidden coupling). ## The deeper point `lateinit` is a deliberate, *local* escape hatch from definite-assignment. As an architectural default it erodes the very null-safety and immutability guarantees Kotlin gives you; used surgically where the framework forces it, it's perfectly idiomatic.
- Give a case where constructor injection genuinely can't be used.Platform-instantiated objects: Android Activities/Fragments are constructed by the OS via a no-arg constructor, so dependencies must be field-injected (e.g., lateinit) after creation.
- How does constructor injection improve testability over lateinit field injection?You build the object directly with fakes — `Service(fakeRepo)` — no DI container, reflection, or replicating the framework's field assignment; the dependency contract is explicit in the signature.
- Is using `lateinit` in a JUnit `@BeforeEach` field a code smell?No — that's an idiomatic, intended use: the test framework assigns the fixture before each test, and the field stays clean non-null in the test body.
Constructor injection is signing for a package at delivery — you can't enter the building without it. lateinit injection is leaving the door unlocked and trusting the package arrives before you reach for it.
saying these in an interview costs you the question
- Claiming lateinit field injection is strictly better/equal to constructor injection
- Ignoring the loss of compile-time and immutability guarantees
- Not recognizing legitimate cases (cycles, Android, proxies)
- Suggesting try/catch around injected fields as normal practice
- Treating a class with many lateinit fields as fine