🧱

学習日報アプリのDB設計を晒す:技術×ジャンル×ステータスで学習を記録する

に公開

⚙️ この記事は AI(ChatGPT)による自動生成および編集支援のもとで作成されています。
内容はできる限り正確に記述していますが、参考情報としてご覧ください。
使用ツール:ChatGPT(OpenAI / GPT-5 モデル)


個人開発で 学習日報アプリ を作る中で、最初にめちゃくちゃ悩んだのが DB 設計 でした。

  • 「メンバー(ユーザー)」
  • 「会社(テナント候補)」
  • 「技術(PHP / Laravel / React など)」
  • 「学習ジャンル」
  • 「学習ステータス(未着手 / 進行中 / 完了)」
  • 「日報(いつ・何を・どれくらい勉強したか)」

こういった情報を あとから集計しやすい形 で持ちたい。
この記事では、実際に学習日報アプリで採用したテーブル構成と、そのとき考えていたことをまとめます。


全体像:どんなテーブルを作ったか

ざっくり ER 図にすると、こんな関係になります(テキスト版)。

companies ---< members ---< study_reports ---< study_report_technologies >--- technologies --- technology_genres
                    ↑
                   roles (多対多でもOK)

登場する主なテーブルは以下です。

  • companies … 会社 / 組織(将来的なテナント候補)
  • members … ユーザー(研修生 / 講師など)
  • roles … 権限(trainee / teacher / admin)
  • technologies … 技術(PHP / Laravel / React など)
  • technology_genres … 技術ジャンル(言語 / フレームワーク / DB / インフラ…)
  • study_reports … 日報(いつ・何を・どれくらい勉強したか)
  • study_report_technologies … 日報と技術の中間テーブル
  • study_statuses … (任意)学習ステータスのマスタ

以降、1つずつ見ていきます。


1. メンバーと会社とロール

companies

CREATE TABLE companies (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  -- 将来的にサブドメインを切るなら
  subdomain VARCHAR(64) UNIQUE NULL,
  created_at TIMESTAMP NULL,
  updated_at TIMESTAMP NULL,
  deleted_at TIMESTAMP NULL
) ENGINE=InnoDB;

将来的に SaaS 化 / マルチテナント を見据えているので、
「会社」という概念を最初から分けておきました。

members

CREATE TABLE members (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  uuid CHAR(36) NOT NULL UNIQUE,
  company_id BIGINT UNSIGNED NOT NULL,
  name VARCHAR(255) NOT NULL,
  email VARCHAR(255) NOT NULL,
  email_hash CHAR(64) NOT NULL,
  password VARCHAR(255) NOT NULL,
  created_at TIMESTAMP NULL,
  updated_at TIMESTAMP NULL,
  deleted_at TIMESTAMP NULL,
  UNIQUE KEY members_email_company_unique (company_id, email_hash, deleted_at),
  CONSTRAINT members_company_id_foreign
    FOREIGN KEY (company_id) REFERENCES companies(id)
) ENGINE=InnoDB;

ポイントは、

  • uuid … フロントには UUID を渡して、ID を隠す
  • email_hash … 物理メールアドレスをそのままユニークキーに使わない
  • company_id + email_hash + deleted_at をユニーク制約にして、
    「同じ会社内でメールが被らない」 & SoftDelete 後に再登録可能、の両立を狙っています。

roles(ロール)

シンプルに trainee / teacher / admin などのロールを定義。

CREATE TABLE roles (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(64) NOT NULL UNIQUE,
  display_name VARCHAR(64) NOT NULL,
  created_at TIMESTAMP NULL,
  updated_at TIMESTAMP NULL
);

メンバーとの関係は、システム次第で:

  • 単一ロール → members.role_id
  • 複数ロール → 中間テーブル member_role を作る

今回は 「講師でもあり研修生でもある」 みたいなケースも想定したので中間テーブルにしています。


2. 技術マスタとジャンル

学習日報アプリの肝は「何を勉強したか」です。
ここを マスタテーブルに切り出す ことで、あとから

  • 技術別学習時間
  • ジャンル別(言語 / FW / インフラ)集計

などのグラフが作りやすくなります。

technology_genres(技術ジャンル)

CREATE TABLE technology_genres (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(64) NOT NULL UNIQUE,
  display_name VARCHAR(64) NOT NULL,
  sort_order INT UNSIGNED NOT NULL DEFAULT 100,
  created_at TIMESTAMP NULL,
  updated_at TIMESTAMP NULL
);

例:

  • backend, frontend, infra, database, language … など

technologies(技術)

CREATE TABLE technologies (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  genre_id BIGINT UNSIGNED NOT NULL,
  name VARCHAR(64) NOT NULL,
  display_name VARCHAR(64) NOT NULL,
  sort_order INT UNSIGNED NOT NULL DEFAULT 100,
  created_at TIMESTAMP NULL,
  updated_at TIMESTAMP NULL,
  UNIQUE KEY technologies_name_unique (name),
  CONSTRAINT technologies_genre_id_foreign
    FOREIGN KEY (genre_id) REFERENCES technology_genres(id)
) ENGINE=InnoDB;

例:

  • php, laravel, react, nextjs, mysql, aws

3. 日報テーブル(study_reports)

学習日報そのものを表すテーブルです。

CREATE TABLE study_reports (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  member_id BIGINT UNSIGNED NOT NULL,
  studied_on DATE NOT NULL,               -- 学習日
  title VARCHAR(255) NOT NULL,
  body TEXT NULL,
  total_minutes INT UNSIGNED NOT NULL,    -- 1日の合計学習時間(分)
  status VARCHAR(32) NOT NULL DEFAULT 'submitted', -- draft / submitted など
  created_at TIMESTAMP NULL,
  updated_at TIMESTAMP NULL,
  deleted_at TIMESTAMP NULL,
  CONSTRAINT study_reports_member_id_foreign
    FOREIGN KEY (member_id) REFERENCES members(id),
  UNIQUE KEY study_reports_member_date_unique (member_id, studied_on, deleted_at)
) ENGINE=InnoDB;

ここでこだわったのは、

  • member_id + studied_on にユニーク制約を張ることで
    「1 日 1 日報」ルール を DB レベルで保証すること
  • total_minutes をカラムに持っておくかどうか

total_minutes集計時に中間テーブルから SUM しても求まる のですが、

  • フロントで「今日 120 分勉強しました」と入力させる
  • 中間テーブルはより詳細な「技術ごとの配分」として使う

という役割分担にしたかったので、あえてカラムに持ちました。


4. 日報と技術の中間テーブル

日報 1 件に対して複数の技術を紐づけるために、中間テーブルを作ります。

CREATE TABLE study_report_technologies (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  study_report_id BIGINT UNSIGNED NOT NULL,
  technology_id BIGINT UNSIGNED NOT NULL,
  minutes INT UNSIGNED NOT NULL,      -- その技術に使った学習時間(分)
  status VARCHAR(32) NOT NULL,        -- 未着手 / 進行中 / 完了 など
  memo VARCHAR(255) NULL,             -- 任意のメモ
  created_at TIMESTAMP NULL,
  updated_at TIMESTAMP NULL,
  CONSTRAINT srt_report_id_foreign
    FOREIGN KEY (study_report_id) REFERENCES study_reports(id),
  CONSTRAINT srt_technology_id_foreign
    FOREIGN KEY (technology_id) REFERENCES technologies(id),
  INDEX srt_report_id_index (study_report_id),
  INDEX srt_technology_id_index (technology_id)
) ENGINE=InnoDB;

このテーブルを作ることで、例えばこんな集計が簡単になります。

  • 直近 7 日間の 技術別学習時間
  • 1 か月単位の ジャンル別学習割合
  • 学習ステータスごとの件数(status = 'in_progress' の数など)

**「グラフ API を作りやすい設計」**にしておくと、後続のフロント実装がかなり楽になります。


5. 学習ステータスはどう持つか問題

「未着手 / 進行中 / 完了」をどう表現するかは悩みポイントでした。

選択肢はざっくり 3 つです。

  1. VARCHAR カラムに文字列で持つ(今回採用)
  2. TINYINT などで数値コードを持つ(0,1,2)
  3. 別テーブル study_statuses を作って FK で紐づける

今回は「ステータスの種類が少なく、頻繁に変わらない」ことと、
レスポンスをわかりやすくしたかったので、文字列形式にしました。

status VARCHAR(32) NOT NULL -- 'not_started' / 'in_progress' / 'completed'

もし今後「ユーザーごとにステータスマスタをカスタムしたい」などの要件が出てきたら、
study_statuses テーブルを切るのが良さそうです。


6. マルチテナント(tenant_id)をどう考えたか

学習日報アプリは、将来的には 複数の会社が使う SaaS をイメージしています。

この場合、「どこに tenant_id(もしくは company_id)を入れるのか?」という問題が出てきます。

基本の方針は、

  • 「人」に紐づくものmembers.company_id を辿ればわかる
    study_reports / study_report_technologies には明示的な tenant_id を入れなくてもよい
  • 会社ごとに設定が変わるマスタ
    → 例:technologies をテナントごとにカスタムしたいなら technologies.company_id を持たせる

という感じで、

そのデータがどこまで共有されるか

を軸に、company_id を持たせるかどうかを分けると整理しやすかったです。


7. 失敗しかけたポイント

実際に作りながら「あ、これやめといてよかった」と思ったのは次のあたりです。

  • 日報テーブルに technology_id を直接持たせようとした
    → 「1 日に 3 つ技術を勉強した」みたいなケースを表現できず撃沈
  • 「学習時間は全部 text で free 記入にしよう」と一瞬考えた
    → 集計できなくて後悔しそうだったので数値(分)で持つ方向に統一
  • メールアドレスをそのままユニークキーにしようとした
    → 削除 / 復活 / 会社跨ぎを考えると email_hash + company_id に切り出した方が安心

まとめ

学習日報アプリの DB 設計では、

  • companies / members / roles で「誰がどの会社でどんな立場か」を表現
  • technologies / technology_genres で「何を学習したか」をマスタ化
  • study_reports / study_report_technologies で「いつ・何を・どれくらい学んだか」を記録
  • 将来のマルチテナントやグラフ表示を見据えて、中間テーブルと集計しやすいカラム構成にする

という方針に落ち着きました。

次の記事では、この DB を元に

  • 学習時間グラフ API
  • Next.js + Recharts でのフロント実装

あたりも書いていこうと思います 📈

Discussion