What are the design risks of `lateinit`, and how would you decide whether to use it in a class API?
answer
- Trades compile-time guarantee for runtime promise
- Risk: half-built object, temporal coupling, no thread safety
- Error appears at access site, far from missing assign
- Prefer constructor injection > lazy > lateinit
- Use only for externally-driven lifecycles (DI/Android/tests)
basics
~20 slateinit lets objects exist in a half-built state, so calling methods in the wrong order crashes at runtime instead of compile time. Use it only when an external system controls timing (DI, lifecycle); otherwise prefer constructor injection or lazy so the value is guaranteed present.
solid answer
~50 s`lateinit` trades a compile-time non-null guarantee for a runtime promise, which introduces real risks: a temporal coupling where the object is valid only after a setup step runs; `UninitializedPropertyAccessException` surfacing far from the missing assignment; thread-safety gaps since assignment is unsynchronized; and weakened invariants because the constructor no longer fully establishes a valid object. Prefer **constructor injection** when the value is available at construction — it makes invalidity unrepresentable. Prefer **`by lazy`** when the object can compute the value on demand. Reserve `lateinit` for genuinely externally-driven lifecycles: DI field injection, Android views in `onCreate`, JUnit `@BeforeEach` fixtures, or builder patterns where the constructor can't receive the value. When you do use it, keep the window between construction and assignment small, document the ordering, and guard teardown with `::prop.isInitialized`. Frequent `isInitialized` checks are a sign the design should model state more explicitly (e.g., sealed states, nullable, or separate types).
go deeper
Recognizes lateinit can crash if used before setup and should be assigned first.
Knows to prefer constructor injection or lazy when possible and reserves lateinit for framework lifecycles.
Articulates temporal coupling, thread-safety, and invariant risks, and applies a clear preference ordering.
Frames lateinit as a deliberate weakening of type guarantees, drives API design toward unrepresentable-invalid-states, and uses state modeling to retire isInitialized checks.
## The core trade-off `lateinit` removes the compiler's initialization guarantee and replaces it with a runtime check. That's powerful but carries design costs. ## The risks - **Temporal coupling / half-built objects.** After `Foo()` the object is *not yet usable*; it becomes valid only after some setup runs. Callers must know the hidden ordering. A constructor that fully initializes makes invalid states unrepresentable; `lateinit` reopens that door. - **Errors surface far from the cause.** The `UninitializedPropertyAccessException` fires at the *access* site, which may be far (in code and time) from where assignment was forgotten. - **No thread safety.** Assignment to a `lateinit var` is unsynchronized; concurrent setup/read can race. - **Weakened invariants & testability.** Tests and refactors must reproduce the setup sequence; the type alone doesn't tell you it needs priming. ## When `lateinit` is the right call Use it when an **external party controls timing** and the value truly isn't available at construction: - DI **field injection** (Spring/Dagger setting the field reflectively after construction). - Framework **lifecycle** callbacks (Android `lateinit var binding` assigned in `onCreate`). - Test fixtures in **`@BeforeEach`**. - Builder/two-phase setup where the constructor can't take the value. ## Preferred alternatives ```kotlin // Best: constructor injection — always valid after construction class Service(private val repo: Repo) // Self-computable & immutable: lazy class Report { val rows by lazy { load() } } // Genuinely externally driven & unavoidable: lateinit class Fragment { lateinit var binding: Binding } // set in onCreate ``` Rule of thumb: **constructor injection > lazy > lateinit > nullable-with-checks**, choosing the leftmost that fits the constraint. ## Hygiene when you must use it - Minimize the construction-to-assignment window. - Document the required ordering on the property/class. - Guard cleanup paths with `::prop.isInitialized` so teardown never touches an unset field. - If you find many `isInitialized` checks, treat it as a smell — consider sealed/state classes or splitting the type into 'unconfigured' and 'ready' forms.
- How does constructor injection eliminate the lateinit risk?By requiring the dependency as a constructor parameter, the object can't be created without it, so there's no half-built state and no ordering to get wrong — invalid states become unrepresentable.
- What does heavy use of `::prop.isInitialized` signal?It signals the object legitimately has 'not-ready' and 'ready' states that aren't modeled. Consider sealed state classes, a nullable contract, or separate types instead of scattering checks.
Like shipping a gadget with the batteries left out: it looks complete, but it only works after someone inserts them — and the failure shows up only when you press the button.
saying these in an interview costs you the question
- Defaulting to lateinit for values available at construction
- Ignoring thread-safety of concurrent assignment
- Treating UninitializedPropertyAccessException as a normal flow signal
- No awareness that constructor injection is usually superior
- Scattering isInitialized checks instead of modeling state