- ID
- f6fc4950-07e6-459b-a256-c18282ca9256
Mobile Transition Plan: Cookbooks V3 Nested → Flattened Fields
Context
[[https://hungryroot.atlassian.net/browse/BE-7924][BE-7924]] added flat replacements (=author_id=, =pairing_ids=, =product_ids=,
=section_ids=, =collection_ids=) for the nested =author= object and =sections=
array on the V3 cookbook endpoints, gated behind
=BoolDynamicConfigEnum.COOKBOOK_LIST_REMOVE_DEPRECATED_FIELDS=
(=app/constants/dynamic_config.py=). The flag currently defaults to =0=
(off) — nested fields are still live in production today.
Both endpoints are affected, since =CookbookDetailSerializer= subclasses
=CookbookListSerializer= and inherits its field-dropping logic:
- =GET /api/v3/cookbooks/= (list) — =CookbookListSerializer=,
=cookbooks/views.py:575-594=
- =GET /api/v3/cookbooks/<slug>/= (detail) — =CookbookDetailSerializer=,
=cookbooks/views.py:636-638=
What flipping the flag actually does
=CookbookListSerializer.to_representation= (=cookbooks/serializers.py:346-351=)
unconditionally pops =author= and =sections= from the response dict when the
flag is active — for *every* request, *every* client, *every* app version,
the instant it flips. There is no per-client staging, no expand param, no
version gate. This is a single global switch:
#+begin_src python
def to_representation(self, instance: Any) -> Any:
data = super().to_representation(instance)
if bool_config_is_active(BoolDynamicConfigEnum.COOKBOOK_LIST_REMOVE_DEPRECATED_FIELDS):
for name in ("author", "sections"):
data.pop(name, None)
return data
#+end_src
Rollback is equally global and instant: flip the config back to =0= and the
nested fields return, no deploy needed either direction.
*The safe ordering is always "clients finish migrating first, flag flips
second." Flipping it before every active client version has shipped
flat-field parsing is an instant breaking change for whoever hasn't.*
Current field availability (as of this doc)
| Field | List default | List via =?expand= | Detail default | Detail via =?expand= |
|------------------------+----------------------+-----------------------+-----------------+----------------------------|
| =author= (nested) | present | n/a | present | n/a |
| =sections= (nested) | present | n/a | present | n/a |
| =author_id= | *always present* | n/a | always present | n/a |
| =pairing_ids= | absent | =pairing_ids= | always present | n/a |
| =product_ids= | absent | =product_ids= | always present | n/a |
| =section_ids= | absent | =section_ids= | always present | n/a |
| =collection_ids= | absent | =collection_ids= | absent | =collection_ids= |
(=cookbooks/serializers.py:239-364=, =CookbookSerializer=/=CookbookListSerializer=/=CookbookDetailSerializer=)
=author_id= needs no client change at all — it's already unconditional on
both endpoints today. =pairing_ids=/=product_ids=/=section_ids= need no
change on detail either. The only new work for mobile is: (1) requesting
=?expand=pairing_ids,product_ids,section_ids,collection_ids= on the *list*
endpoint wherever those are read today via nested =sections=, and (2)
replacing any read of =author.<field>= (either endpoint) with an
=author_id=-based lookup.
What Mobile Needs to Change
- Any list-view code parsing =cookbook.sections[].items[].pairing_id=/
=product_id= → switch to requesting =?expand=pairing_ids,product_ids= and
reading the flat arrays directly.
- Any code reading section groupings from the nested =sections[].name=/
=position= structure on the list endpoint → request
=?expand=section_ids= if only IDs are needed; if display-level section
metadata (names, positions) is needed from the *list* endpoint, flag that
back to backend — =section_ids= alone won't cover it, and that's a real
gap worth raising before committing to a phase-2 flip.
- Any code reading =cookbook.author.name=/=.slug=/=.avatar= etc. (list or
detail) → resolve author display data via =author_id= instead — either a
lookup against the author detail route or from an author record the
client already has cached from elsewhere. *This is very likely the
riskiest piece for mobile*, since =author_id= alone carries no display
data.
- Any code reading =collections= — this endpoint doesn't (and never did)
expose a nested =collections= field; =collection_ids= is the only shape
that's existed. If mobile already reads collection membership from this
endpoint, confirm it's already using =collection_ids= via expand — this
doc's flag doesn't change that field's behavior either way.
- Detail-view code reading =pairing_ids=/=product_ids=/=section_ids=
already gets them unconditionally — only the =author= parsing above needs
a change there.
Rollout Phases
Sequence-gated, not date-gated — each phase's start depends on the previous
phase actually completing, not a calendar date.
Phase 0 — Inventory (mobile-owned)
Backend has no visibility into client code, so this step has to start on
the mobile side:
- Confirm every iOS/Android screen or feature that reads =author=,
=sections=, or nested section/author data from
=/api/v3/cookbooks/=list or detail=.
- Confirm the oldest app version still active in production traffic, and
whether *that* version depends on the nested shape (it almost certainly
does, since flat fields are recent).
Phase 1 — Mobile ships flat-field parsing
- iOS and Android each add =?expand=pairing_ids,product_ids,section_ids,collection_ids=
(whichever subset each screen actually needs) to list requests, and
switch parsing to the flat fields.
- Replace any =author.*= reads with an =author_id=-based resolution path.
- Ship as a normal release — no backend coordination required yet, since
the flag stays off through this phase. Nested fields keep flowing
alongside the new flat ones, so there's no forced cutover risk here.
- Remove the old nested-parsing code path once the new path is verified in
that release — no need to keep both indefinitely, since the server isn't
dropping anything yet.
Phase 2 — Confirm adoption before flipping
- Watch Datadog for `/api/v3/cookbooks/` and `/api/v3/cookbooks/<slug>/`
traffic grouped by =client_name=/=client_version= (already tagged on
every request via =get_client_name_and_version=,
=app/utils.py:1543-1574=, visible on =request_finished= logs).
- The flag is safe to flip only once production traffic shows every active
app version is running Phase-1-or-later parsing. In practice this means
waiting out the long tail of users who haven't updated — app-store
adoption, not a backend timeline.
- If adoption stalls, that's a product/mobile call (forced upgrade prompt,
extended soak, etc.) — not something backend can resolve unilaterally.
Phase 3 — Flip =COOKBOOK_LIST_REMOVE_DEPRECATED_FIELDS=
- Toggle the dynamic config to =1= — no deploy required.
- Effective immediately, for all clients: =author= and =sections= vanish
from both list and detail responses.
- Recommend flipping during a lower-traffic window first and watching
mobile's crash/error monitoring briefly before considering it settled.
- Rollback: flip back to =0=, equally instant, if anything unexpected shows
up.
Phase 4 — Cleanup (optional, separate ticket)
Once the flag has stayed on through a full soak period with no rollback,
consider removing the flag branch and the dead =author=/=sections=/
=get_sections= code from =CookbookListSerializer= entirely, closing out
BE-7924 for good. Not urgent — leaving the flag permanently on is a
perfectly fine steady state indefinitely; this phase is pure tech-debt
cleanup whenever someone has the time.
Open Questions (mobile-owned — backend can't answer these)
- Exact inventory of screens/features touching =author=/=sections= today
(Phase 0).
- Do iOS and Android need to ship Phase 1 in the same release, or can they
land independently? The flag is global, so whichever platform is slower
gates Phase 3 regardless of the other's readiness.
- Is there a forced-upgrade / minimum-supported-version mechanism, or does
this wait out the natural update tail?
- What crash/error dashboard should backend watch (or be looped into)
during the Phase 3 soak window?
- How is =author= display data (name, slug, avatar) actually used today —
does resolving it from =author_id= alone require a new lookup, or is it
already cached client-side from another call?
Verification (backend side, once the flag is flipped)
1. ~python manage.py test cookbooks.tests.test_api.CookbookV3ReadAPITestCase --keepdb~
— exercises the existing DC-gated coverage
(=test_cookbook_list_removes_author_and_sections_when_dynamic_config_enabled=
and siblings).
2. Manual: hit both endpoints with the config overridden on locally,
confirm =author=/=sections= are absent and =author_id=/=pairing_ids=/
=product_ids=/=section_ids= are present.