When hardening the Java interop boundary, when should defensive null handling fail fast (throw) versus tolerate null with a fallback? How do you decide, and what are the trade-offs?
answer
- Classify null: required vs optional vs defect
- Required/invariant -> fail fast (require/check/error)
- Optional with sane default -> fallback (?:, ?.let)
- Swallowing required nulls hides bugs
- Harden once at the edge; map IAE->400, ISE->500
basics
~20 sThrow early when a null means something is truly broken or violates a contract, so bugs surface immediately. Use a fallback when null is a legitimate, expected case you can handle meaningfully. Don't silently swallow nulls that hide defects.
solid answer
~50 sThe decision hinges on whether null is **expected and recoverable** or a **contract violation**. If the value is required for the operation to make sense, fail fast at the boundary: `requireNotNull` (bad input → IllegalArgumentException) or `checkNotNull`/`error()` (broken invariant → IllegalStateException). Failing fast converts a vague, far-away NPE into a precise, well-labeled error near the source, which is cheaper to debug and safer than corrupting state. Use a fallback (`?:`, `?.let`, default, `Optional`-style empty) only when absence is a real domain state with a sensible behavior — e.g. a missing optional config defaults to a known value. The anti-pattern is **swallowing**: defaulting a null that actually indicates a bug, which hides defects and produces wrong results silently. Senior judgment: harden at the edge once, fail fast on invariants, tolerate only documented optional cases, and never let a platform-type null leak deep into the system.
code
kotlin · 5 linesfun ingest(p: JavaPayment) {
val amount = requireNotNull(p.amount) { "amount required" } // fail fast, IAE
val currency = p.currency ?: "USD" // optional default
check(amount > 0) { "amount must be positive" } // invariant
}go deeper
Knows you can either throw or supply a default for a null.
Picks fail-fast vs fallback per field and avoids swallowing required nulls.
Classifies nulls by contract, hardens once at the edge, and maps exception types to caller-meaningful semantics.
Sets organization-wide boundary-validation and error-taxonomy policy, balancing resilience vs. debuggability and ensuring nulls never leak deep into the domain.
## The core question At the Java boundary every value may secretly be null (platform types). For each one you must classify the null: 1. **Required / invariant** — the program cannot proceed correctly. → **Fail fast.** 2. **Optional / expected absence** — null is a legitimate domain state with a meaningful behavior. → **Fallback.** 3. **Defect-indicating** — null 'shouldn't' happen but you're unsure. → **Fail fast** (loud) rather than swallow. ## Fail-fast tools and intent - `requireNotNull(x) { ... }` → `IllegalArgumentException` — bad **input** from the caller. - `checkNotNull(x) { ... }` / `error("...")` → `IllegalStateException` — broken **internal invariant**. - `x ?: throw IllegalStateException("...")` — Elvis that fails fast with context. ```kotlin fun process(req: JavaRequest) { val userId = requireNotNull(req.userId) { "userId is mandatory" } // contract val cfg = loadedConfig ?: error("config not initialized") // invariant val nickname = req.nickname ?: "anonymous" // optional, tolerated } ``` ## Why fail fast at the boundary - **Locality:** the exception names the real cause at the entry point, not a downstream NPE 12 frames later. - **Integrity:** you never persist or propagate a half-valid object built from an unexpected null. - **Observability:** `IllegalArgumentException` vs `IllegalStateException` lets monitoring distinguish client errors from server bugs (e.g. map to HTTP 400 vs 500). ## When a fallback is right - The field is genuinely optional in the domain (a middle name, an override that defaults). - There is a *correct* default that does not mask a bug. - Absence has defined behavior the caller expects. ## The swallowing anti-pattern ```kotlin val amount = req.amount ?: 0 // BAD if amount is required: silently charges 0 ``` Defaulting a required value hides the defect and yields wrong results. Prefer `requireNotNull(req.amount)`. ## Defense-in-depth principle Harden **once, at the edge**: convert platform types into explicit non-null Kotlin types (or genuine nullables for optional fields) at the ingestion point. Downstream code then operates on honest types and needs no further guards. This keeps null-handling concentrated and auditable instead of scattered `!!` and ad-hoc checks throughout. ## Trade-offs summary - Fail fast: maximum safety and debuggability, but can crash on data you might have tolerated — only acceptable for genuinely required values. - Fallback: resilient, but risks masking bugs if applied to values that should never be null. - Rule: **fail fast on contracts/invariants, fallback only on documented optionality, never swallow.**
- How does failing fast with the right exception help an HTTP layer?IllegalArgumentException signals bad client input (map to 400), while IllegalStateException signals a server-side broken invariant (map to 500). The distinction lets a global handler respond and log correctly.
- Why is `req.amount ?: 0` for a required field dangerous?It swallows a defect: a null that should have stopped processing instead silently becomes 0, producing wrong results with no error to debug.
It's a customs checkpoint: contraband (broken invariant) gets stopped immediately, declared optional goods (optional fields) pass with a stamp, but you never wave through a forged passport just to avoid paperwork.
saying these in an interview costs you the question
- Defaulting required values to hide nulls (silent corruption)
- Throwing generic NPE via !! instead of a labeled exception
- Scattering guards everywhere instead of hardening at the edge
- Treating every null as recoverable or every null as fatal
- Not distinguishing bad-input from broken-invariant exceptions