AI for the Boring 80%: Boilerplate, Mappers, Parsers
Cheap verification — not difficulty — is what makes a task a good fit for generation. The categories that qualify, the four failures that recur in exactly this kind of work (optimistic nullability, dropped fields, enum valueOf, dates), and why you write the assertions.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — The selection criterion for generated code isn't "is this hard?", it's "can I verify the
output in under a minute?" Mappers from real payloads, test fixtures, exhaustive when blocks,
migration SQL and mechanical multi-file refactors all pass. Concurrency, caching and anything with a
race don't — there, generation just moves work into review. Four failures recur in this exact domain:
optimistic nullability, silently dropped fields, enumValueOf with no Unknown fallback, and date
parsing that eats your offset. And always: you write the assertions, it writes the implementation.
The criterion is verification cost, not difficulty
People sort tasks by how hard they are. That's the wrong axis. Sort by how expensive it is to know whether the output is correct.
A 300-line mapper is tedious and its correctness is decidable in seconds — run it against a real payload and compare. A 30-line caching layer is intellectually smaller and its correctness depends on timing, eviction, concurrent access, and process death. Generate the first freely. Generate the second and you've converted writing into reviewing, which is the slower half of the work and the half nobody enjoys.
So the question before every generation: what's my check, and how long does it take? If you can't answer that in one sentence, you're about to create a review problem.
What passes
Mappers, from real payloads. The single best case. Not a description of the API — the actual JSON, three samples, ideally including one from a record you know is awkward.
Here are 3 real production payloads from GET /articles.
Generate the @Serializable DTO and a toDomain() mapper.
Rules:
- Any field not present AND non-null in all 3 → nullable in the DTO
- Keep the server's field names exactly; use @SerialName
- Enum-like strings → sealed/enum with an Unknown fallback
- Dates: parse as Instant, keep the offset, don't reformat
The rules block is the difference between a good result and an afternoon of debugging. Without it you get an optimistic, tidied-up model of the API that works on your three samples and fails on the fourth.
Test fixtures and builders. Mechanical, high volume, verified by compilation and by the tests using them.
Exhaustive when blocks. Add a case to a sealed interface and thirty when expressions need a
branch. The compiler verifies the result completely — this is generation with a free proof attached.
Room migration SQL. Give it schemas/12.json and schemas/13.json and ask for the Migration.
Then verify with runMigrationsAndValidate, which actually checks the resulting schema against the
expected one. Generated code with a real oracle.
Mechanical refactors across many files. Renaming a concept, swapping a logging API, converting
LiveData to StateFlow in 30 ViewModels. Each edit is trivial; the volume is the obstacle. Verified
by compilation plus an existing test suite.
Boilerplate with a template. Compose previews for a component's states, @Parcelize classes,
string extraction to strings.xml.
What doesn't
Not because a model can't produce it, but because you can't cheaply check it:
- Concurrency. Anything with a lock, a
Mutex, a shared cache, an ordering requirement. Correct and incorrect look identical, including under test. - Caching and invalidation policy. The bugs appear at boundaries: stale reads, races between refresh and read, eviction under memory pressure.
- Security-relevant code. Token storage, cert pinning, crypto. The failure is silent by definition.
- Anything encoding a business rule you haven't written down. If the rule only exists in someone's head, generation produces a plausible rule instead, and plausible rules are the worst kind.
The four failures specific to this work
1. Optimistic nullability. Your sample had "subtitle": "Hello", so subtitle: String. Production
has 40,000 rows where it's absent, and kotlinx.serialization throws MissingFieldException on a
screen that used to work.
State the rule explicitly, as above, and when in doubt prefer nullable — a null you handle beats a crash you don't.
2. Silently dropped fields. It'll decide internal_ref isn't interesting and omit it. Nothing fails;
you notice in three weeks when a feature needs it.
Ten-second check: count the keys in the payload, count the properties in the DTO. Any discrepancy should be a deliberate decision, not a discovery.
3. enumValueOf with no fallback.
// Generated. Crashes the day the backend adds "ARCHIVED".
val status = Status.valueOf(dto.status)
// What you need — always
val status = Status.entries.firstOrNull { it.name == dto.status } ?: Status.Unknown
This is the most reliable production bug in generated mapping code. Server teams add enum values without
telling anyone; that's normal and your client must tolerate it. Ask for the Unknown branch every time,
because it will never be there by default.
4. Dates. It will pick a format, be confident about it, and quietly drop the offset — "2026-11-12T08:00:00+03:30"
parsed as local time, and a user in Tehran sees yesterday's date on today's article. Insist on Instant
at the boundary, and keep the zone conversion in one place you control.
You write the assertions
The rule that keeps all of this honest:
Generate the implementation. Write the test yourself — or at minimum write the expected values yourself.
Ask for the mapper and its test in one prompt and you get code that agrees with itself. If the mapper
misreads created_at as local time, so will the test, and both will be green. The test isn't checking
the code; it's checking that the same assumption was applied twice.
In practice:
// You write this — from the payload, by reading it
@Test fun `maps a payload with a null subtitle and an unknown status`() {
val dto = json.decodeFromString<ArticleDto>(REAL_PAYLOAD_3)
val article = dto.toDomain()
assertNull(article.subtitle)
assertEquals(Status.Unknown, article.status)
assertEquals(Instant.parse("2026-11-12T04:30:00Z"), article.createdAt) // +03:30 → UTC
}
Three assertions, written by a human who looked at the data. That's the oracle, and it's five minutes.
What it actually buys
Roughly: this category is most of the diff volume and a small fraction of the thinking. A feature that took three days had maybe four hours of real design in it; the rest was mappers, fixtures, previews, and threading a field through five layers.
Two honest caveats before you scale it up. First, more generated code is still more code — it gets reviewed, maintained and migrated like anything else, so generating a mapper you didn't need is a negative. Second, the review load is real: a large mechanical diff is still a large diff, and the argument for capping PR size doesn't get an exemption because a tool wrote it.
The rule
Generate where the check is cheap, and own the check. The boring 80% isn't the unglamorous use of AI — it's the highest-leverage one, because it's the only category where you can be certain, in under a minute, that you got what you asked for. Doing it by hand was never a virtue.