綴Photo の勉強ノートに戻る
技術設計2026.04.04

写真アプリのローカル DB をどう設計したか。「将来の同期」をどこまで先読みするか問題

FlutterDriftDB設計綴Photo

綴Photo のローカル DB を設計したときの話だ。 最初に判断したことの中で、いまでも「やっといてよかった」と思えるものがいくつかある。 逆に「やりすぎたな」と思っているものもある。 正解じゃなくて、当時の自分が何を考えてどう決めたかの記録として残しておく。


なぜ複数テーブルに分けたか

綴Photo は1枚の写真に対して、複数の「気持ち(日記エントリ)」を後から重ねていける作りです。 3年前の写真を今見たときの気持ちを書き足せる、というコンセプトがあります。

これを最初から踏まえると、「1枚の写真 = 1レコード」というシンプルな構造だと困ります。

写真テーブルと、日記エントリテーブルを別々に持って、 1:N で繋ぐ構造にした。

実ファイルと行は別の箱。行は端末 SQLite に保存する。端末の写真ライブラリ実ファイルsource_id の指す先アプリのテーブルではない閉じてもライブラリに残るsource_id端末 SQLite photos1枚 = 1行。id / source_id / taken_at閉じても残る1:N photo_identries 2023body / written_at同じ写真への 1 行目entries 2026body / written_at3年後に足したもう 1 行実ファイルはライブラリ。行は端末 SQLite の photos と entries。クラウドのテーブルはまだ無い。1枚1レコードにすると、2026 の行を足す箱が無い。

ここまでは順当な判断だったと思う。


詰まったところ:同期未実装なのにスキーマで気にしないといけないか

ここからが個人開発の難しいところだった。

綴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 用テーブルは設計が変わる可能性が高い。 不確実なものに対して構造を作り込むと、その構造に縛られて後で動けなくなります。

ここで止めた感覚は、振り返って正しかったと思っている。

足したのは既存テーブルの列。新しい箱は作っていない。端末 SQLitephotos / entries の列created_at / updated_atdeleted_at(NULL なら未削除)sync_status = local_onlyいまは未使用。閉じても残るほぼ確実に必要 → 足したクラウド未実装テーブルも無い保存する場所が無い衝突用テーブル作らなかった箱は無い将来必要かも → 止めた残るのは端末 SQLite の 4 列だけである。クラウドも衝突用テーブルも、置き場所が無い。

「先読みは、ほぼ確実に必要なものだけ」というルールが、自分の中で固まった瞬間だった。



Drift を使った理由

正直、最初に「Drift いいな」と思った決め手はシンプルで、TypeScript っぽい書き方ができそう だった、それが大きかった。スキーマを Dart コードで書いて、クエリすると 型のついたクラス が返ってきます。TS の型あり開発に慣れていた感覚で書けそうだったので、自分の頭の中で接続が早かった。

「3つの基準で並べて評価して...」みたいな選び方は実はしてなくて、触ってみて『これなら自分でも後で読み返せそう』と思ったから使い続けている、というのが正直なところ。

なぜこの「型がある/ない」が個人開発でこんなに効くのかは、書きながら後で言語化できた。


型がない世界の何がしんどいか

DB クエリの返り値は、生のままだとだいたいこういう形で返ってきます。

dart
{
  "id": 42,
  "title": "今日の朝食",
  "created_at": "2026-04-30",
  "owner_id": 1,
}

これが Map<String, dynamic> ─ 「文字列をキーにした、何でも入る辞書」のこと。コンピュータからすると 中身が決まってないただの箱 で、何が入ってるかは実行してみないと分かりません。

しんどいのは2点。

1. タイポしても気づけない

dart
final title = photo["titel"];  // ← typo. 正しくは "title"

コンパイルは通ります。実行すると title が null になって、別の場所でエラーになります。バグの発生地点と気づく地点が離れるので、追跡がしんどいです。

2. IDE が補完してくれない

エディタで photo[ まで打っても、何も候補が出ません。エディタも「中身が何か」を知らないからです。列名を全部覚えておかないと書けません。テーブルが10個あって列が80個あると、これがひたすらしんどいです。


Drift だとこうなる

Drift はテーブル定義から 型のついたクラス を自動生成してくれます。

dart
class PhotoRow {
  final int id;
  final String title;
  final DateTime createdAt;
  final int ownerId;
}

クエリの返り値はこのクラスになります。すると:

dart
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_at updated_at deleted_at だけは入れておく
  • 「ほぼ確実に必要」と「将来必要かも」の線引きをサボらない
  • 道具(DB ライブラリ)は「自分が後で読み返せるか」で選ぶ
  • 個人開発の DB 設計は、未来への保険を最小限だけ買う

完璧な先読みはできません。 だからこそ「どこまで先読みするか」の判断軸を自分で持っておくのが大事だと思った。


この記事は2026年4月時点の自分の理解と実装方針で書いています。アプリは継続開発中なので、現在の実装と細部が違っている可能性があります。