はじめに
先日、エンジニア志望として面接を受けた際に、自分が開発したアプリの技術的な質問にほとんど答えられませんでした。
「テーブルはいくつありますか?」
「なぜこの設計にしたのですか?」
そんな基本的な質問に対して、言葉が出てきませんでした。
自分で作ったアプリなのに——。
AIに頼りながらなんとか動くものは作れた。でも、「なぜこう作ったのか」を自分の言葉で説明できない。その事実がとても悔しかったです。
この記事は、その悔しさをバネに、自分が開発したアプリのテーブル設計を改めて学び直し、「理解した上で説明できるエンジニア」に近づくために書いています。同じような経験をした方に、少しでも参考になれば嬉しいです。
2. 答えられなかったこと——まず基礎から整理する
データベースとは?
一言でいうと、 「アプリのデータを整理して保存しておく棚」 です。
たとえば、ユーザーの名前・メールアドレス・パスワードなど、アプリが動くために必要な情報をバラバラに持っていると管理が大変です。データベースはそれらを整理された形で保存し、必要なときにすぐ取り出せるようにしてくれます。
テーブル設計とは?
テーブルとは、データベースの中にあるExcelの表のようなものです。
テーブル設計とは、「どんな表を作るか・その表にどんな項目を持たせるか」を決める作業です。設計がしっかりしていると、データの管理がしやすく、バグも起きにくくなります。
3. テーブル設計(DB設計)について
「なぜこの形にしたのか」という意図が大事
テーブル設計で最も重要なのは、「なぜこの構造にしたのか」を説明できることだと学びました。
今回は自分が開発した子ども予防接種スケジューラーアプリを題材に、テーブル設計を振り返ります。
アプリのテーブル一覧
自分で設計・作成したテーブルは 9つです。
| # | テーブル名 | 役割 |
|---|---|---|
| ① | children | 子ども管理 |
| ② | vaccines | ワクチン情報 |
| ③ | vaccination_schedules | 接種スケジュール |
| ④ | side_effects | 副反応記録 |
| ⑤ | medical_institutions | 医療機関管理 |
| ⑥ | appointments | 予約管理 |
| ⑦ | mother_child_photos | 母子手帳写真 |
| ⑧ | family_groups | 家族グループ |
| ⑨ | family_group_members | 家族グループのメンバー |
さらに、プッシュ通知機能のために導入したLaravelパッケージが自動生成する push_subscriptions テーブルを含めると、合計10テーブルになります。
3-1. テーブル設計の全体像と「リレーション」の意図を理解することが大事
各テーブルはバラバラに存在するのではなく、 リレーション(つながり) によって紐づいています。
本アプリでは、「誰の子どもが・どのワクチンを・いつ接種したか」 という情報を正確に管理するため、テーブル同士を関連づける設計にしました。
3-2. 各テーブルの設計理由
カラムとは:データベースの表における「縦方向の項目(列)」のこと。Excelでいう列ヘッダーにあたります。
① children テーブル(子ども管理)
public function up(): void
{
Schema::create('children', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('nickname');
$table->date('birth_date');
$table->timestamps();
$table->softDeletes();
});
}
リレーションシップの定義
-
一対多(users → children):一人のユーザー(親)が複数の子どもを登録できます。
user_idで「誰の子どもか」を管理します。
users ── 1対多 ──→ children
カラムの選定理由
-
nickname:本名ではなく普段の呼び名で管理することで、親が愛着を持って使いやすい設計にしました。 -
birth_date(生年月日):接種推奨日を自動計算するための起点として、極めて重要な役割を担っています。 -
softDeletes():誤って削除ボタンを押してしまっても、データベースから完全には消さず論理削除にとどめることで、誤操作によるデータ消失リスクを回避する設計にしました。
② vaccines テーブル(ワクチン情報)
public function up(): void
{
Schema::create('vaccines', function (Blueprint $table) {
$table->id();
$table->string('name'); // ワクチン名
$table->enum('type', ['regular', 'optional']); // 定期・任意
$table->text('description')->nullable(); // 説明
$table->text('reason')->nullable(); // なぜ必要か
$table->integer('recommended_months'); // 推奨接種月齢
$table->timestamps();
});
}
リレーションシップの定義
-
多対多(children ↔ vaccines):一人の子どもは複数のワクチンを接種し、一つのワクチンは多くの子どもに接種されます。この多対多の関係は
vaccination_schedulesテーブルが中間テーブルとして解消しています。
children ── 多対多 ──→ vaccines(vaccination_schedules を中間テーブルとして使用)
カラムの選定理由
-
name:ワクチンの名前 -
type:定期接種か任意接種かを区別するための項目 -
description:ワクチンの説明 -
reason:なぜそのワクチンが必要かを保持する項目 -
recommended_months:推奨される接種月齢。birth_dateと組み合わせることで、接種推奨日を自動計算できます。
③ vaccination_schedules テーブル(接種スケジュール)
public function up(): void
{
Schema::create('vaccination_schedules', function (Blueprint $table) {
$table->id();
$table->foreignId('child_id')->constrained()->onDelete('cascade');
$table->foreignId('vaccine_id')->constrained()->onDelete('cascade');
$table->enum('status', ['pending', 'completed', 'scheduled'])->default('pending'); // 未接種・接種済・次回予定
$table->date('scheduled_date')->nullable(); // 次回予定日
$table->date('vaccinated_date')->nullable(); // 接種済み日
$table->text('memo')->nullable(); // メモ
$table->timestamps();
$table->softDeletes();
});
}
リレーションシップの定義
-
一対多(children → vaccination_schedules):一人の子どもが複数の接種スケジュールを持ちます。
child_idで「誰のスケジュールか」を管理します。 -
一対多(vaccines → vaccination_schedules):一つのワクチンが複数のスケジュールに紐づきます。
vaccine_idで「どのワクチンか」を管理します。 -
中間テーブルとしての役割:このテーブルは
childrenとvaccinesの多対多の関係を解消する中間テーブルでもあります。
children ── 1対多 ──→ vaccination_schedules ←── 1対多 ── vaccines
カラムの選定理由
-
child_id:どの「子」の予定かを示す外部キー -
vaccine_id:どの「ワクチン」の予定かを示す外部キー -
status:状態(未接種・接種済・次回予定)。デフォルトはpending(未接種) -
scheduled_date:次回の接種予定日 -
vaccinated_date:実際に接種した日 -
memo:接種時の様子などのメモ -
softDeletes():論理削除用。誤削除によるデータ消失を防ぎます
④ side_effects テーブル(副反応記録)
public function up(): void
{
Schema::create('side_effects', function (Blueprint $table) {
$table->id();
$table->foreignId('child_id')->constrained()->onDelete('cascade');
$table->foreignId('vaccination_schedule_id')->constrained()->onDelete('cascade');
$table->string('symptom'); // 症状
$table->date('start_date'); // 開始日
$table->date('end_date')->nullable(); // 終了日
$table->text('memo')->nullable(); // メモ
$table->timestamps();
});
}
リレーションシップの定義
-
一対多(children → side_effects):一人の子どもに複数の副反応記録が紐づきます。
child_idで「誰の症状か」を管理します。 -
一対多(vaccination_schedules → side_effects):一つの接種スケジュールに複数の副反応記録が紐づきます。
vaccination_schedule_idで「どの接種で起きた症状か」を管理します。
children ── 1対多 ──→ side_effects ←── 1対多 ── vaccination_schedules
カラムの選定理由
-
child_id:どの子どもに起きた症状かを示す外部キー -
vaccination_schedule_id:どの接種(予定・記録)に紐づく症状かを示す外部キー -
symptom:発熱・腫れなどの症状名 -
start_date:症状が出始めた日 -
end_date:症状が治まった日(nullableなので空でもOK) -
memo:詳しい経過などのメモ(nullableなので空でもOK)
⑤ medical_institutions テーブル(医療機関管理)
public function up(): void
{
Schema::create('medical_institutions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('name'); // 医療機関名
$table->string('address')->nullable(); // 住所
$table->string('phone')->nullable(); // 電話番号
$table->string('reception_hours')->nullable(); // 受付時間
$table->text('closed_days')->nullable(); // 休診日
$table->text('memo')->nullable(); // メモ
$table->timestamps();
});
}
リレーションシップの定義
-
一対多(users → medical_institutions):一人のユーザー(親)が複数の医療機関を登録できます。
user_idで「誰が登録した病院か」を管理します。
users ── 1対多 ──→ medical_institutions
カラムの選定理由
-
user_id:どのユーザー(親)が登録した病院かを示す外部キー -
name:病院の名前 -
address:病院の住所(nullableなので空でもOK) -
phone:病院の電話番号(nullableなので空でもOK) -
reception_hours:受付時間(nullableなので空でもOK) -
closed_days:休診日(nullableなので空でもOK) -
memo:駐車場情報などのメモ(nullableなので空でもOK)
⑥ appointments テーブル(予約管理)
public function up(): void
{
Schema::create('appointments', function (Blueprint $table) {
$table->id();
$table->foreignId('child_id')->constrained()->onDelete('cascade');
$table->foreignId('medical_institution_id')->constrained()->onDelete('cascade');
$table->foreignId('vaccination_schedule_id')->nullable()->constrained()->onDelete('set null');
$table->date('appointment_date'); // 予約日
$table->time('appointment_time')->nullable(); // 予約時間
$table->text('memo')->nullable(); // 予約メモ
$table->timestamps();
});
}
リレーションシップの定義
-
一対多(children → appointments):一人の子どもが複数の予約を持てます。
child_idで「誰の予約か」を管理します。 -
一対多(medical_institutions → appointments):一つの医療機関に複数の予約が紐づきます。
medical_institution_idで「どの病院への予約か」を管理します。 -
一対多(vaccination_schedules → appointments):接種スケジュールと予約を連動させます。
vaccination_schedule_idはnullableで、スケジュールが削除されても予約はset nullで残ります。
children ────────────── 1対多 ──→ appointments ←── 1対多 ── medical_institutions
vaccination_schedules ── 1対多 ──→ appointments(削除時は set null)
カラムの選定理由
-
child_id:どの子どもの予約かを示す外部キー -
medical_institution_id:どの病院への予約かを示す外部キー -
vaccination_schedule_id:どの接種予定(スケジュール)と連動しているか(nullable) -
appointment_date:予約日 -
appointment_time:予約時間(nullableなので空でもOK) -
memo:予約に関するメモ(nullableなので空でもOK)
vaccination_schedule_id に onDelete('set null') を設定しました。
もし接種予定(スケジュール)を間違えて削除してしまっても、病院の予約そのものは消さずに残しておくための設計です。cascade にしてしまうと予約ごと消えてしまうため、あえて set null を選択しました。
⑦ mother_child_photos テーブル(母子手帳写真)
public function up(): void
{
Schema::create('mother_child_photos', function (Blueprint $table) {
$table->id();
$table->foreignId('child_id')->constrained()->onDelete('cascade');
$table->string('file_path'); // ファイルパス
$table->string('title')->nullable(); // タイトル
$table->text('memo')->nullable(); // メモ
$table->timestamps();
});
}
リレーションシップの定義
-
一対多(children → mother_child_photos):一人の子どもが複数の写真を持てます。
child_idで「誰の写真か」を管理します。
children ── 1対多 ──→ mother_child_photos
カラムの選定理由
-
child_id:どの子どもの写真かを示す外部キー -
file_path:画像ファイルが保存されている場所(パス)。画像そのものをデータベースに保存するのではなく、ファイルの保存先パスだけを持たせることで、データベースの肥大化を防いでいます。 -
title:「1歳6ヶ月健診」「予防接種記録ページ」などのタイトル(nullableなので空でもOK) -
memo:写真に関する補足メモ(nullableなので空でもOK)
⑧ family_groups テーブル(家族グループ)/ ⑨ family_group_members テーブル(家族グループのメンバー)
この2つのテーブルはセットで機能します。family_groups がグループ本体、family_group_members がそのメンバー一覧を管理する中間テーブルです。
public function up(): void
{
Schema::create('family_groups', function (Blueprint $table) {
$table->id();
$table->foreignId('owner_id')->constrained('users')->onDelete('cascade');
$table->string('invite_code')->unique(); // 招待コード
$table->timestamps();
});
Schema::create('family_group_members', function (Blueprint $table) {
$table->id();
$table->foreignId('family_group_id')->constrained()->onDelete('cascade');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->timestamps();
$table->unique(['family_group_id', 'user_id']); // 同じユーザーの二重登録を防ぐ
});
}
リレーションシップの定義
-
一対一(users → family_groups):一人のユーザーが一つのグループを作成(オーナーとして管理)します。
owner_idで「誰が作ったグループか」を管理します。 -
多対多(users ↔ family_groups):一人のユーザーは複数のグループに参加でき、一つのグループには複数のユーザーが参加できます。この多対多の関係を解消するのが
family_group_members(中間テーブル)です。
users ── 1対1 ──→ family_groups(owner_id:グループ作成者)
users ── 多対多 ──→ family_groups(family_group_members を中間テーブルとして使用)
⑧ family_groups カラムの選定理由
-
owner_id:グループ作成者(管理者)のID。constrained('users')で明示的にusersテーブルを指定しています -
invite_code:他の家族を招待するための一意のコード。unique()を付けることで、招待コードの重複を防いでいます
⑨ family_group_members カラムの選定理由
-
family_group_id:所属先グループのID(外部キー) -
user_id:参加しているユーザーのID(外部キー) -
unique(['family_group_id', 'user_id']):同じユーザーが同じグループに二重登録されるのを防ぐための複合ユニーク制約です
family_group_members テーブルは、最初のマイグレーション時には複合ユニーク制約がありませんでした。後から add_unique_to_family_group_members.php という追加マイグレーションで制約を加えています。設計は一度で完璧にはならず、実装しながら改善していくものだと学びました。
おわりに
今回、自分が開発したアプリのテーブル設計を改めて振り返ることで、「なぜこの設計にしたのか」を理解することができました。
面接での悔しい経験があったからこそ、こうして深く学び直すことができました。
テーブル設計で特に大切だと感じたことは、この3つです。
- リレーションの意図を理解する:テーブル同士がなぜ繋がっているのかを説明できること
- カラムの選定理由を持つ:なんとなくではなく、「この情報が必要だから」という意図を持たせること
- 設計は一度で完璧にならない:実装しながら改善していくことも、設計の一部であること
次回は、面接で「テストコードを書きましたか?」と聞かれた際に、テストコード自体が分からず「たぶん書いていません」と答えてしまった経験について書く予定です。
その後、実際にテストケースを作成して実施したところ、さまざまな不具合を発見することができました。
その経験を通じて感じたテストの重要性についてまとめていきます。
最後まで読んでいただきありがとうございました🙏