メディカルサークルでは、ノートを「大学 > 学年 > 科目」の3階層で整理できます。
この階層をどうデータモデリングするかは、後の検索や横断機能の自由度を大きく左右する重要な設計判断でした。
ここでは Firestore 上の実装方針と、選択の背景を整理します。
目次
サブコレクションを使わずフラットなコレクションに揃える
files コレクション 1 本に university / grade / subject をフィールドとして持たせるフラット構成にしました。
理由は 2 つあります。
1 つは「他大学のノートを横断検索したい」という要件で、サブコレクション構成だとコレクショングループクエリが必須になり、設計の自由度が落ちるからです。
もう 1 つは「科目名は固定マスタではなく、ユーザーが自由入力する」必要があり、サブコレクションの親 ID として固定化するには向かないと判断したためです。
マスタ + 自由入力のハイブリッドで取りこぼしを防ぐ
大学と科目は、アプリ内に主要なものを初期リストとしてハードコードしつつ、選択 UI の末尾に「その他」を置き、フリーテキスト入力欄を展開する構成にしました。
大学・学年・科目はすべて学校ごとに名称が違うため、マスタだけで網羅するのは現実的ではありません。
候補から選べる手軽さと、必要なときに自由に書ける逃げ道を両立させる設計です。
表記ゆれは許容し、検索側で吸収する
「解剖学」「人体解剖学」のような表記ゆれは正規化せず、ユーザー入力をそのまま保存しています。
そのうえで、検索時には Firestore の前方一致(isGreaterThanOrEqualTo)に加えてクライアント側でフリーワードフィルタをかけることで、部分一致を吸収しています。
「解剖」で検索すれば「解剖学」も「人体解剖学」もヒットするため、ユーザー側からは正規化の不在が問題にならない設計です。
複合インデックスは宣言で管理し、無音失敗を防ぐ
階層で絞り込みつつ「閲覧数順」「いいね順」で並べる検索は、Firestore の複合インデックスとの戦いになります。
必要な組み合わせは事前に宣言してデプロイで管理する方式にしました。
最も苦労したのは「インデックス未定義でもエラーが出ずに結果が無音で空になる」ケースです。
機能が壊れているのに気づけない事故を防ぐため、StreamBuilder の error ハンドラで必ず debugPrint を仕込み、新しいクエリパターンを追加したら即 firebase deploy --only firestore:indexes を CI に組み込む運用に切り替えました。
アトミックカウンタでリアルタイム集計を整合させる
いいね数や閲覧数はリアルタイムに増えるため、ドキュメントの読み書きが競合します。
ここは Firestore のアトミックインクリメントを使い、競合下でも数が崩れないようにしました。
リアルタイム集計を別コレクションに分けるかどうかは要件次第ですが、メディカルサークルの規模では同一ドキュメント内のフィールド更新で十分に応答性も整合性も保てています。
階層ナビは画面 state で持ち、データ構造から切り離す
マイファイル画面のパンくず式ナビは、Firestore の階層構造ではなく画面 state で selectedSubject を持つだけのシンプルな実装にしています。
面倒だったのは FAB の挙動で、トップ階層では汎用の「+」ボタンを置き、科目階層では「その科目の最新ファイルから教授名・曜日・時限を自動引き継ぎしてアップロード画面に渡す」処理を組み込みました。
データ構造をシンプルに保つ代わりに、操作の文脈は UI 側で持つというトレードオフです。
まとめ
階層データの設計では「正規化するか / 許容するか」「サブコレクションにするか / フラットにするか」が常に論点になります。
メディカルサークルでは、横断検索と自由入力という要件を起点にフラット構成を選び、検索側で表記ゆれを吸収しました。
結果として、追加の階層や学校が増えてもスキーマ変更なしで対応できる柔軟さが残せています。

.webp%3Falt%3Dmedia%26token%3D6ca2c2ef-9413-4453-b992-55b66b11ed54&w=3840&q=75)


.webp%3Falt%3Dmedia%26token%3D900f385d-12a2-449b-8d1e-83a57cef0088&w=3840&q=75)
.webp%3Falt%3Dmedia%26token%3D0e802fb0-2dda-44a7-bf80-5d39019635ba&w=3840&q=75)
.webp%3Falt%3Dmedia%26token%3D3fb3dc66-ecca-402e-8fb8-fbec9407f7f5&w=3840&q=75)
.webp%3Falt%3Dmedia%26token%3Ddb21d760-e1ed-4ec2-af28-3462041e31b5&w=3840&q=75)
.webp%3Falt%3Dmedia%26token%3Dcce7bd72-f11e-4292-86bf-e6ccf3e7bf32&w=3840&q=75)
.webp%3Falt%3Dmedia%26token%3D457ff920-e0df-4ff5-95eb-e29f74b73823&w=3840&q=75)
.webp%3Falt%3Dmedia%26token%3Dc21fcc77-7404-458d-9eb5-85b8d84ae1bc&w=3840&q=75)
.webp%3Falt%3Dmedia%26token%3D92052f12-5280-49df-877a-b514582e95db&w=3840&q=75)

.webp%3Falt%3Dmedia%26token%3Da7c14698-1b08-4fea-89c6-f77a9121f4c5&w=3840&q=75)
.webp%3Falt%3Dmedia%26token%3D899eeefd-f4c9-44a6-9ec2-3ced0b223ffd&w=3840&q=75)
.webp%3Falt%3Dmedia%26token%3Dca25fa6b-e233-43f7-90c3-e68e4c5b0bc5&w=3840&q=75)
.webp%3Falt%3Dmedia%26token%3D7f18e5f1-cfda-4148-ab86-b3d2e6547262&w=3840&q=75)
.webp%3Falt%3Dmedia%26token%3D5f10e078-4d87-4c87-928c-21b719cbf1cb&w=3840&q=75)
.webp%3Falt%3Dmedia%26token%3D957b18b6-9b01-4c94-9207-7b9fca22a787&w=3840&q=75)
.webp%3Falt%3Dmedia%26token%3Dd952e11d-4461-47ae-892d-622fc3f2a48a&w=3840&q=75)
.webp%3Falt%3Dmedia%26token%3D532bb657-5670-49b4-9165-5f758062d8dd&w=3840&q=75)