🏗️ アーキテクチャ
🛠️ 1. 技術スタック
| レイヤ | 採用技術 | 備考 |
|---|---|---|
| 言語 | TypeScript | |
| フレームワーク | Next.js (App Router) | React 19 / RSC / Server Actions 中心 |
| UI ライブラリ | HeroUI | Tailwind ベース、React Aria 製 |
| スタイリング | Tailwind CSS | |
| フォーム | React Hook Form | HeroUI の controlled コンポーネントと組む |
| バリデーション | Zod | server / client 両側で同一スキーマ |
| 認証 | better-auth | Email + Password のみ (Phase 1)、Open registration |
| ORM | Drizzle ORM | |
| DB | PostgreSQL | 本番: Neon (Serverless) / 開発: ローカル Docker |
| デプロイ | Vercel | |
| Linter / Formatter | Biome | |
| テスト | Vitest (Unit) + Playwright (E2E) | |
| パッケージマネージャ | pnpm | |
| CI | GitHub Actions | 型チェック + Biome + Vitest |
| レート制限 | Upstash Ratelimit | Vercel Middleware 経由 |
📱 1.1 PWA 方針
PWA 化はしない(純 Web として運用)。
🌐 1.2 ランタイム / Locale
- タイムゾーン:
Asia/Tokyo - UI 言語: 日本語のみ
📚 1.3 ライブラリの仕様確認方針
better-auth / HeroUI / Drizzle は更新が活発なため、実装着手前および主要バージョン更新時に context7 で最新仕様を取得して確認 すること。
🗄️ 2. データモデル(Phase 1)
🔗 2.1 ER 概要
better-auth 管理"]:::auth --> B["☕ Bean"]:::bean B --> Br["🫖 Brew"]:::brew Br --> P["💧 Pour"]:::pour classDef auth fill:#f0f6fc,stroke:#1e5a8e,stroke-width:2px,color:#1a2533 classDef bean fill:#eef3f8,stroke:#6b9bc7,color:#1a2533 classDef brew fill:#eef3f8,stroke:#6b9bc7,color:#1a2533 classDef pour fill:#f7f9fc,stroke:#c4d0dc,color:#1a2533
📊 2.2 テーブル定義
👤 user
better-auth が管理(user / session / account / verification 等)。
☕ bean
| カラム | 型 | 備考 |
|---|---|---|
| id | text PK | cuid2(24 文字、@paralleldrive/cuid2) |
| user_id | text FK user(id) | better-auth user.id は string |
| name | text | |
| roaster | text | |
| roast_level | text | Phase 1 はフリーテキスト、Phase 2 でマスタ FK 化 |
| roast_date | date | |
| origin | text | 同上 |
| variety | text | 同上 |
| process | text | 同上 |
| notes | text | |
| photo_url | text (nullable) | Phase 2 で使用、カラムは Phase 1 から確保 |
| archived | boolean default false | 飲み終わった豆 |
| created_at / updated_at / deleted_at | timestamp |
🫖 brew
| カラム | 型 | 備考 |
|---|---|---|
| id | text PK | cuid2(24 文字、@paralleldrive/cuid2) |
| user_id | text FK user(id) | クエリ全部スコープのため冗長保持 |
| bean_id | text FK bean(id) | |
| brewed_at | timestamp | |
| dose_g | numeric(5,1) | |
| total_water_g | numeric(6,1) | |
| water_temp_c | numeric(4,1) | |
| grind_setting | text | ミル名と目盛を自由記述 |
| dripper_name | text | |
| total_time_seconds | integer | |
| overall_rating | smallint (1-5) | |
| flavor_notes | text | 自由記述 |
| memo | text | |
| created_at / updated_at / deleted_at | timestamp |
💧 pour
| カラム | 型 | 備考 |
|---|---|---|
| id | text PK | cuid2(24 文字、@paralleldrive/cuid2) |
| brew_id | text FK brew(id) | ON DELETE CASCADE (Brew が soft delete されると論理的に消える) |
| sequence | smallint | 注ぎ順 (1, 2, 3, ...) |
| time_seconds | integer | 抽出開始からの経過秒 |
| water_g | numeric(6,1) | この注ぎで入れた湯量 |
| note | text (nullable) |
💡 Pour は Brew にネストされた値オブジェクトの位置付け。
(brew_id, sequence)で UNIQUE。
🏢 2.3 マルチテナント方針
- すべての主要テーブルに
user_idカラムを保持 - 全クエリで
WHERE user_id = ? AND deleted_at IS NULLを徹底 - DB 側 RLS は使わず、アプリ層で強制(Drizzle のクエリヘルパーで抽象化)
📁 3. ディレクトリ構造
🧩 3.1 設計方針: 軽量モジュラーモノリス
| 項目 | 採用 |
|---|---|
| アーキテクチャ | 軽量モジュラーモノリス(DDD レイヤード構造は採用しない) |
| 動機 | (a) Modern なモジュール設計の学習 + (b) Phase 2 への備え |
| モジュール | Bean / Brew / Auth の 3 つ(Pour は Brew にネストされた値オブジェクトとして同居) |
| モジュール内部構造 | フラット型(種類ごとに 1 ファイル)— Phase 1 規模で十分、肥大化時に分割 |
| public API | 各モジュールの index.ts (barrel) 経由のみ許可 |
| 境界強制 | Biome noRestrictedImports で機械的に強制(規約ではなくツール強制) |
🌲 3.2 全体ツリー
coffee-note-develop/
├── src/
│ ├── app/ # Next.js App Router(中間層: data fetching + 組み立て)
│ │ ├── (auth)/
│ │ │ ├── layout.tsx
│ │ │ ├── sign-in/page.tsx
│ │ │ └── sign-up/page.tsx
│ │ ├── (app)/
│ │ │ ├── layout.tsx # 認証ガード + ナビ
│ │ │ ├── page.tsx # / ホーム / ダッシュボード
│ │ │ ├── beans/
│ │ │ │ ├── page.tsx # /beans 豆一覧
│ │ │ │ ├── new/page.tsx # /beans/new 豆追加
│ │ │ │ └── [id]/
│ │ │ │ ├── page.tsx # /beans/[id] 豆詳細 + brew 履歴
│ │ │ │ └── edit/page.tsx # /beans/[id]/edit
│ │ │ └── brews/
│ │ │ ├── page.tsx # /brews 抽出一覧(絞り込み + ページング)
│ │ │ ├── new/page.tsx # /brews/new タイマー画面(コアフロー)
│ │ │ └── [id]/
│ │ │ ├── page.tsx # /brews/[id] 抽出詳細
│ │ │ └── edit/page.tsx # /brews/[id]/edit
│ │ ├── api/auth/[...all]/route.ts # better-auth ハンドラ(薄いラッパー)
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── loading.tsx
│ │ ├── error.tsx
│ │ └── not-found.tsx
│ ├── modules/
│ │ ├── auth/ # better-auth 設定 + サインイン/アップ UI
│ │ │ ├── index.ts # public API (barrel)
│ │ │ ├── schema.ts # better-auth CLI 生成テーブル
│ │ │ ├── config.ts # better-auth サーバー設定
│ │ │ ├── client.ts # better-auth クライアント
│ │ │ ├── actions.ts
│ │ │ ├── queries.ts
│ │ │ ├── components/
│ │ │ └── *.test.ts(x) # Unit テスト colocate
│ │ ├── bean/
│ │ │ ├── index.ts # public API
│ │ │ ├── schema.ts # bean テーブル定義
│ │ │ ├── validations.ts # Zod
│ │ │ ├── types.ts
│ │ │ ├── actions.ts # "use server"
│ │ │ ├── queries.ts # RSC からの読み取り('server-only')
│ │ │ ├── components/
│ │ │ │ ├── bean-list.tsx
│ │ │ │ ├── bean-form.tsx
│ │ │ │ ├── bean-card.tsx
│ │ │ │ └── bean-select.tsx # Brew 画面で使う Bean 選択 UI
│ │ │ └── *.test.ts(x)
│ │ └── brew/
│ │ ├── index.ts
│ │ ├── schema.ts # brew + pour 両テーブル
│ │ ├── validations.ts
│ │ ├── types.ts
│ │ ├── actions.ts
│ │ ├── queries.ts
│ │ ├── components/
│ │ │ ├── brew-list.tsx
│ │ │ ├── brew-form.tsx
│ │ │ ├── brew-timer.tsx
│ │ │ └── pour-timeline.tsx
│ │ └── *.test.ts(x)
│ └── shared/ # 横断基盤
│ ├── db/
│ │ ├── client.ts # Drizzle client (Neon adapter)
│ │ ├── schema.ts # 各モジュール schema を re-export して drizzle-kit に渡す
│ │ └── relations.ts # 横断 relations を一括定義(循環参照回避)
│ ├── ui/ # HeroUI ラッパー、共通 UI
│ │ ├── button.tsx
│ │ ├── form-field.tsx # React Hook Form + HeroUI controlled wrapper
│ │ ├── app-header.tsx
│ │ └── app-shell.tsx
│ ├── lib/ # 純粋関数 utility
│ │ ├── date.ts # Asia/Tokyo 変換
│ │ ├── format.ts
│ │ ├── ratelimit.ts # Upstash Ratelimit ラッパー
│ │ └── server-only.ts
│ └── config/
│ ├── env.ts # zod で検証する env loader
│ └── constants.ts
├── tests/e2e/ # Playwright(E2E のみ集約)
│ ├── fixtures/
│ └── *.spec.ts
├── drizzle/ # Drizzle migrations 出力先
├── public/ # 静的アセット(Next.js 仕様でルート固定)
├── docs/
│ ├── design.md # index
│ ├── requirements.md
│ ├── sitemap.md
│ ├── architecture.md
│ ├── conventions.md
│ └── operations.md
├── .github/workflows/ci.yml # 型チェック + Biome + Vitest
├── .editorconfig
├── .env.example # commit する(.env.local は gitignore)
├── .gitignore
├── .nvmrc # Node バージョン固定
├── biome.json # noRestrictedImports でモジュール境界強制
├── docker-compose.yml # 開発用 Postgres
├── drizzle.config.ts
├── next.config.ts
├── package.json
├── playwright.config.ts
├── pnpm-lock.yaml
├── README.md
├── tsconfig.json # paths: { "@/*": ["./src/*"] }
└── vitest.config.ts
🚦 3.3 モジュール間通信のルール
📦 public API (barrel)
各モジュールの index.ts が export するもの:
| 種類 | 例 | 用途 |
|---|---|---|
| Types | Bean, BeanInput, BeanView | 他モジュール・app/ で型として使う |
| Queries | getBeans, getBeanById, getBeansByIds | RSC から読み取り |
| Server Actions | createBean, updateBean, archiveBean | app/ の form action から呼ぶ |
| Components | BeanList, BeanForm, BeanCard, BeanSelect | app/・他モジュールで使用 |
| Validations | createBeanSchema, updateBeanSchema | フォーム resolver、Server Action 検証 |
index.ts に export しないもの(internal):
- Drizzle schema(テーブル定義は internal、他モジュールは relations 経由のみアクセス)
- 内部 utility / 内部コンポーネント
🔑 アクセスルール
| アクセス先 | 判定 |
|---|---|
| 自モジュール内テーブルへの SELECT/INSERT/UPDATE | OK |
| 他モジュールテーブルへの relations 経由の JOIN | OK 読み取りのみ |
| 他モジュールテーブルへの直接 INSERT/UPDATE/DELETE | NG 必ず public action 経由 |
| 他モジュールテーブル名の直接 import | NG relations が知っているのは Drizzle 内部のみ |
| 他モジュールの type / public query 関数 | OK barrel 経由 |
| 他モジュールの UI component | OK barrel 経由 |
shared/ → modules/ の逆依存 | NG |
➡️ 依存方向
独立"]:::indep Bean["☕ bean"]:::bean Brew["🫖 brew"]:::brew Brew -->|"✅ OK"| Bean Bean -.->|"❌ NG (循環禁止)"| Brew classDef indep fill:#f0f6fc,stroke:#1e5a8e,stroke-width:2px classDef bean fill:#eef3f8,stroke:#6b9bc7 classDef brew fill:#eef3f8,stroke:#6b9bc7 linkStyle 0 stroke:#2f7a4a,stroke-width:2px linkStyle 1 stroke:#b13838,stroke-width:1.5px,stroke-dasharray: 5 5
modules/brew→modules/bean: OK Brew は Bean に従属modules/bean→modules/brew: NG 一方向、循環禁止modules/auth: 他モジュールに依存しない
🎯 3.4 app/ ディレクトリの責務
-
中間層として運用
page.tsx で searchParams 受け取り → モジュールの query 呼び出し → モジュールの component 組み立て。
-
app/配下にローカル component を作らない_components/は不使用。- 再利用可能ならモジュールへ
- page 固有なら page.tsx 内に inline JSX
-
認証チェックは layout で集約
(app)/layout.tsxで better-auth の session 取得 → 未認証なら redirect。各 page で個別チェック不要。 - loading.tsx / error.tsx は Next.js 規約通り
app/配下に配置
🧪 3.5 テスト配置
| 種類 | 配置 | 命名 | 設定 |
|---|---|---|---|
| Unit (Vitest) | モジュール内 colocate | *.test.ts(x) | include: ['src/**/*.test.{ts,tsx}'] |
| E2E (Playwright) | tests/e2e/ に集約 | *.spec.ts | testDir: './tests/e2e' |
- DB を使うテストは 実 DB(docker-compose の Postgres)で実行、mocking は使わない
- E2E は Phase 1 では手動、Phase 2 で CI 統合(requirements.md §2.3)
- モジュール削除時に Unit テストも一緒に消える(colocate の利点)
🗂️ 3.6 Drizzle スキーマ管理
- 各モジュールが自身のテーブル定義を所有(
modules/*/schema.ts) shared/db/schema.tsで各モジュールの schema を re-export して drizzle-kit に渡す- 横断 relations は
shared/db/relations.tsに集約(循環参照を物理的に排除) - Migration 出力先はリポジトリルートの
drizzle/
🛡️ 3.7 モジュール境界の強制
Biome の noRestrictedImports で機械的に強制する。他モジュールへのアクセスは barrel(@/modules/<name> = index.ts)のみ許可、内部ファイルへの直接 import は CI でブロック。自モジュール内では barrel ではなく相対パス(./queries)を使うこと(循環参照リスク回避)。
🔌 3.8 Phase 2 への接続
- マスタ機能(origin / roast_level / variety / process、§5)は Bean に強く結合
-
Phase 2 着手時に配置粒度を判断
- (a)
modules/bean/master/サブディレクトリ(Bean に内包) - (b)
modules/master/独立モジュール(4 マスタを集約)
- (a)
- §5 の方針(Bean プロパティ化)を踏まえると (a) が有力候補
🔐 4. 認証ガード構成
実装の詳細(requireSession() ヘルパ等のコード)は conventions.md に集約。ここでは責務分担のみ。
| レイヤ | 責務 |
|---|---|
src/middleware.ts | /api/auth/:path* への Upstash Ratelimit のみ。matcher も同パスに限定 |
src/app/(app)/layout.tsx | auth.api.getSession() で session 本物検証 → 未認証は /sign-in へ redirect |
| Server Action | 冒頭で requireSession() を呼ぶ。直接 POST 経路の防御深度 |
設計上のキー判断:
-
middleware に session 検証は置かない
layout で十分、cookie 存在チェックの最適化メリットは Phase 1 規模では誤差。
-
レート制限の対象は
/api/auth/*のみBean / Brew の Server Action にはレート制限を付けない(Phase 1 単一ユーザー前提)。
🗺️ 5. Phase 2: マスタデータ化(origin / roast_level / variety / process)
💡 5.1 動機
入力時に選択肢として簡単に入力できるようにする(表記ゆれの抑制は副次効果)。
🧭 5.2 設計方針
-
ハイブリッド型マスタ
システムプリセット(共通)+ ユーザー追加(自分専用)。
-
未登録値
入力中に「新規追加してマスタ化」フローで吸収(HeroUI Autocomplete)。
-
ブレンド対応
各マスタに「ブレンド」値を 1 つ持つ。多対多は採用しない。
🏗️ 5.3 マスタテーブル設計(4 つ、すべて同形)
CREATE TABLE origin_master (
id TEXT PRIMARY KEY, -- cuid2
name TEXT NOT NULL,
name_ja TEXT,
display_order INT NOT NULL DEFAULT 0,
created_by_user_id TEXT REFERENCES "user"(id), -- NULL = システム共通
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
deleted_at TIMESTAMP,
UNIQUE (name, created_by_user_id)
);
-- roast_level_master, variety_master, process_master も同形
👁️ 5.4 表示条件
WHERE deleted_at IS NULL
AND (created_by_user_id IS NULL OR created_by_user_id = :current_user_id)
✏️ 5.5 Bean テーブル変更
ALTER TABLE bean
ADD COLUMN origin_id TEXT REFERENCES origin_master(id),
ADD COLUMN roast_level_id TEXT REFERENCES roast_level_master(id),
ADD COLUMN variety_id TEXT REFERENCES variety_master(id),
ADD COLUMN process_id TEXT REFERENCES process_master(id);
-- 既存 text カラムは移行完了後に DROP
🔄 5.6 マイグレーション手順
- マスタ 4 テーブル作成
- Seed 投入(
created_by_user_id = NULL、システム共通プリセット) - Bean に
*_idカラム追加(nullable) -
既存 Bean.{origin,roast_level,variety,process}_text の値を:
- マスタと name 完全一致 → 対応する
*_idに紐付け -
一致なし → 自分専用マスタとして INSERT
created_by_user_id = bean.user_idでマスタに INSERT し、ID 取得後 Bean に紐付け。
- マスタと name 完全一致 → 対応する
- 旧 text カラムを DROP
⌨️ 5.7 入力 UI(Phase 2)
- HeroUI Autocomplete で マスタからリスト表示 + 自由入力可能
- 自由入力時に「『XXX』をマスタに追加しますか?」を確認
- OK で自分専用マスタとして INSERT → そのまま Bean に紐付け
🌱 5.8 Seed プリセット(最低限の方針)
-
origin: 主要産地 20-30 件Ethiopia, Kenya, Colombia, Brazil, Guatemala, ... + 「ブレンド」
-
roast_level: Light 〜 Dark 系 + ブレンドLight / Medium-Light / Medium / Medium-Dark / Dark / シティ / フルシティ / フレンチ / ブレンド
-
variety: 主要品種 + ブレンド / 不明Typica / Bourbon / Caturra / Catuai / Gesha / SL28 / SL34 / Pacamara / ブレンド / 不明
-
process: 主要精製方法 + ブレンド / 不明Washed / Natural / Honey / Anaerobic / Wet-hulled / ブレンド / 不明