MD Reader Open in MD Reader

Grounded it in the group-challenges code. Here's the draft to send — layers named in both Flutter and design vocabulary, with real files as proof.


Proposal: adapter architecture (MVVM + one extra layer)

We keep hitting the same friction: the UI changes weekly, backend contracts change monthly, and the two get welded together — so a copy tweak or a motion change turns into an API-shaped refactor, and a contract change breaks visuals. Fix is to split into four layers with one clear owner each. It's MVVM with one extra layer inserted, and it's the extra layer that does the real work.

Layer 1 — Pure UI (owner: design, via designs.git)

Dumb widgets. Props in, pixels out. No API types, no fetching, no business rules. In challenges these are chat-bubble.tsx, feed-day-separator.tsx, reaction-emoji.tsx, reaction-picker.tsx, feed-empty.tsx, award-stat.tsx. Flutter equivalent: a StatelessWidget that takes a view-model struct. Design ships these; engineering copies them in near-verbatim and does not argue with spacing.

Layer 2 — UI-only logic (owner: design + Jaydeep, still designs.git)

Local state that exists purely because of how the screen feels: motion timelines, staggers, scroll anchoring, expand/collapse, which sheet is open. It never touches the server. In challenges this is use-tour-intro.ts — the intro beat schedule, per-row stagger (INTRO_ROW_STEP_S), veil timings, TOUR_RISE_EASE — plus the scroll-anchoring behaviour in group-feed.tsx. Flutter equivalent: AnimationControllers, State fields, ScrollController logic. Jaydeep drives this because it's a design decision expressed as timing values, not a product decision.

Layer 3 — The adapter (owner: web + Flutter engineers) — this is the new layer

The translation between what the backend says and what the UI needs to render. It owns: shaping wire data into what the screen actually asks for, optimistic state, client-only rules, derived flags, and pagination bookkeeping. Nothing visual, nothing network. In challenges that's use-group-feed.ts plus lib/feed-merge.ts, lib/feed-reactions.ts, lib/group-chat-unread.ts, utils/challenge-card-state.ts, utils/catch-up-digest.ts. Flutter equivalent: the ViewModel / ChangeNotifier per screen — but strictly per screen's needs, not a mirror of the repository.

Concrete things that layer earns its keep on, all from group chat:

Layer 4 — API → models (owner: engineers, already exists)

Generated client + mappers from wire DTO to domain type. In challenges: use-group-api-hooks.ts (mapFeedItem, mapGroupCurrent, mapFeedPage) over the generated ~/apis/challenges-api, with domain types in lib/types.ts. This is where contract ugliness gets absorbed once: the loosely-typed payload bag becomes a discriminated union of feed item types; the oneOf for joined-vs-crewless membership collapses into one GroupCurrent shape so no screen branches on response variants; unknown item types return null and are dropped instead of crashing. Flutter equivalent: repository + freezed models with fromJson.

Why the extra layer, and not plain MVVM

In plain MVVM the ViewModel drifts into being a second copy of the API response, and then the UI reads API-shaped data. The moment the contract moves, every screen moves. Splitting 3 from 4 means a contract change stops at layer 4, and a redesign stops at layers 1–2. Only a genuine product-behaviour change should touch layer 3.

What this means day to day

This has held up across the last two features — most of the churn landed in exactly one layer each time.


Want me to save this as a doc under docs/architecture/, or adjust tone (more Flutter-heavy for the dev, lighter for the designer)?