綴Photo のローカル DB を設計したときの話だ。 最初に判断したことの中で、いまでも「やっといてよかった」と思えるものがいくつかある。 逆に「やりすぎたな」と思っているものもある。 正解じゃなくて、当時の自分が何を考えてどう決めたかの記録として残しておく。
なぜ複数テーブルに分けたか
綴Photo は1枚の写真に対して、複数の「気持ち(日記エントリ)」を後から重ねていける作りです。 3年前の写真を今見たときの気持ちを書き足せる、というコンセプトがあります。
これを最初から踏まえると、「1枚の写真 = 1レコード」というシンプルな構造だと困ります。
写真テーブルと、日記エントリテーブルを別々に持って、 1:N で繋ぐ構造にした。
ここまでは順当な判断だったと思う。
詰まったところ:同期未実装なのにスキーマで気にしないといけないか
ここからが個人開発の難しいところだった。
綴Photo はまだクラウド同期を実装していません。 ローカルにしかデータがない、シンプルなアプリです。
でも、「将来は別端末で同じデータを見られるようにしたい」という気持ちは最初からあった。
問題は、じゃあ今のスキーマで同期のことをどこまで先読みすべきかだった。
選択肢は大きく2つある。
- A: 今は完全にローカルのことだけ考える。同期するときに大改修する
- B: 同期実装は後でも、スキーマだけは同期に耐える形にしておく
最初は A でいいと思っていた。 「YAGNI(必要になるまで作るな)」という言葉も知っていたし、 1人で作っているアプリで先回りしすぎるのは怖い。
でも、ローカル DB の世界で「データの作成日時・更新日時」「どの端末から書いたか」「論理削除フラグ」あたりを後から足すのは、結構しんどいです。
既存データに対して NULL で埋めたカラムを足すと、後で同期するときに「初期値の意味」が分からなくなります。
ここで判断を変えて、B にした。
スキーマに最小限の「同期準備」を入れた
具体的には、以下のカラムを各テーブルに足した。
created_at(作成日時)updated_at(更新日時)deleted_at(論理削除日時、NULL なら未削除)sync_status(同期状態を表す enum 風の文字列、いまはlocal_only固定)
sync_status は完全にダミーで、いまは何にも使われていません。
でも、将来「同期未済」「同期済」「衝突中」みたいな状態を入れたくなったときに、
カラムを足す DDL が要らないのは大きい。
ここを入れたことで、
- 削除を物理削除じゃなく論理削除に統一できた
- 「いつ書いたか」がエントリ単位で分かるようになった
- 後から同期を実装しても、過去データを救える
の3つを同時に得た。
これは個人開発で言うと、たぶん 1〜2時間の追加作業で、 将来の自分への保険になっている。やってよかった判断のひとつだ。
逆に、やりすぎた話
正直に書くと、「Conflict resolution テーブル」みたいなものまで予習で作ろうとして、 途中で「いや、これはやりすぎ」と止めた経緯がある。
クラウド同期で衝突が起きたときに、両方のバージョンを保持して比較する仕組み。 これは将来必要になるかもしれない。
「将来必要かもしれない」のと「将来必ず必要になる」は別物です。
updated_at はほぼ確実に必要になるけど、Conflict 用テーブルは設計が変わる可能性が高い。
不確実なものに対して構造を作り込むと、その構造に縛られて後で動けなくなります。
ここで止めた感覚は、振り返って正しかったと思っている。
「先読みは、ほぼ確実に必要なものだけ」というルールが、自分の中で固まった瞬間だった。
Drift を使った理由
正直、最初に「Drift いいな」と思った決め手はシンプルで、TypeScript っぽい書き方ができそう だった、それが大きかった。スキーマを Dart コードで書いて、クエリすると 型のついたクラス が返ってきます。TS の型あり開発に慣れていた感覚で書けそうだったので、自分の頭の中で接続が早かった。
「3つの基準で並べて評価して...」みたいな選び方は実はしてなくて、触ってみて『これなら自分でも後で読み返せそう』と思ったから使い続けている、というのが正直なところ。
なぜこの「型がある/ない」が個人開発でこんなに効くのかは、書きながら後で言語化できた。
型がない世界の何がしんどいか
DB クエリの返り値は、生のままだとだいたいこういう形で返ってきます。
{
"id": 42,
"title": "今日の朝食",
"created_at": "2026-04-30",
"owner_id": 1,
}これが Map<String, dynamic> ─ 「文字列をキーにした、何でも入る辞書」のこと。コンピュータからすると 中身が決まってないただの箱 で、何が入ってるかは実行してみないと分かりません。
しんどいのは2点。
1. タイポしても気づけない
final title = photo["titel"]; // ← typo. 正しくは "title"コンパイルは通ります。実行すると title が null になって、別の場所でエラーになります。バグの発生地点と気づく地点が離れるので、追跡がしんどいです。
2. IDE が補完してくれない
エディタで photo[ まで打っても、何も候補が出ません。エディタも「中身が何か」を知らないからです。列名を全部覚えておかないと書けません。テーブルが10個あって列が80個あると、これがひたすらしんどいです。
Drift だとこうなる
Drift はテーブル定義から 型のついたクラス を自動生成してくれます。
class PhotoRow {
final int id;
final String title;
final DateTime createdAt;
final int ownerId;
}クエリの返り値はこのクラスになります。すると:
final photo = await query.getSingle(); // 型は PhotoRow
photo.title; // ✅ String と分かってる、IDE が補完してくれる
photo.titel; // ❌ コンパイルエラー — 「PhotoRow に titel ってフィールドはない」コンパイルの段階でタイポが落ちます。実行する前に分かります。列名を全部覚えてなくても、photo. と打てば候補が全部出ます。
「自分がこの先読み返す」前提で道具を選ぶときに、この 静的解析が効く という性質はめちゃくちゃデカかった。AI に書いてもらったコードを後で自分が手直しするときも、型があると「この変数、何が入ってるんだっけ」を都度辿らなくて済みます。
副次的に、スキーマ変更時のマイグレーションが Drift にちゃんとサポートされてるのも、後から効いた。
この記事で書かなかった話
- マイグレーションの具体的な書き方
- Drift で 1:N の Join をどう書いているか
- インデックスの貼り方
このあたりは「DB スキーマの判断」というよりは「Drift の使い方」の話なので、 別記事に分けたい。
学んだこと
- 1:N にしておくと、後から「重ねる」系の機能を足しやすい
- 同期未実装でも、
created_atupdated_atdeleted_atだけは入れておく - 「ほぼ確実に必要」と「将来必要かも」の線引きをサボらない
- 道具(DB ライブラリ)は「自分が後で読み返せるか」で選ぶ
- 個人開発の DB 設計は、未来への保険を最小限だけ買う
完璧な先読みはできません。 だからこそ「どこまで先読みするか」の判断軸を自分で持っておくのが大事だと思った。
この記事は2026年4月時点の自分の理解と実装方針で書いています。アプリは継続開発中なので、現在の実装と細部が違っている可能性があります。