綴Photo の勉強ノートに戻る
技術ブログ2026.07.12

DB の型を UI から隠したら、新しい抜け穴ができた — 列の写し忘れをテストで塞ぐ

DartDriftデータモデルテスト綴Photo

DB のテーブル型を UI に直接渡すのをやめて別の型に写したとき、その変換で起きうる事故をどう先回りで塞いだかの判断メモです。

ここで言う「web」は、綴ったものを web でも見られるように開発中のアプリ(紡Book)のことです。

UI が DB の型を直接持っていた

綴Photo のローカル DB は Drift(SQLite の ORM)で、テーブルごとに Photo や Meaning という行の型が自動生成される。今まではこれを UI までそのまま渡していた。画面のウィジェットが Photo を受け取って、photo.takenAt や photo.width を読む。

これ自体は動く。というより、そう書くのが自然に見えていた。Drift が生成する Photo は「その写真について分かることが全部載っている、一番詳しい型」だ。実際、詳細画面の公開バッジを出す判定関数は Photo の行を丸ごと受け取って、webSelected(公開対象に選んだか)・syncState(同期済みか)・remoteUrl(画像が向こうに着地したか)の3つの列から状態を組み立てていた。行ごと渡しておけば、あとで「あの列も要る」となっても画面側で取り出せる ── 渡さない理由のほうが無い、という見え方だった。

引っかかったのは、アーキテクチャの整理をしていたとき(層の分け方と依存方向そのものは、前日の「既存の違反は責めず、新しい違反だけ止める」に書いた。ここはその層の線を1本、実際に引き直した話になる)。DB のテーブルに列(カラム)を1本足すと、自動生成される Photo の型が変わる。すると Photo を受け取っている遠くの画面のコードまで、全部が作り直しの対象になる(画面の再描画ではなく、コード生成とコンパイルをやり直すという意味)。UI は「写真の撮影日時と幅と高さ」が欲しいだけなのに、syncState や serverId みたいな同期用の内部の列まで一緒に見えてしまっている。「一番詳しい型だから渡しておく」は、裏返すと「画面が同期の内部事情を知っている」状態だった。

そこで UI が使う分だけを持つ別の型(read model)を切って、DB の生成型は変換して渡すことにした。

dart
// UI が実際に使う列だけを持つ。syncState / serverId など同期用の「生の列」は載せない
// (公開状態の表示だけは、それらから導出した1フィールドにまとめて持たせている)
class MemoryPhoto {
  const MemoryPhoto({
    // required = 省略不可。ここに項目を足すと、渡していない変換コードがビルドで落ちる
    required this.id,
    required this.takenAt,
    required this.width,
    required this.height,
    // …UI が読む分だけ
  });
  final int id;
  final DateTime? takenAt; // 型のうしろの ? は「値が無い(null)こともある」の意味
  // …
}

隠したぶん、写し忘れの穴が開く

read model にしたことで UI から DB の型は見えなくなった。けど、変換を1枚挟むということは、DB の行から read model へ列を写すコードがどこかに要る。それが新しく事故れる場所になる。

具体的にこう転ぶ。あとで写真テーブルに「お気に入りフラグ」みたいな列を足したくなる。DB に列を追加して、MemoryPhoto にもフィールドを足す。でも変換のコードに写しを1行足すのを忘れる。すると UI にはそのフラグが常に初期値で届く。DB には正しく入っているのに、画面では反映されない ── コードの組み立て(コンパイル)は通るし、それまでに書いてあったテストも全部パスしたまま。機械はどこも「異常なし」と言う。静かにデータが欠ける。

生成型をそのまま使っていたときは、この穴はなかった。列を足せば型が自動で変わって、全部載って UI まで届いていた。read model にした瞬間、「手で写す」工程が生まれて、そこが抜け落ちうる。隠すことのコストとして、この写し忘れの経路を引き受けることになる。

層を1枚増やすと、その線を越える「写し」が1つ生まれる。前 ── Drift の行を、そのまま UI までPhoto(Drift の行)列が全部載る列を足すと、そのまま UI まで届く= 写し忘れようがないUI(画面)後 ── UI 用の型を1つはさむ(増えた箱は1つ)Photo(Drift の行)列が全部載る⚠ ここで手で写すtoMemoryPhoto()MemoryPhotoUI が使う分だけ = 増えた層UI(画面)増えた箱は1つ(UI 用の型)。でももう1つ増えている ── 箱のあいだの「境目」のほう。境目を越える「写し」は手で書く ── 列を1本足したら、そこにも1行足す。書き忘れても DB は正しいまま。

「渡し忘れ」はビルドで止め、「取り違え」はテストで止める

穴を塞ぐのに2枚重ねた。

1枚目は型そのもの。read model のコンストラクタ(=その型を1個作るときの入り口)で、全フィールドを required にした。MemoryPhoto に favorite フィールドを足したら、favorite: を渡していない変換コードはコンパイルエラーになる。「フィールドを足したのに写していない」が、動かす前に落ちる。

でもこれだけだと「間違ったフィールドを写した」(favorite に width を入れた、みたいな取り違え)は捕まらない。required が保証するのは「何か値を渡した」ことだけで、渡した値が正しいかは見ていないので。

2枚目に、変換を全フィールド1対1で固定するテストを置いた(DB の1列に、テストのチェック1行が必ず1つ対応する、という意味)。DB の行(Photo)を全部の列を埋めて作り、それを画面用の型(MemoryPhoto)に変換して、フィールドを1個ずつ突き合わせる。下のテスト名にある Photo → MemoryPhoto は「Photo から MemoryPhoto へ写す」と読む。

ここで効いてくるのが、変換が「写すだけ」ではない場所だ。冒頭に書いた公開バッジの判定が、まさにそれにあたる。read model には「web に公開済みかどうか」を表すフィールドが1つあるが、DB 側にその列は無い。3つの同期用の列から、変換のときに組み立てている。単なる写し忘れは1行足せば直る。でもこういう導出は条件を1つ間違えると「公開したのに未公開表示」のような、原因の遠い壊れ方をする。固定しておきたいのは、実は素直な写しよりこっちだった。

dart
// test('テストの名前', () { 中身 }) が1本のテスト
test('Photo → MemoryPhoto は全フィールドを写す', () {
  // 1. DB から読んだ行のつもりのデータを、全部の列を埋めて手で作る
  final row = Photo(id: 42, takenAt: takenAt, width: 4032, height: 3024, /*…全部の列…*/); // 全列を埋めた Photo 行を直接組み立てる
  // 2. 記事で言っている「変換」の本体。DB の行を画面用の型へ写す
  final view = row.toMemoryPhoto(); // 変換本体を呼び read model を得る
 
  // 3. 下の1行が read model の1フィールドに対応する。expect(実際の値, こうなってほしい値)
  //    取り違えても写し忘れてもここが落ちる
  expect(view.id, 42); // id がそのまま写っているか
  expect(view.takenAt, takenAt); // 撮影日時がそのまま写っているか
  expect(view.width, 4032); // 幅がそのまま写っているか
  expect(view.height, 3024); // 高さがそのまま写っているか
  // 導出フィールド: 公開対象 + 同期済み + 画像着地 から「公開済み」を組み立てる
  expect(view.webPublish, WebPublishState.published); // 3列から組み立てた導出値が期待どおりか
});

このテストの値は、toMemoryPhoto() の変換コードをそのまま二重に書いているだけに見える。実際そうで、退屈なテストだ。でもここが二重になっていること自体が狙いで、変換とテストの両方を同時に間違えないと素通りしない。片方だけ直して片方を忘れる、が一番起きるので、そこを鏡にして突き合わせる。

まとめるとこうなる。

Drift の行を UI へ渡すのをやめて、UI 用の型へ写す。その写しの事故を2枚で塞ぐ。Photo(Drift が自動生成)テーブルの列が全部載るid / takenAt / width / heightrotation / relinkedAt / title …serverId / syncState / remoteUrlwebSelected / uploadedAt =同期用toMemoryPhoto()UI へ出す唯一の経路MemoryPhoto(UI 用の型)DB のテーブルは1枚も増えないid / takenAt / width / heightrotation / relinkedAt / title …webPublish=写しではなく、組み立てた値webPublish だけは写しではない ── 左の同期用3列から、変換のときに組み立てている。網① required ── ビルドで止まる止めるのは【渡し忘れ】型にフィールドを足したのに、変換で渡していない → コンパイルが通らない網② 全列テスト ── 赤で落ちる止めるのは【取り違え】渡してはいるが、値が違う(幅を別のフィールドに入れた、など)どちらの網も「何を載せるか」は決めない ── それは設計の判断(要るものだけ載せる)。網① は「全部埋めたか」だけ、網② は「埋めた値が合っているか」だけを見ている。

read model と変換コードとテストの3箇所に、コメントも1行ずつ残した ──「列を足すときはこのテストも必ず更新すること」。半年後の自分(と AI)が、この二重化のルールを知らずにフィールドだけ足すのを止めたい。

補足として、これは記事を書いたあとの話。翌日に写真の回転角の列を1本足したとき、read model・変換・テストの3箇所が同時に増えた。仕組みを置いてから最初に来た「列の追加」を、3点セットで通せた形になる。ただしそのフィールドには既定値(rotation = 0)を付けたので、そこだけ1枚目の required の網からは外れていた。省略できると書く側が楽なので付けたのだが、付けた瞬間に1枚目の防御からこぼれる。写し自体は2枚目のテストが押さえていた。

隠すコストは、隠す前に見えていない

やってみて腑に落ちたのは、「DB の型を UI から隠す」の見返りとそのコストが、非対称に見えていたこと。見返り(UI が DB の変更に振り回されない)は最初から欲しくてやった。コスト(写し忘れの経路が増える)は、変換コードを書き始めるまで意識に上がってこなかった。

隠す・挟む・抽象を1枚足す、という整理は形が似ていて、境界を1枚増やすと「境界をまたぐ写し」が出てくる。その写しは新しく間違えられる場所になる。次に抽象を足すときは、足したぶんの「またぎ」をどう守るかも一緒に決めておきたい ── read model なら required と全列テスト、が今回の答えだった。

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