skip to content

What are the design risks of `lateinit`, and how would you decide whether to use it in a class API?

level: seniorimportance: should knowfreq 40%

answer

  1. Trades compile-time guarantee for runtime promise
  2. Risk: half-built object, temporal coupling, no thread safety
  3. Error appears at access site, far from missing assign
  4. Prefer constructor injection > lazy > lateinit
  5. Use only for externally-driven lifecycles (DI/Android/tests)

basics

~20 s

lateinit 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

for a junior

Recognizes lateinit can crash if used before setup and should be assigned first.

for a middle

Knows to prefer constructor injection or lazy when possible and reserves lateinit for framework lifecycles.

for a senior

Articulates temporal coupling, thread-safety, and invariant risks, and applies a clear preference ordering.

for a principal

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

context