Validate DTOs at the mapper boundary (Track C) - #28
Conversation
PR #24 gave every DTO parameter a default because Firestore's reflective object mapper needs a public no-arg constructor — without it every read threw and the Firebase path was a write-only app. §9.4 of TECH_LEAD_REVIEW called that fix "cheapest, but it makes every field silently optional". It was right, and this is the follow-through. With everything defaulted, a partial or malformed document no longer fails at deserialization. It produces a DTO full of "", 0 and emptyList(), and the mappers then did one of two unhelpful things: - threw `No enum constant com.example.diamonds.domain.model. VerificationStatus.` — technically loud, but naming neither the document, the field, nor the DTO, and swallowed into a generic Result.Error; or - silently substituted a default. An unparseable ReviewDirection became CLIENT_REVIEWS_PROVIDER, filing a cleaner's review of a customer against the customer's own profile. Unrecognised specializations were dropped by mapNotNull, so a provider listing five showed three with nothing to say so. An unreadable cleaningType became null, indistinguishable from "unspecified". All 13 `*Dto.toDomain()` mappers now validate. Two helpers do the work: - `enumField` / `enumFieldOrNull` parse or throw, naming the DTO, field, value received and the valid set. Enums are never defaulted on failure: an unrecognised value means the backend sent something this build does not understand, and guessing misrepresents the record. The nullable variant keeps absent-as-null while still rejecting a present-but- unparseable value — those two used to collapse together. - `requiredId` rejects a blank identity field. This is not a harmless partial read: repositories upsert by primary key, so every malformed record of a type collides on the same "" row and overwrites the last. Failures raise `MalformedDtoException`, added to :core beside OfflineException. Scope is deliberately the DTO boundary only. Entity->Domain mappers read values we wrote ourselves; a bad one there means a migration bug, which is a different risk class and a different change. Verified the new strictness against existing data before committing: the stub's seed DTOs and every DTO construction across the test sources parse cleanly under the new rules (checked mechanically against the real enum constants), and no DTO reaches a mapper with a blank id. One consequence is recorded rather than hidden: `map { it.toDomain() }` over a list fails the whole read if one row is malformed, so a single corrupt document empties a screen. That was already true of the previously unguarded valueOf fields; uniform validation makes it uniformly true. Choosing between failing the read, dropping bad rows with a surfaced count, or returning partial results is a per-repository judgement across 13 call sites, so it is a ROADMAP Track C item, not a mapper concern. Docs: this also reconciles six documents that still described #24's problem as open — AUDIT_REPORT, README, ROADMAP, ARCHITECTURE, IMPLEMENTATION_SUMMARY and CLAUDE.md all still said every Firestore read fails. TECH_LEAD_REVIEW §9.4 is marked resolved with the original analysis kept as the record. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6LvbheYzc1mN9toTuuPR5
There was a problem hiding this comment.
🟢 Ready to approve
The functional changes are well-scoped and backed by unit tests, with only minor non-blocking nits identified in review comments.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
This PR tightens Firestore DTO → Domain mapping at the :data mapper boundary by adding explicit validation for enum fields and primary identity fields, so malformed/partial backend documents fail loudly and diagnosably instead of silently defaulting into incorrect domain objects. It also updates multiple docs to reflect that Firestore reads are now functional (post-PR #24) and that strictness is restored via mapper validation.
Changes:
- Add shared mapper helpers to validate required ids and parse enums with clear
MalformedDtoExceptionfailures. - Add JVM unit tests that pin the new validation behavior for representative DTOs.
- Update roadmap/audit/architecture/docs to mark the “Firestore reads fail” blocker as resolved and document the new validation stance.
File summaries
| File | Description |
|---|---|
| TECH_LEAD_REVIEW.md | Marks §9.4 “Firestore reads fail” as resolved and documents the follow-up mapper validation. |
| ROADMAP.md | Updates Track C status (no-arg constructor fixed; mapper validation done; notes remaining degradation decision). |
| README.md | Updates Known Issues / warning text to reflect reads work and validation now exists. |
| IMPLEMENTATION_SUMMARY.md | Updates the implementation issues table to mark the read blocker as resolved and describe mapper validation. |
| data/src/test/java/com/example/diamonds/data/mapper/MappersTest.kt | Adds DTO-validation-focused unit tests for blank ids and unparseable enums. |
| data/src/main/java/com/example/diamonds/data/mapper/Mappers.kt | Introduces enumField/enumFieldOrNull/requiredId and applies them across DTO→Domain mappers. |
| core/src/main/java/com/example/diamonds/domain/model/Result.kt | Adds MalformedDtoException alongside existing domain-layer exceptions. |
| CLAUDE.md | Updates contributor guidance to reflect that reads work and mappers validate enums/ids. |
| AUDIT_REPORT.md | Updates audit summary/table to mark Firestore DTO deserialization as working and document mapper validation. |
| ARCHITECTURE.md | Updates backend swap notes and Track C status to reflect resolved DTO read blocker + validation behavior. |
Review details
- Files reviewed: 10/10 changed files
- Comments generated: 2
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Two nits, both correct. `enumField` called `enumValues<T>()` on the success path. That compiles to `getEnumConstants()`, which clones the constants array on every call — one allocation per enum field per row, so mapping a list of bookings paid it repeatedly for no reason. Now `enumValueOf<T>()` on the happy path, with `enumValues<T>()` reached only when building the failure message. The failure path is rare by construction, so the try/catch costs nothing that matters. The specializations test's comment described "listing three specialities, one unknown, rendered two" but the fixture only had two entries. Made the fixture match the scenario it describes, which also exercises an unknown value in the middle of the list rather than at the end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6LvbheYzc1mN9toTuuPR5
There was a problem hiding this comment.
🟢 Ready to approve
The mapper-boundary validation is consistently applied, covered by focused unit tests, and the documentation updates align with the implemented behavior.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
- Files reviewed: 10/10 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Copilot review on #29, and correct on both halves. `BackendServiceStub.createService` appended unconditionally and returned its input unchanged, while `FirebaseBackendService.createService` generates an id when one is blank and overwrites the document when an id is supplied. The blank-id half is the sharper one. Handing back a DTO whose id is still "" means the caller maps it with `ServiceDto.toDomain()`, which as of #28 rejects a blank id outright — so the debug backend would fail a create that the Firebase backend completes. A behavioural divergence between the two backends is exactly what the stub exists to avoid, and this one only became reachable when the mapper validation landed. The duplicate half matters too: re-creating with an existing id showed the same service twice in search, where Firebase would have overwritten it. Tests: id generation when blank, upsert-not-append for a supplied id, and updateService editing in place — each asserted through a subsequent read rather than the return value, so the store is what is under test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6LvbheYzc1mN9toTuuPR5
PR #24 gave every DTO parameter a default because Firestore's reflective object mapper needs a public no-arg constructor — without it every read threw and the Firebase path was a write-only app. TECH_LEAD_REVIEW §9.4 called that fix "cheapest, but it makes every field silently optional". It was right. This is the follow-through.
The problem the defaults created
With everything defaulted, a partial or malformed document no longer fails at deserialization — it produces a DTO full of
"",0andemptyList(). The mappers then did one of two unhelpful things:No enum constant com.example.diamonds.domain.model.VerificationStatus.— loud, but naming neither the document, the field, nor the DTO, and swallowed into a genericResult.ErrorbyfirestoreCall.ReviewDirectionbecameCLIENT_REVIEWS_PROVIDER, filing a cleaner's review of a customer against the customer's own profile. Unrecognised specializations were dropped bymapNotNull, so a provider listing five showed three and nothing said so. An unreadablecleaningTypebecamenull, indistinguishable from "unspecified".The fix
All 13
*Dto.toDomain()mappers now validate, via two helpers:enumField/enumFieldOrNull— parse or throw, naming the DTO, the field, the value received and the valid set. Enums are never defaulted on failure: an unrecognised value means the backend sent something this build doesn't understand, and guessing misrepresents the record. The nullable variant keeps absent-as-nullwhile still rejecting a present-but-unparseable value; those two used to collapse together.requiredId— rejects a blank identity field. Not a harmless partial read: repositories upsert by primary key, so every malformed record of a type collides on the same""row and overwrites the last one.Failures raise
MalformedDtoException, added to:corebesideOfflineException.Scope is deliberately the DTO boundary only.
Entity→Domainmappers read values we wrote ourselves; a bad one there means a migration bug — different risk class, different change.Verification
I can't compile here, so I checked the new strictness against existing data mechanically before committing: the stub's seed DTOs and every DTO construction across all test sources parse cleanly under the new rules (validated against the real enum constants), and no DTO reaches a mapper with a blank id. 8 new
MappersTestcases pin each behaviour.One consequence recorded, not hidden
map { it.toDomain() }over a list fails the whole read if one row is malformed — a single corrupt document empties a screen. That was already true of the previously-unguardedvalueOffields; uniform validation makes it uniformly true. Choosing between failing the read, dropping bad rows with a surfaced count, or returning partial results is a per-repository judgement across 13 call sites, so it's a ROADMAP Track C item rather than something to decide inside a mapper.Docs
This also reconciles six documents that still described #24's problem as open —
AUDIT_REPORT,README,ROADMAP,ARCHITECTURE,IMPLEMENTATION_SUMMARYandCLAUDE.mdall still stated that every Firestore read fails.TECH_LEAD_REVIEW§9.4 is marked resolved with the original analysis kept as the record.Verification
No Android SDK in this environment, so CI (
assembleDebug,allUnitTests,assembleDebugAndroidTest) is the compile signal — please treat a red run as blocking. Basedevelop@c692bc3.🤖 Generated with Claude Code
https://claude.ai/code/session_01G6LvbheYzc1mN9toTuuPR5
Generated by Claude Code