紡Book の勉強ノートに戻る
プロダクト設計2026.06.24

保存の形を変えても、画面は1行も触らずに済んだ — 「しまう形」と「返す形」を分けた話

DjangoデータモデルAPI設計紡Book

レビューで見つかった保存形のズレ(P1-b)を実際に直したときの設計判断を、勉強のためにまとめた制作メモです。綴Photo の実コードが題材。実装は AI(Claude/Codex)に手伝ってもらい、保存形をどう切るか・範囲をどこで止めるかの判断は自分が補う形で進めている。

綴Photo の「テンプレページ」は、保存しているのが写真IDの配列(photoIds)だけでした。どの綴り(写真に添えた言葉)を見せたいかが保存に残らず、復元時に勝手に最新へ差し替わる——という話を前回書いた。今回はそれを直した。直す過程でいちばん効いたのは、「データベースにしまう形」と「APIが画面に返す形」を別物として分けたことだった。

この記事で扱うこと

  • 直し方を「写真ID → 綴りID」だけで終わらせず、source という二択に広げた
  • 保存形を変えると、画面側(写真IDの順番に依存)が壊れる問題が出た
  • 「しまう形」と「返す形」を分けて、フロントを無改修にした
  • 古い保存ページを後方互換で置いてけぼりにしない
  • 今回はバックエンドだけに範囲を切った

直し方を「写真IDを綴りIDに変える」で終わらせなかった

前回の結論は「保存の単位を綴り(meaning)に寄せる」だった。実際に手を動かす段で、もう一歩踏み込むことにした。テンプレに写真を「手で並べる」だけじゃなく、「撮影日付順で自動に流し込む」もやってみたかったからだ。同じテンプレでも、どっちの出し方をするかを自分で選べる方がいいです。

そこで保存する形を、source(源)というタグで二択に分けた。

jsonc
// 源A:手で並べる(curated)。写真1枚ぶんの枠(スロット)ごとに「どの綴りを見せるか」も持つ
{
  "template": "letter",
  "source": "curated",
  "slots": [
    { "photoId": "42", "meaning": { "mode": "pinned", "id": 1001 } }, // この綴りで固定
    { "photoId": "87", "meaning": { "mode": "latest" } }              // 常に最新の綴り
  ]
}
 
// 源B:撮影日付順で自動(timeline)。載せる写真も並び順も保存せず、ルールだけ持つ
{
  "template": "letter",
  "source": "timeline",
  "rule": { "order": "taken_at", "limit": 12, "meaning": "latest" }
}

(この翌日に3つ動いている。meaning の latest は「最初の綴り」へ統一されて、いまは名前だけ残って最新を指していない。timeline の軸には written_at(綴った日)と向き(昇順/降順)が足された。そして timeline 自体が「持ち主の写真を全部拾って上限で切る」から「選んだ photoIds を軸で並べ直す」形に変わり、rule.limit は廃止された。以下は 6/24 時点の記録。)

curated と timeline は、保存しているものの種類が違います。curated は並んだ結果を保存していて、timeline は並べ方のルールを保存しています。だから timeline のページは開くたびに中身が決め直されます——あとから写真を足せばページに入ってきますし、撮影日を直せば順番が変わります。curated は保存した時の並びのまま動きません。

curated は「この写真の、この綴りで固定」まで指定できます。timeline は載せる写真も並び順も自分では持ちません——「撮影日付の古い順に、上限N枚まで」というルールだけ持ち、どの写真がどの順で入るかはサーバが決めます。載せる枚数は rule.limit で必ず指定させた(正の整数だけ受ける)。この timeline が「並びを自分で持たない」というのが次の問題につながります。

困ったのは、画面側が「写真IDの順番」に強く依存していたこと

保存形を作り替える前に、描画側がいま何を読んでいるかを洗い出した。すると、テンプレの大半が layout_json.photoIds の並び順そのものを正として描いていました。

ts
// 画面側はずっとこの形を読んでいる:写真IDの並び(配列) + そのIDから写真の中身を引ける置き場(プール)
toTplPhotos(photoIds, pool)   // ← 並び順は photoIds がそのまま正

ここで保存形を source ユニオンに変えると、画面がそのまま読んでいた「並び順つきの photoIds」が消えます。とくに timeline は写真IDすら持ちません(どの写真がどの順で入るかを丸ごとサーバが決めます)。素直にやると、テンプレ全部とその周辺を「source / slots / rule を読める」ように書き換える話になります。30種あるテンプレを全部だ。それは触る面が広すぎる。

選択肢は2つでした。(A) 画面側を全部、新しい保存形を読めるように書き換えます。 (B) 画面が読む形は変えず、保存形だけ変えます。 Bでいけるなら、そっちが圧倒的に被害が少ないです。

しまう形と、返す形を分ける

Bを成立させるために置いたのが、保存形を「画面が前から読んでいる形」に戻してから返す一枚の解決器です(ここでの「解決」は問題を解くことではなく、source やルールを見て最終的な並びを1つに確定させる、の意味)。resolve_layout という関数を一本足してもらった。

python
# resolve_layout(イメージ): source を見て「順序つきの photoIds」に解決して返す
# - curated → slots の順に photoId を並べる
# - timeline → 持ち主の写真を taken_at 順に拾って上限(rule.limit)で切る
# - 旧形(source 無し)→ photoIds をそのまま(後方互換)
def resolve_layout(layout_json, owner, request):
    source = layout_json.get("source")
    # ここで source を見て分岐し、最終的に「並んだ photoId のリスト」を作る ← タグ付きユニオンを読む所
    ...
    return {"photoIds": [...解決済みの順...], "photos": {...IDで引けるプール...}}

そして、API が owner(そのページを作った本人)や共有相手に返すときは、保存してある source / slots / rule をそのまま出しません。解決済みの photoIds に直して返します。

jsonc
// 返す形(解決後)。画面が前から読んでいるのと同じ形
{ "template": "letter", "photoIds": ["87", "42", ...] }   // source も slots も rule も無い

これで、しまう形(source ユニオン)と、返す形(photoIds の配列)が別物になりました。保存は新しい二択、でも画面に届く形は前と同じ。結果、テンプレ側は1行も触らずに済みました。timeline のページも、サーバが撮影日付順に並べた photoIds を返すから、そのまま描けます。

解決器の仕事は並べ替えだけじゃありません。拾ってきた写真の中には、持ち主が違うものや、あとから消されて空振りになるID(欠番)が混ざりうるので、そこで落とします。写真ごとに「どの綴りを見せるか」を1件に決めるのも同じ場所です。

しまう形(DB に入っている)backend の境界返す形(画面が読む形)curated(手で並べる)slots[ ] を持つ=並んだ「結果」を保存timeline(撮影日順)rule(順序+上限)だけを持つ=並べ方の「ルール」を保存旧形(source なし)photoIds だけを持つ=前に保存済みのページresolve_layout()backend の1関数(返す形を作る所)① source を見て分岐② 他人・欠番の写真を落とす③ 写真ごとに綴りを1件決める④ 順序つきの ID に均す解決して返すphotoIds — 並んだ IDphotos — ID から中身保存の形は3つに増えたここで1つに均す画面(テンプレ30種)は1行も触っていない

保存側は3つに枝分かれしているのに、境界を超えて出ていく形は1本しかありません。左が増えても右は変わらない、というのがこの図の全部だ。保存の進化は、この API の境界で止めた。

図の②③は今回の主題から少し外れるが、境界を1本にしたことの副産物だ。ここが「保存形を返す形に直す唯一の関門」になったので、他人の写真が混ざっていないか・写真が消えていたらどう扱うか・綴りが複数あるときどれを見せるか、といった判断も自然とここに集まった。返す先は本人の画面と共有相手の2つありますけれど、通る関門が1枚なので、どっちから見ても同じ並び・同じ綴りになります。出口を1つに絞ると、そこが検問所も兼ねます。

古い保存ページを置いてけぼりにしない

困るのは、もう保存されている既存のページだ。それらは旧形(photoIds だけ・source 無し)で入っています。新しい解決器がこれを読めないと、過去に作ったページが全部開けなくなります。

そこを resolve_layout の中で、「source が無い = 手で並べた(curated)扱い」として旧形をそのまま受ける分岐にした。新形でも旧形でも、同じ「解決済み photoIds」が出てきます。

直したあと、既存ページ向けのテストが全部通ったままか(=古いページを壊していないか)を最初に確認した。旧形の写真IDの並びが変わっていない=古いページは見え方が1ミリも変わっていない、という確認だ。

範囲をバックエンドだけに切った

今回は、作成画面(CreateWizard)の UI には手を付けなかった。「手で並べる/撮影日付順」を選ぶボタンも、特定の綴りで固定する画面も、今回は作っていない。バックエンドが新しい保存形を受け取れて・解決できる状態にするところで止めた。

切り分けはこうです。選択肢の「中身」は決めます(curated / timeline / pinned / latest をデータとして固める)。けど「出し方(UI)」は後にします。 バックエンドが新形を捌けることはテストで担保したので、画面で選ばせるのは独立した次のかたまりとして切り出せます。一度に全部やると、保存形の作り替えと UI の作り込みが混ざって、どこで壊れたか分かりにくくなります。

もう1つ範囲を絞ったところがあります。テンプレの種類は30種あるけど、その一覧をバックエンドのバリデーションには持ち込まなかった。template は「空でない文字列か」だけ確認して、30種の正解リストはフロント側だけに置いたままにした。同じリストを2か所で管理すると、テンプレを増やすたびに両方直す羽目になります。今回の検証は「保存形の構造が壊れていないか(slots や rule の形)」に絞って、種類の正誤判定はフロントに任せた。

おわりに

このテーマは要するに、保存する形を変えるときに、APIが返す形まで一緒に変える必要はない、という分け方だった。

逆に言うと、保存形をそのまま返していたら、その形がそのまま「APIの約束」になります。一度画面に見せた形は、次に保存形をいじるときも守らないといけません。返す形を据え置いたのは、今回テンプレ30種を触らずに済ませるためだけじゃなく、次に保存形を変えるときの逃げ道を残すためでもある。

やってみて効いたのは順番で、先に「画面側は結局なにを読んでいるのか」を洗い出したから、「返す形を据え置けば画面は触らなくていい」に気づけた。保存形を作り替える話だと思って始めたのに、いちばん時間をかけたのは画面側が何に依存しているかの調べ物だった。そこが分かると、変えていい所(保存)と、据え置く所(返す形)の線が引けた。


この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。