0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

実践データベース設計:TDDで育てる販売管理システム

Posted at

はじめに

「データベース設計」と聞くと、最初に完璧なER図を描かなければならない、と身構えてしまうかもしれません。しかし、実際の開発はもっと動的で、変化し続ける要求に追従していく必要があります。

この記事で解説する販売管理システム全体のユースケースは以下の通りです。

この記事では、テスト駆動開発(TDD)の原則をデータベース設計に応用し、小さな要求から始めて販売管理システムのデータベースを段階的に育てていく、極めて実践的なプロセスを追体験します。

なぜTDDでデータベース設計?

  • 要求が明確になる: 「テスト」という形で、その時点で必要なデータ要求を具体的に定義します。
  • 設計がシンプルに保たれる: 今必要なことだけを実装するため、過剰な設計を防ぎます。
  • 変更に強くなる: テストが既存の要求を守るため、安心してリファクタリング(設計改善)を行えます。

第0章:環境構築

はじめに

本章では、TDDでデータベース設計を進めるための開発環境を構築します。テスト駆動開発のゴールは 動作するきれいなコード ですが、それを実現するためには ソフトウェア開発の三種の神器 が必要です。

今日のソフトウェア開発の世界において絶対になければならない3つの技術的な柱があります。
三本柱と言ったり、三種の神器と言ったりしていますが、それらは

  • バージョン管理
  • テスティング
  • 自動化

の3つです。

https://t-wada.hatenablog.jp/entry/clean-code-that-works

0.1 前提条件

以下のツールがインストールされていることを確認してください。

  • Node.js (v18以上推奨)
  • npm (Node.jsに同梱)
  • Docker & Docker Compose(推奨)
  • Git

または

  • PostgreSQL (v14以上推奨) または MySQL (v8.0以上推奨)

0.2 プロジェクトの初期化

パッケージマネージャのセットアップ

npm を使ってプロジェクトを初期化します。

npmとは、Node.jsで記述されたサードパーティ製のライブラリを管理するためのツールで、npmで扱うライブラリをパッケージと呼びます。

— Node.js公式ドキュメント

$ mkdir sales-management-db
$ cd sales-management-db
$ npm init -y

package.json が作成されます。以下のように編集します。

{
  "name": "sales-management-db",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "lint": "eslint . --ext .ts,.tsx",
    "lint:fix": "eslint . --ext .ts,.tsx --fix",
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "gulp": "gulp",
    "watch": "gulp watch",
    "guard": "gulp guard",
    "check": "gulp checkAndFix",
    "setup": "npm install && npm run check"
  }
}

0.3 TypeScript環境のセットアップ

TypeScriptと必要なパッケージをインストールします。

$ npm install -D typescript @types/node
$ npx tsc --init

tsconfig.json を以下のように編集します。

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "lib": ["ES2020"],
    "moduleResolution": "node",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

0.4 Prismaのセットアップ

Prismaのインストール

Prismaは、TypeScript用のORM(Object-Relational Mapping)ツールです。データベーススキーマをコードとして定義し、型安全なデータベースアクセスを提供します。

$ npm install -D prisma
$ npm install @prisma/client
$ npx prisma init

実行すると、以下のファイルが作成されます。

  • prisma/schema.prisma: データベーススキーマ定義ファイル
  • .env: 環境変数ファイル
データベース接続の設定

.env ファイルを編集して、PostgreSQLの接続情報を設定します。

DATABASE_URL="postgresql://user:password@localhost:5432/sales_management?schema=public"
Prisma Clientの生成

スキーマからPrisma Clientを生成します。

$ npx prisma generate

0.5 Docker Composeのセットアップ(推奨)

Docker を使用すると、データベース環境を簡単にセットアップできます。PostgreSQL と MySQL の両方をサポートしています。

Docker Compose ファイルの作成

docker-compose.yml を作成します。

services:
  # PostgreSQL データベース
  postgres:
    image: postgres:16-alpine
    container_name: sales-management-postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-postgres}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
      POSTGRES_DB: ${POSTGRES_DB:-sales_management}
      TZ: 'Asia/Tokyo'
    ports:
      - "${POSTGRES_PORT:-5432}:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./docker/postgres/init:/docker-entrypoint-initdb.d
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - db_network

  # MySQL データベース
  mysql:
    image: mysql:8.0
    container_name: sales-management-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root}
      MYSQL_DATABASE: ${MYSQL_DATABASE:-sales_management}
      MYSQL_USER: ${MYSQL_USER:-user}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD:-password}
      TZ: 'Asia/Tokyo'
    command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    ports:
      - "${MYSQL_PORT:-3306}:3306"
    volumes:
      - mysql_data:/var/lib/mysql
      - ./docker/mysql/init:/docker-entrypoint-initdb.d
      - ./docker/mysql/conf.d:/etc/mysql/conf.d
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-root}"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - db_network

  # Adminer - データベース管理ツール
  adminer:
    image: adminer:latest
    container_name: sales-management-adminer
    restart: unless-stopped
    ports:
      - "${ADMINER_PORT:-8080}:8080"
    environment:
      ADMINER_DEFAULT_SERVER: ${DB_TYPE:-postgres}
      ADMINER_DESIGN: 'nette'
    networks:
      - db_network
    depends_on:
      - postgres
      - mysql

volumes:
  postgres_data:
    driver: local
  mysql_data:
    driver: local

networks:
  db_network:
    driver: bridge
環境変数ファイルの作成

.env.example を作成します。

# データベース選択(postgres または mysql)
DB_TYPE=postgres

# PostgreSQL 設定
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=sales_management
POSTGRES_PORT=5432

# MySQL 設定
MYSQL_ROOT_PASSWORD=root
MYSQL_DATABASE=sales_management
MYSQL_USER=user
MYSQL_PASSWORD=password
MYSQL_PORT=3306

# Adminer ポート
ADMINER_PORT=8080

# Prisma データベース接続 URL
# PostgreSQL を使用する場合
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:${POSTGRES_PORT}/${POSTGRES_DB}?schema=public"

# MySQL を使用する場合(PostgreSQL の行をコメントアウトして、こちらを有効にする)
# DATABASE_URL="mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@localhost:${MYSQL_PORT}/${MYSQL_DATABASE}"

.env ファイルを作成します(.env.example をコピー)。

$ cp .env.example .env
Docker 初期化スクリプトの作成

PostgreSQL 初期化スクリプト (docker/postgres/init/01-init.sql):

-- PostgreSQL 初期化スクリプト
-- データベースの初期設定を行う

-- タイムゾーンの設定
SET timezone = 'Asia/Tokyo';

-- ログ出力
DO $$
BEGIN
    RAISE NOTICE 'PostgreSQL database initialized successfully';
END $$;

MySQL 初期化スクリプト (docker/mysql/init/01-init.sql):

-- MySQL 初期化スクリプト
-- データベースの初期設定を行う

-- 文字コードの確認
SELECT @@character_set_database, @@collation_database;

-- タイムゾーンの設定
SET time_zone = '+09:00';

-- ログ出力
SELECT 'MySQL database initialized successfully' AS message;

MySQL 設定ファイル (docker/mysql/conf.d/my.cnf):

[mysqld]
# 文字コード設定
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci

# タイムゾーン
default-time-zone='+09:00'

# 一般的な設定
max_connections=200
max_allowed_packet=64M

[client]
default-character-set=utf8mb4
データベースコンテナの起動
# PostgreSQL を起動
$ docker-compose up -d postgres

# または MySQL を起動
$ docker-compose up -d mysql

# すべてのサービスを起動(PostgreSQL + MySQL + Adminer)
$ docker-compose up -d
データベースの確認

Adminer(データベース管理ツール)にアクセス:

  • URL: http://localhost:8080
  • PostgreSQL の場合:
    • システム: PostgreSQL
    • サーバ: postgres
    • ユーザ名: postgres
    • パスワード: postgres
    • データベース: sales_management
  • MySQL の場合:
    • システム: MySQL
    • サーバ: mysql
    • ユーザ名: user
    • パスワード: password
    • データベース: sales_management
Docker Compose の便利なコマンド
# コンテナの停止
$ docker-compose stop

# コンテナの停止と削除
$ docker-compose down

# コンテナの停止、削除、ボリュームも削除
$ docker-compose down -v

# ログの確認
$ docker-compose logs -f postgres
$ docker-compose logs -f mysql

# コンテナの状態確認
$ docker-compose ps

# コンテナに接続
$ docker-compose exec postgres psql -U postgres -d sales_management
$ docker-compose exec mysql mysql -u user -p sales_management

0.6 Zod によるスキーマ検証

Prisma スキーマから自動的に Zod バリデーションスキーマを生成するための設定を行います。

Zod と Prisma Zod Generator のインストール
$ npm install zod zod-prisma-types
  • zod: TypeScript 用のスキーマバリデーションライブラリ
  • zod-prisma-types: Prisma スキーマから Zod スキーマを自動生成するジェネレーター
Prisma スキーマへの Zod Generator 追加

prisma/schema.prisma に Zod ジェネレーター設定を追加します。

generator client {
  provider = "prisma-client-js"
}

generator zod {
  provider                         = "zod-prisma-types"
  output                           = "../src/generated/zod"
  createRelationValuesTypes        = true
  createPartialTypes               = true
  createOptionalDefaultValuesTypes = true
  useDecimalJs                     = true
  prismaJsonNullability            = true
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
.gitignore の更新

生成されたファイルをバージョン管理から除外します。

.gitignore に以下を追加:

# Generated files
src/generated/
Zod スキーマの使用例

src/zod-example.test.ts を作成して、Zod の基本的な使い方を確認します。

import { describe, test, expect } from 'vitest'
import { z } from 'zod'

describe('Zod バリデーション例', () => {
  test('基本的なスキーマ定義とバリデーション', () => {
    // スキーマ定義
    const UserSchema = z.object({
      id: z.number().int().positive(),
      name: z.string().min(1).max(100),
      email: z.string().email(),
      age: z.number().int().min(0).max(150).optional(),
    })

    // 正常なデータ
    const validData = {
      id: 1,
      name: 'John Doe',
      email: 'john@example.com',
      age: 30,
    }

    const result = UserSchema.safeParse(validData)
    expect(result.success).toBe(true)
    if (result.success) {
      expect(result.data).toEqual(validData)
    }
  })

  test('バリデーションエラーの検出', () => {
    const UserSchema = z.object({
      id: z.number().int().positive(),
      name: z.string().min(1),
      email: z.string().email(),
    })

    // 不正なデータ(emailが不正)
    const invalidData = {
      id: 1,
      name: 'John Doe',
      email: 'invalid-email',
    }

    const result = UserSchema.safeParse(invalidData)
    expect(result.success).toBe(false)
  })
})
生成される Zod スキーマの種類

Prisma のモデルを定義すると、以下のような Zod スキーマが自動生成されます:

  • 基本スキーマ: UserSchema
  • Partial スキーマ: UserPartialSchema(すべてのフィールドがオプショナル)
  • Optional デフォルト値スキーマ: UserOptionalDefaultsSchema
  • リレーション値スキーマ: UserWithRelationsSchema

これらのスキーマは、API のリクエストボディのバリデーションや、外部データの型チェックに活用できます。

Note: Prisma スキーマにモデルを定義していない場合、Zod スキーマは生成されません。第1章以降でモデルを追加した際に、npx prisma generate コマンドで自動生成されます。

0.7 SchemaSpy によるスキーマ可視化

SchemaSpy は、データベーススキーマを解析して ER 図やテーブル定義を HTML で可視化するツールです。日本語のテーブル名・カラム名にも対応させることで、データベース設計の理解を深めることができます。

Docker Compose への SchemaSpy サービス追加

docker-compose.yml に SchemaSpy とその結果を表示する nginx サービスを追加します。

  # SchemaSpy - データベーススキーマ可視化ツール
  schemaspy:
    build:
      context: ./docker/schemaspy
      dockerfile: Dockerfile
    container_name: sales-management-schemaspy
    volumes:
      - ./schemaspy-output:/output
    environment:
      - LANG=ja_JP.UTF-8
      - LC_ALL=ja_JP.UTF-8
    command: >
      -t pgsql
      -host postgres
      -port 5432
      -db ${POSTGRES_DB:-sales_management}
      -u ${POSTGRES_USER:-postgres}
      -p ${POSTGRES_PASSWORD:-postgres}
      -s public
      -charset UTF-8
      -connprops useUnicode\\=true;characterEncoding\\=UTF-8
    networks:
      - db_network
    depends_on:
      postgres:
        condition: service_healthy

  # Nginx - SchemaSpy の結果を表示
  schemaspy-viewer:
    image: nginx:alpine
    container_name: sales-management-schemaspy-viewer
    restart: unless-stopped
    ports:
      - "${SCHEMASPY_PORT:-8081}:80"
    volumes:
      - ./schemaspy-output:/usr/share/nginx/html:ro
    networks:
      - db_network
    depends_on:
      - schemaspy
日本語フォント対応 Dockerfile の作成

docker/schemaspy/Dockerfile を作成します。

FROM schemaspy/schemaspy:latest

# Install Japanese fonts for Graphviz
USER root
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    fonts-noto-cjk \
    fonts-noto-cjk-extra \
    fontconfig && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

# Update font cache
RUN fc-cache -f -v

# Set locale
ENV LANG=ja_JP.UTF-8 \
    LC_ALL=ja_JP.UTF-8

USER java
.gitignore の更新

生成された SchemaSpy の出力をバージョン管理から除外します。

.gitignore に以下を追加:

# SchemaSpy
schemaspy-output/
SchemaSpy の実行
# スキーマドキュメントを生成
$ docker-compose run --rm schemaspy

# 生成されたドキュメントを表示
$ docker-compose up -d schemaspy-viewer
SchemaSpy Viewer へのアクセス

ブラウザで以下の URL にアクセスします:

以下の内容を確認できます:

  • ER 図: テーブル間のリレーションシップを可視化
  • テーブル定義: カラム、制約、インデックスの詳細
  • 外部キー関係: テーブル間の依存関係
  • 異常検出: 孤立テーブルなどの設計上の問題

日本語のテーブル名(「部門マスタ」「社員マスタ」)やカラム名(「部門コード」「社員名」)が正しく表示されることを確認してください。

Note: SchemaSpy は PostgreSQL のスキーマを解析します。第1章以降でテーブルを追加した際に、docker-compose run --rm schemaspy コマンドで再生成してください。

0.8 テスト環境のセットアップ

Vitestのインストール

VitestはVite上で動作する高速なテストフレームワークです。

$ npm install -D vitest @vitest/coverage-v8
テスト設定ファイルの作成

vitest.config.ts を作成します。

import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'json'],
      reportsDirectory: 'coverage',
      exclude: [
        'dist/**',
        'node_modules/**',
        '**/*.test.ts',
        '**/*.config.js',
        '**/*.config.ts',
        'prisma/**'
      ],
      all: true
    }
  }
})
最初のテスト

src/app.test.ts を作成して、Prismaの動作確認を行います。

import { describe, test, expect, beforeAll, afterAll } from 'vitest'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

describe('Prisma接続テスト', () => {
  beforeAll(async () => {
    // データベース接続の確認
  })

  afterAll(async () => {
    await prisma.$disconnect()
  })

  test('データベースに接続できる', async () => {
    // 接続確認用の簡単なクエリ
    const result = await prisma.$queryRaw`SELECT 1 as result`
    expect(result).toBeDefined()
  })
})

テストを実行します。

$ npm run test
テストデータベース分離セットアップ

開発を進めていく中で、テスト実行のたびに本番データが削除されてしまう問題が発生します。これを防ぐため、テスト用データベースと本番用データベースを分離する必要があります。

1. 環境変数の設定

.env ファイルに TEST_DATABASE_URL を追加します。

# Prisma データベース接続 URL
# PostgreSQL を使用する場合
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/sales_management?schema=public"

# テスト用データベース接続 URL
TEST_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/sales_management_test?schema=public"

# MySQL を使用する場合(PostgreSQL の行をコメントアウトして、こちらを有効にする)
# DATABASE_URL="mysql://user:password@localhost:3306/sales_management"
# TEST_DATABASE_URL="mysql://user:password@localhost:3306/sales_management_test"
2. 必要なパッケージのインストール
$ npm install dotenv
$ npm install -D cross-env
  • dotenv: .env ファイルから環境変数を読み込むライブラリ
  • cross-env: クロスプラットフォーム対応の環境変数設定ツール
3. vitest.config.ts の更新

テスト実行時に TEST_DATABASE_URLVITEST 環境変数を設定します。

/* eslint-disable no-undef */
import { defineConfig } from 'vitest/config'
import { config } from 'dotenv'

// .env ファイルから環境変数を読み込む
config()

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    // テスト実行時に TEST_DATABASE_URL と VITEST を環境変数に設定
    env: {
      VITEST: 'true',
      TEST_DATABASE_URL:
        process.env.TEST_DATABASE_URL ||
        'postgresql://postgres:postgres@localhost:5432/sales_management_test?schema=public'
    },
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'json'],
      reportsDirectory: 'coverage',
      exclude: [
        'dist/**',
        'node_modules/**',
        '**/*.test.ts',
        '**/*.config.js',
        '**/*.config.ts',
        'prisma/**'
      ],
      all: true
    }
  }
})
4. Prisma Client の修正

テスト実行時に自動的にテスト用DBを使用するように、Prisma Client のインスタンス化を修正します。

例えば、src/api/lib/prisma.ts を以下のように実装します。

// src/api/lib/prisma.ts
/* eslint-disable no-undef, no-console */
import { PrismaClient } from '@prisma/client'

/**
 * テスト実行時(VITEST=true)は TEST_DATABASE_URL を使用し、
 * 通常実行時は DATABASE_URL を使用する
 */
const isTest = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test'
const databaseUrl = isTest ? process.env.TEST_DATABASE_URL : process.env.DATABASE_URL

// デバッグ用ログ(必要に応じて有効化)
if (process.env.DEBUG_PRISMA === 'true') {
  console.log('[Prisma] VITEST:', process.env.VITEST)
  console.log('[Prisma] NODE_ENV:', process.env.NODE_ENV)
  console.log('[Prisma] isTest:', isTest)
  console.log('[Prisma] Using DATABASE_URL:', databaseUrl)
}

export const prisma = new PrismaClient({
  datasources: {
    db: {
      url: databaseUrl
    }
  }
})

テストファイルでも同様に修正します。

/* eslint-disable no-undef */
import { describe, test, expect, beforeAll, afterAll } from 'vitest'
import { PrismaClient } from '@prisma/client'

/**
 * テスト実行時は TEST_DATABASE_URL を使用する
 */
const isTest = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test'
const databaseUrl = isTest ? process.env.TEST_DATABASE_URL : process.env.DATABASE_URL

const prisma = new PrismaClient({
  datasources: {
    db: {
      url: databaseUrl
    }
  }
})

// ... テストコード
5. テスト用データベースのマイグレーション

package.json にテスト用DBのマイグレーションスクリプトを追加します。

{
  "scripts": {
    "prisma:migrate": "prisma migrate dev",
    "prisma:migrate:test": "cross-env DATABASE_URL=\"postgresql://postgres:postgres@localhost:5432/sales_management_test?schema=public\" prisma migrate deploy",
    "prisma:generate": "prisma generate",
    // ... 他のスクリプト
  }
}

テスト用データベースにマイグレーションを実行します。

$ npm run prisma:migrate:test
6. 動作確認

テスト用DBと本番DBが正しく分離されているか確認します。

# 本番DBにシードデータを投入
$ npm run seed

# テストを実行(テスト用DBを使用)
$ npm test

# 本番DBのデータが保持されていることを確認
# (必要に応じて Adminer や psql で確認)

これで、テスト実行時は sales_management_test データベースを使用し、通常の開発では sales_management データベースを使用するようになります。テストのたびに本番データが削除される問題が解決されました。

0.9 静的コード解析の設定

ESLintのインストール

良いコードを書き続けるためにはコードの品質を維持していく必要があります。

静的コード解析とは、プログラムを実行することなく、ソースコードを解析してバグや脆弱性、コーディング規約違反などを検出する手法です。

— Wikipedia

$ npm install -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin \
  eslint-config-prettier eslint-plugin-prettier eslint-plugin-sonarjs
ESLint設定ファイルの作成

eslint.config.js を作成します。

import js from '@eslint/js'
import typescript from '@typescript-eslint/eslint-plugin'
import typescriptParser from '@typescript-eslint/parser'
import prettier from 'eslint-plugin-prettier'
import prettierConfig from 'eslint-config-prettier'
import sonarjs from 'eslint-plugin-sonarjs'

export default [
  js.configs.recommended,
  {
    files: ['**/*.{ts,tsx}'],
    languageOptions: {
      parser: typescriptParser,
      parserOptions: {
        ecmaVersion: 'latest',
        sourceType: 'module'
      }
    },
    plugins: {
      '@typescript-eslint': typescript,
      prettier,
      sonarjs
    },
    rules: {
      ...typescript.configs.recommended.rules,
      ...prettierConfig.rules,
      'prettier/prettier': 'error',
      // 循環的複雑度の制限 - 7を超える場合はエラー
      'complexity': ['error', { max: 7 }],
      // 認知的複雑度の制限 - 4を超える場合はエラー
      'sonarjs/cognitive-complexity': ['error', 4],
      // その他の推奨ルール
      'no-console': ['warn', { allow: ['warn', 'error'] }],
      'no-debugger': 'error',
      'no-var': 'error',
      'prefer-const': 'error'
    }
  },
  {
    files: ['**/*.test.{ts,tsx}'],
    rules: {
      '@typescript-eslint/no-unused-expressions': 'off',
      '@typescript-eslint/no-unused-vars': 'warn',
      'no-console': 'off'
    }
  },
  {
    ignores: ['dist/**', 'node_modules/**', 'coverage/**', '*.config.js', 'prisma/**']
  }
]
コード複雑度のチェック

設定には 循環的複雑度認知的複雑度 の制限が含まれています。

循環的複雑度 (Cyclomatic Complexity)

循環的複雑度とは、ソフトウェア測定法の一つであり、コードがどれぐらい複雑であるかをメソッド単位で数値にして表す指標。

複雑度の範囲 意味
1~10 低複雑度:管理しやすく、問題なし。
11~20 中程度の複雑度:リファクタリングを検討。
21~50 高複雑度:リファクタリングが強く推奨される。
51以上 非常に高い複雑度:コードを分割する必要がある。

認知的複雑度 (Cognitive Complexity)

認知的複雑度は、プログラムを読む人の認知負荷を測るための指標のこと。コードの構造が「どれだけ頭を使う必要があるか」を定量的に評価する。

複雑度の範囲 意味
0~4 理解が非常に容易:リファクタリング不要。
5~14 中程度の難易度:改善が必要な場合もある。
15以上 理解が困難:コードの簡素化を検討するべき。

0.10 コードフォーマッタの設定

Prettierのインストール

良いコードであるためにはフォーマットも大切な要素です。

優れたソースコードは「目に優しい」ものでなければいけない。

— リーダブルコード

$ npm install -D prettier
Prettier設定ファイルの作成

.prettierrc を作成します。

{
  "semi": false,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "none",
  "printWidth": 100,
  "arrowParens": "always"
}

0.11 タスクランナーの設定

Gulpのインストール

GulpはJavaScript/TypeScriptにおけるタスクランナーです。gulpコマンドと起点となるgulpfile.jsというタスクを記述するファイルを用意することで、タスクの実行や登録されたタスクの一覧表示を行えます。

— Gulp公式ドキュメント

$ npm install -D gulp gulp-shell
Gulpfile.jsの作成

gulpfile.js を作成します。

import { watch, series } from 'gulp'
import shell from 'gulp-shell'

// テストタスク
export const test = shell.task(['npm run test'])

// テストカバレッジタスク
export const coverage = shell.task(['npm run test:coverage'])

// 静的コード解析タスク
export const lint = shell.task(['npm run lint'])

// 自動修正付き静的コード解析タスク
export const lintFix = shell.task(['npm run lint:fix'])

// フォーマットタスク
export const format = shell.task(['npm run format'])

// フォーマットチェックタスク
export const formatCheck = shell.task(['npm run format:check'])

// Prismaマイグレーション
export const migrate = shell.task(['npx prisma migrate dev'])

// Prisma Client生成
export const generate = shell.task(['npx prisma generate'])

// Prisma Studio起動
export const studio = shell.task(['npx prisma studio'])

// 全体チェックタスク(自動修正付き)
export const checkAndFix = series(lintFix, format, test)

// ファイル監視タスク(自動化)
export function guard() {
  console.log('🔍 Guard is watching for file changes...')
  console.log('Files will be automatically linted, formatted, and tested on change.')
  watch('src/**/*.ts', series(lintFix, format, test))
  watch('**/*.test.ts', series(test))
  watch('prisma/schema.prisma', series(generate))
}

// デフォルトタスク
export default series(checkAndFix, guard)

0.12 Gitセットアップ

.gitignoreの作成
# Dependencies
node_modules/

# Build
dist/

# Environment
.env
.env.local

# Test coverage
coverage/

# IDE
.vscode/
.idea/

# OS
.DS_Store
Thumbs.db

# Prisma
prisma/migrations/
初期コミット
$ git init
$ git add .
$ git commit -m 'chore: 初期プロジェクトセットアップ'
コミットメッセージ規約

この書式は Conventional Commits に従っています。

コミットのタイプは次を用いて下さい。

  • feat: A new feature (新しい機能)
  • fix: A bug fix (バグ修正)
  • docs: Documentation only changes (ドキュメント変更のみ)
  • style: Changes that do not affect the meaning of the code (コードに影響を与えない変更)
  • refactor: A code change that neither fixes a bug nor adds a feature (機能追加でもバグ修正でもないコード変更)
  • perf: A code change that improves performance (パフォーマンスを改善するコード変更)
  • test: Adding missing or correcting existing tests (存在しないテストの追加、または既存のテストの修正)
  • chore: Changes to the build process or auxiliary tools and libraries (ビルドプロセスの変更)

0.13 セットアップの確認

すべてのセットアップが完了したら、以下のコマンドで動作確認を行います。

Docker を使用する場合
# 依存パッケージのインストール
$ npm install

# データベースコンテナの起動
$ docker-compose up -d postgres

# コンテナの状態確認
$ docker-compose ps

# 静的コード解析
$ npm run lint

# フォーマットチェック
$ npm run format:check

# テスト実行(データベース起動後)
$ npm run test

# すべてのチェック(自動修正付き)
$ npm run check

# 自動監視モード
$ npm run guard
ローカルデータベースを使用する場合
# 依存パッケージのインストール
$ npm install

# 静的コード解析
$ npm run lint

# フォーマットチェック
$ npm run format:check

# テスト実行
$ npm run test

# すべてのチェック(自動修正付き)
$ npm run check

# 自動監視モード
$ npm run guard

これで TDD によるデータベース設計を始める準備が整いました。次章から実際のデータベース設計に入ります。


マスタ管理

第1章:最初の要求「部門と従業員」

すべてのシステムは、ごく単純な要求から始まります。私たちの最初の要求は「従業員を部門に所属させたい」でした。この章では、この要求を基に、テスト駆動で部門社員のテーブルを設計していく過程を追体験します。

レッド:要求をテストコードで表現する

TDDの最初のステップは「失敗するテストを書く」ことです。データベース設計において、これは「どのようなデータを、どのように扱いたいか」を具体的なコードで示すことに相当します。

まず部門マスタ社員マスタに対するCRUD(作成、読み取り、更新、削除)操作のテストが定義します。

要求1:部門を管理したい

まず、「部門」という概念を管理するためのテストです。

// src/app.test.ts

// テスト用の部門データ
const departments: Department[] = [
  {
    deptCode: "11101",
    startDate: new Date("2021-01-01"),
    endDate: new Date("2100-12-31"), // endDateは未来の日付
    name: "新規部署",
    layer: 1,
    psth: "D0001",
    lowestType: 1,
    slitYn: 0,
    createDate: new Date("2021-01-01"),
    creator: "admin",
    updateDate: new Date("2021-01-01"),
    updater: "admin",
  },
];

describe("部門マスタ", () => {
  // 各テストの前に、テーブルをクリーンな状態にする
  beforeAll(async () => {
    await prisma.department.deleteMany();
  });

  test("部門を登録できる", async () => {
    // 1. テストデータを作成
    await prisma.department.create({ data: departments[0] });
    // 2. 取得したデータが期待通りか検証
    const result = await prisma.department.findMany();
    expect(result).toEqual(departments);
  });

  test("部門を更新できる", async () => {
    // 1. データを「更新部署」という名前に更新
    const expected = { ...departments[0], name: "更新部署" };
    await prisma.department.update({
      where: { deptCode_startDate: { deptCode: departments[0].deptCode, startDate: departments[0].startDate } },
      data: { name: "更新部署" },
    });
    // 2. 更新されたか検証
    const result = await prisma.department.findUnique({ where: { deptCode_startDate: { deptCode: departments[0].deptCode, startDate: departments[0].startDate } } });
    expect(result).toEqual(expected);
  });

  test("部門を削除できる", async () => {
    // 1. データを削除
    await prisma.department.delete({ where: { deptCode_startDate: { deptCode: departments[0].deptCode, startDate: departments[0].startDate } } });
    // 2. テーブルが空になったか検証
    const result = await prisma.department.findMany();
    expect(result).toEqual([]);
  });
});

このコードは、Departmentというモデルが存在し、指定したデータ構造でCRUD操作ができることを要求しています。当然、まだモデルがないのでこのテストは失敗します(レッド)。

グリーン:要求を満たす最小限のスキーマを定義する

次に、このテストをパスさせるための最小限のスキーマをprisma/schema.prismaに定義します。

// prisma/schema.prisma

model Department {
  deptCode   String    @map("部門コード") @db.VarChar(6)
  startDate  DateTime  @default(dbgenerated("CURRENT_DATE")) @map("開始日") @db.Timestamp(6)
  endDate    DateTime? @default(dbgenerated("'2100-12-31 00:00:00'::timestamp without time zone")) @map("終了日") @db.Timestamp(6)
  name       String?   @map("部門名") @db.VarChar(40)
  layer      Int       @default(0) @map("組織階層")
  psth       String    @map("部門パス") @db.VarChar(100)
  lowestType Int       @default(0) @map("最下層区分")
  slitYn     Int       @default(1) @map("伝票入力可否")
  createDate DateTime  @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator    String?   @map("作成者名") @db.VarChar(12)
  updateDate DateTime  @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater    String?   @map("更新者名") @db.VarChar(12)

  // 履歴管理のため、部門コードと適用開始日で複合主キーとする
  @@id([deptCode, startDate], map: "pk_department")
  @@map("部門マスタ")
}

このスキーマを定義し、マイグレーションを実行するとテーブルが作成され、先ほどのテストがパスします(グリーン)。

要求2:社員を部門に所属させたい

同様に、社員を管理するためのテストとスキーマを定義します。

// src/app.test.ts
const employees: Employee[] = [
  {
    empCode: "EMP999",
    name: "伊藤 裕子",
    kana: "イトウ ユウコ",
    loginPassword: "password",
    tel: "090-1234-5678",
    fax: "03-1234-5678",
    deptCode: "11101", // 所属部門のコード
    startDate: new Date("2021-01-01"),
    occuCode: "",
    approvalCode: "",
    createDate: new Date("2021-01-01"),
    creator: "admin",
    updateDate: new Date("2021-01-01"),
    updater: "admin",
  },
];
// ... 社員マスタのCRUDテストも同様に記述 ...
// prisma/schema.prisma
model Employee {
  empCode       String   @id(map: "pk_employee") @map("社員コード") @db.VarChar(10)
  name          String?  @map("社員名") @db.VarChar(20)
  kana          String?  @map("社員名カナ") @db.VarChar(40)
  loginPassword String?  @map("パスワード") @db.VarChar(8)
  tel           String?  @map("電話番号") @db.VarChar(13)
  fax           String?  @map("FAX番号") @db.VarChar(13)
  deptCode      String   @map("部門コード") @db.VarChar(6) // 外部キー
  startDate     DateTime @default(dbgenerated("CURRENT_DATE")) @map("開始日") @db.Timestamp(6)
  occuCode      String   @map("職種コード") @db.VarChar(2)
  approvalCode  String   @map("承認権限コード") @db.VarChar(2)
  createDate    DateTime @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator       String?  @map("作成者名") @db.VarChar(12)
  updateDate    DateTime @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater       String?  @map("更新者名") @db.VarChar(12)

  @@map("社員マスタ")
}

EmployeeモデルにdeptCodeカラムを持たせることで、「社員はどこかの部門に所属する」というリレーションシップを表現しています。

リファクタリング:ER図で設計を可視化・改善する

テストが通る状態になったら、設計を見直します。この時点でのデータモデルをER図で表現すると以下のようになります。

  • 複合主キー: Departmentテーブルでは、deptCodestartDateで複合主キーを構成しています。これにより、組織変更などで同じ部門コードでも適用期間が異なる履歴データを管理できます。
  • リレーション: EmployeedeptCodeDepartmentを参照する外部キーの役割を果たします。(Prismaでは@relationを定義することで、より厳密な参照整合性制約を表現できますが、この時点ではまだ追加されていません)

第2章:商品の管理

次にシステムに追加された要求は、「商品を管理したい」というものでした。これには、商品を分類したり、顧客ごとに異なる価格を設定したり、代替商品を管理したりする、といった副次的な要求も含まれます。

レッド:商品をめぐる多様な要求のテスト

商品に関連する複数のテストを追加します。

  • 商品を登録・更新・削除したい (Product)
  • 商品を階層的に分類したい (ProductCategory)
  • 顧客ごとに特別な価格を設定したい (PriceByCustomer)
  • ある商品の代わりに提案できる代替商品を紐づけたい (AlternateProduct)

これらの要求をテストコードで表現することが、TDDの第一歩です。app.test.tsでは、これら複数のモデルが関連しあうため、$transactionを使って関連データを一度にセットアップし、includeを使って関連データを含めて取得・検証しています。

// src/app.test.ts
test("商品を登録できる", async () => {
  const expected = { // 期待する結果には、関連データも含まれる
    ...products[0],
    alternateProducts: alternateProducts,
    pricebycustomers: priceByCustomers,
  };

  // 関連するマスタを先に登録
  await prisma.$transaction(async (prisma) => {
    await prisma.productCategory.createMany({ data: productCategories });
    await prisma.product.createMany({ data: products });
    await prisma.alternateProduct.createMany({ data: alternateProducts });
    await prisma.priceByCustomer.createMany({ data: priceByCustomers });
  });

  // 関連データを含めて取得し、検証する
  const result = await prisma.product.findUnique({
    where: { prodCode: products[0].prodCode },
    include: {
      alternateProducts: true,
      pricebycustomers: true,
    }
  });
  expect(result).toEqual(expected);
});

グリーン:関連するテーブルを一気に追加

これらのテストをパスさせるため、prisma/schema.prismaに複数のモデルが一気に追加されます。

// 商品そのもの
model Product {
  prodCode          String             @id(map: "pk_products") @map("商品コード") @db.VarChar(16)
  fullname          String             @map("商品正式名") @db.VarChar(40)
  name              String             @map("商品略称") @db.VarChar(10)
  kana              String             @map("商品名カナ") @db.VarChar(20)
  prodType          String?            @map("商品区分") @db.VarChar(1)
  serialNo          String?            @map("製品型番") @db.VarChar(40)
  unitprice         Int                @default(0) @map("販売単価")
  poPrice           Int?               @default(0) @map("仕入単価")
  primeCost         Int                @default(0) @map("売上原価")
  taxType           Int                @default(1) @map("税区分")
  categoryCode      String?            @map("商品分類コード") @db.VarChar(8)
  wideUseType       Int?               @map("雑区分")
  stockManageType   Int?               @default(1) @map("在庫管理対象区分")
  stockReserveType  Int?               @map("在庫引当区分")
  supCode           String             @map("仕入先コード") @db.VarChar(8)
  supSubNo          Int?               @map("仕入先枝番")
  createDate        DateTime           @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator           String?            @map("作成者名") @db.VarChar(12)
  updateDate        DateTime           @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater           String?            @map("更新者名") @db.VarChar(12)
  pricebycustomers  PriceByCustomer[]
  alternateProducts AlternateProduct[]
  boms              Bom[]

  @@map("商品マスタ")
}

// 商品の分類
model ProductCategory {
  categoryCode String   @id(map: "pk_product_category") @map("商品分類コード") @db.VarChar(8)
  name         String?  @map("商品分類名") @db.VarChar(30)
  layer        Int      @default(0) @map("商品分類階層")
  path         String?  @map("商品分類パス") @db.VarChar(100)
  lowestType   Int?     @default(0) @map("最下層区分")
  createDate   DateTime @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator      String?  @map("作成者名") @db.VarChar(12)
  updateDate   DateTime @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater      String?  @map("更新者名") @db.VarChar(12)

  @@map("商品分類マスタ")
}

// 顧客別の販売単価
model PriceByCustomer {
  prodCode   String   @map("商品コード") @db.VarChar(16)
  compCode   String   @map("取引先コード") @db.VarChar(8)
  unitprice  Int      @default(0) @map("販売単価")
  createDate DateTime @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator    String?  @map("作成者名") @db.VarChar(12)
  updateDate DateTime @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater    String?  @map("更新者名") @db.VarChar(12)
  products   Product? @relation(fields: [prodCode], references: [prodCode])

  @@id([prodCode, compCode], map: "pk_pricebycustomer")
  @@map("顧客別販売単価")
}

// 代替商品
model AlternateProduct {
  prodCode    String   @map("商品コード") @db.VarChar(16)
  altProdCode String   @map("代替商品コード") @db.VarChar(16)
  priority    Int?     @default(1) @map("優先順位")
  createDate  DateTime @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator     String?  @map("作成者名") @db.VarChar(12)
  updateDate  DateTime @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater     String?  @map("更新者名") @db.VarChar(12)
  products    Product? @relation(fields: [prodCode], references: [prodCode])

  @@id([prodCode, altProdCode], map: "pk_alternate_products")
  @@map("代替商品")
}

リファクタリング:ER図で見る商品モデル

この時点での商品関連のデータモデルは以下のようになります。


第3章:取引先の登場

システムが成長するにつれ、「誰と取引するのか」を管理する必要が出てきました。法人顧客、個人顧客、そして商品を供給してくれる仕入先など、様々な役割の「取引先」が登場します。この章では、これらの複雑な関係性を「パーティモデル」という考え方を使って、いかにシンプルに、かつ柔軟に設計していくかを見ていきます。

レッド:多様な取引先を管理するテスト

ここでの中心的な要求は以下の通りです。

  1. 取引先(Company)を登録したい。 取引先は「顧客(Customer)」としての側面と「仕入先(Supplier)」としての側面を両方持つことがある。
  2. 取引先をグループ(CompanyGroup)でまとめたい。
  3. 取引先を様々な種別(CategoryType)や分類(CompanyCategory)で整理したい。

これらの要求は、app.test.ts内で、関連するモデルをincludeで同時に取得・検証するテストとして表現されています。

// src/app.test.ts

test("取引先を登録できる", async () => {
  const expected: Company[] = companies.map((c) => {
    return {
      ...c,
      customers: customers, // Companyに紐づくCustomer
      suppliers: suppliers, // Companyに紐づくSupplier
    };
  });

  // Company, Customer, Supplierをまとめて登録
  await prisma.$transaction(async (prisma) => {
    await prisma.companyGroup.createMany({ data: companyGroups });
    await prisma.company.createMany({ data: companies });
    await prisma.customer.createMany({ data: customers });
    await prisma.supplier.createMany({ data: suppliers });
  });

  // 関連モデルを含めて取得し、検証
  const result = await prisma.company.findMany({
    include: { // 関連モデルを含めて取得
      customers: true,
      suppliers: true,
    },
  });
  expect(result).toEqual(expected);
});

test("取引先をグループ化できる", async () => {
    const expected = {
        ...companyGroups[0],
        companies: companies,
    }

    const result = {
        ...await prisma.companyGroup.findUnique({ where: { compGroupCode: companyGroups[0].compGroupCode } }),
        companies: await prisma.company.findMany({ where: { compGroupCode: companyGroups[0].compGroupCode } }),
    }
    expect(result).toEqual(expected);
});

グリーン:パーティモデルで要求を満たす

これらのテストを通すため、Companyを取引の当事者(パーティ)の基本情報とし、CustomerSupplierがそれぞれの役割(ロール)に応じた追加情報を持つ、という設計を採用します。

// prisma/schema.prisma

// すべての取引先の基礎情報
model Company {
  compCode      String   @id(map: "pk_companys_mst") @map("取引先コード") @db.VarChar(8)
  name          String   @map("取引先名") @db.VarChar(40)
  kana          String?  @map("取引先名カナ") @db.VarChar(40)
  supType       Int?     @default(0) @map("仕入先区分")
  zipCode       String?  @map("郵便番号") @db.Char(8)
  state         String?  @map("都道府県") @db.VarChar(4)
  address1      String?  @map("住所1") @db.VarChar(40)
  address2      String?  @map("住所2") @db.VarChar(40)
  noSalesFlg    Int?     @default(0) @map("取引禁止フラグ")
  wideUseType   Int?     @default(0) @map("雑区分")
  compGroupCode String   @map("取引先グループコード") @db.VarChar(4)
  maxCredit     Int?     @default(0) @map("与信限度額")
  tempCreditUp  Int?     @default(0) @map("与信一時増加枠")
  createDate    DateTime @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator       String?  @map("作成者名") @db.VarChar(12)
  updateDate    DateTime @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater       String?  @map("更新者名") @db.VarChar(12)

  customers             Customer[]
  suppliers             Supplier[]
  companyCategoryGroups CompanyCategoryGroup[]

  @@map("取引先マスタ")
}

// 顧客としての役割情報
model Customer {
  custCode        String        @map("顧客コード") @db.VarChar(8)
  custSubNo       Int           @map("顧客枝番")
  custType        Int?          @default(0) @map("顧客区分")
  arCode          String        @map("請求先コード") @db.VarChar(8)
  arSubNo         Int?          @map("請求先枝番")
  payerCode       String        @map("回収先コード") @db.VarChar(8)
  payerSubNo      Int?          @map("回収先枝番")
  name            String        @map("顧客名") @db.VarChar(40)
  kana            String?       @map("顧客名カナ") @db.VarChar(40)
  empCode         String        @map("自社担当者コード") @db.VarChar(10)
  custUserName    String?       @map("顧客担当者名") @db.VarChar(20)
  custUserDepName String?       @map("顧客部門名") @db.VarChar(40)
  custZipCode     String?       @map("顧客郵便番号") @db.Char(8)
  custState       String?       @map("顧客都道府県") @db.VarChar(4)
  custAddress1    String?       @map("顧客住所1") @db.VarChar(40)
  custAddress2    String?       @map("顧客住所2") @db.VarChar(40)
  custTel         String?       @map("顧客電話番号") @db.VarChar(13)
  custFax         String?       @map("顧客FAX番号") @db.VarChar(13)
  custEmail       String?       @map("顧客メールアドレス") @db.VarChar(100)
  custArType      Int?          @default(0) @map("顧客請求区分")
  custCloseDate1  Int           @map("顧客締日1")
  custPayMonths1  Int?          @default(1) @map("顧客支払月1")
  custPayDates1   Int?          @map("顧客支払日1")
  custPayMethod1  Int?          @default(1) @map("顧客支払方法1")
  custCloseDate2  Int           @map("顧客締日2")
  custPayMonths2  Int?          @default(1) @map("顧客支払月2")
  custPayDates2   Int?          @map("顧客支払日2")
  custPayMethod2  Int?          @default(1) @map("顧客支払方法2")
  createDate      DateTime      @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator         String?       @map("作成者名") @db.VarChar(12)
  updateDate      DateTime      @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater         String?       @map("更新者名") @db.VarChar(12)
  company         Company       @relation(fields: [custCode], references: [compCode])
  destinations    Destination[]

  @@id([custCode, custSubNo], map: "pk_customer")
  @@map("顧客マスタ")
}

// 仕入先としての役割情報
model Supplier {
  supCode       String   @map("仕入先コード") @db.VarChar(8)
  supSubNo      Int      @map("仕入先枝番")
  name          String   @map("仕入先名") @db.VarChar(40)
  kana          String?  @map("仕入先名カナ") @db.VarChar(40)
  supEmpName    String?  @map("仕入先担当者名") @db.VarChar(20)
  supDepName    String?  @map("仕入先部門名") @db.VarChar(40)
  supZipCode    String?  @map("仕入先郵便番号") @db.Char(8)
  supState      String?  @map("仕入先都道府県") @db.VarChar(4)
  supAddress1   String?  @map("仕入先住所1") @db.VarChar(40)
  supAddress2   String?  @map("仕入先住所2") @db.VarChar(40)
  supTel        String?  @map("仕入先電話番号") @db.VarChar(13)
  supFax        String?  @map("仕入先FAX番号") @db.VarChar(13)
  supEmail      String?  @map("仕入先メールアドレス") @db.VarChar(100)
  supCloseDate  Int      @map("仕入先締日")
  supPayMonths  Int?     @default(1) @map("仕入先支払月")
  supPayDates   Int?     @map("仕入先支払日")
  payMethodType Int?     @default(1) @map("仕入先支払方法")
  createDate    DateTime @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator       String?  @map("作成者名") @db.VarChar(12)
  updateDate    DateTime @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater       String?  @map("更新者名") @db.VarChar(12)

  company Company @relation(fields: [supCode], references: [compCode])

  @@id([supCode, supSubNo], map: "pk_supplier")
  @@map("仕入先マスタ")
}

// 取引先をグループ化するためのマスタ
model CompanyGroup {
  compGroupCode String   @id(map: "pk_company_group_mst") @map("取引先グループコード") @db.VarChar(4)
  groupName     String?  @map("取引先グループ名") @db.VarChar(40)
  createDate    DateTime @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator       String?  @map("作成者名") @db.VarChar(12)
  updateDate    DateTime @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater       String?  @map("更新者名") @db.VarChar(12)

  @@map("取引先グループマスタ")
}

// 「業種」「地域」といった分類の"種類"を定義
model CategoryType {
  categoryTypeCode  String            @id(map: "pk_category_type") @map("取引先分類種別コード") @db.VarChar(2)
  categoryTypeName  String?           @map("取引先分類種別名") @db.VarChar(20)
  createDate        DateTime          @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator           String?           @map("作成者名") @db.VarChar(12)
  updateDate        DateTime          @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater           String?           @map("更新者名") @db.VarChar(12)
  companyCategories CompanyCategory[]

  @@map("取引先分類種別マスタ")
}

// 「IT業」「製造業」といった具体的な分類を定義
model CompanyCategory {
  categoryTypeCode      String                 @map("取引先分類種別コード") @db.VarChar(2)
  compCateCode          String                 @map("取引先分類コード") @db.VarChar(8)
  compCateName          String?                @map("取引先分類名") @db.VarChar(30)
  companyCategoryGroups CompanyCategoryGroup[]
  categoryType          CategoryType?          @relation(fields: [categoryTypeCode], references: [categoryTypeCode])

  @@id([categoryTypeCode, compCateCode], map: "pk_company_category")
  @@map("取引先分類マスタ")
}

// 取引先と分類を紐づける中間テーブル
model CompanyCategoryGroup {
  categoryTypeCode String   @map("取引先分類種別コード") @db.VarChar(2)
  compCateCode     String   @map("取引先分類コード") @db.VarChar(8)
  compCode         String   @map("取引先コード") @db.VarChar(8)
  createDate       DateTime @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator          String?  @map("作成者名") @db.VarChar(12)
  updateDate       DateTime @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater          String?  @map("更新者名") @db.VarChar(12)

  companyCategory CompanyCategory @relation(fields: [compCateCode, categoryTypeCode], references: [compCateCode, categoryTypeCode])
  company         Company?        @relation(fields: [compCode], references: [compCode])

  @@id([categoryTypeCode, compCode, compCateCode], map: "pk_company_category_group")
  @@map("取引先分類所属マスタ")
}

リファクタリング:ER図で見る取引先モデル

Companyモデルを共通の基盤とし、CustomerSupplierがそれぞれ詳細情報を持つ、という「パーティ」モデルに近い設計になっています。これにより、同じ会社が顧客であり仕入先でもある、というケースにも柔軟に対応できます。


受注管理

第4章:ビジネスの中核、受発注プロセス

マスタデータが揃ったところで、いよいよビジネスの中核となる「受注」と「売上」のプロセスを記録するテーブルを追加します。

レッド:取引の流れを記録するテスト

Order(受注)とSales(売上)のモデルを追加します。これらは、いつ、誰が、何を、いくつ注文し、それがいつ売上になったのか、という取引の詳細を記録します。テストコードでは、関連するマスタ(顧客、社員、商品)を先に作成した上で、受注データと売上データが正しく登録・更新・削除できることを検証します。

// src/app.test.ts
describe("受注と受注明細", () => {
  beforeAll(async () => {
    // ... 関連マスタのセットアップ ...
    await prisma.employee.createMany({ data: employees });
    await prisma.company.createMany({ data: companies });
    await prisma.customer.createMany({ data: customers });
    await prisma.product.createMany({ data: products });
  });

  test("受注を登録できる", async () => {
    const expected: Order[] = orders.map((o) => {
      return {
        ...o,
        orderDetails: orderDetails.filter((od) => od.orderNo === o.orderNo),
      };
    });
    await prisma.$transaction(async (prisma) => {
      await prisma.order.createMany({ data: orders });
      await prisma.orderDetail.createMany({ data: orderDetails });
    });

    const result = await prisma.order.findMany({
      include: {
        orderDetails: true,
      }
    });
    expect(result).toEqual(expected);
  });
  // ... 更新・削除のテストも同様に記述
});

グリーン:要求を満たすトランザクションスキーマ

このテストを通すため、受注ヘッダ(Order)と受注明細(OrderDetail)、売上ヘッダ(Sales)と売上明細(SalesDetail)のモデルを定義します。

// prisma/schema.prisma

model Order {
  orderNo      String        @id(map: "pk_orders") @map("受注番号") @db.VarChar(10)
  orderDate    DateTime      @default(dbgenerated("CURRENT_DATE")) @map("受注日") @db.Timestamp(6)
  deptCode     String        @map("部門コード") @db.VarChar(6)
  startDate    DateTime      @default(dbgenerated("CURRENT_DATE")) @map("部門開始日") @db.Timestamp(6)
  custCode     String        @map("顧客コード") @db.VarChar(8)
  custSubNo    Int?          @map("顧客枝番")
  empCode      String        @map("社員コード") @db.VarChar(10)
  requiredDate DateTime?     @map("希望納期") @db.Timestamp(6)
  custorderNo  String?       @map("客先注文番号") @db.VarChar(20)
  whCode       String        @map("倉庫コード") @db.VarChar(3)
  orderAmnt    Int           @default(0) @map("受注金額合計")
  cmpTax       Int           @default(0) @map("消費税合計")
  slipComment  String?       @map("備考") @db.VarChar(1000)
  createDate   DateTime      @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator      String?       @map("作成者名") @db.VarChar(12)
  updateDate   DateTime      @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater      String?       @map("更新者名") @db.VarChar(12)
  orderDetails OrderDetail[]

  @@map("受注データ")
}

model OrderDetail {
  orderNo          String    @map("受注番号") @db.VarChar(10)
  soRowNo          Int       @map("受注行番号")
  prodCode         String    @map("商品コード") @db.VarChar(16)
  prodName         String    @map("商品名") @db.VarChar(10)
  unitprice        Int       @default(0) @map("販売単価")
  quantity         Int       @default(1) @map("受注数量")
  cmpTaxRate       Int?      @default(0) @map("消費税率")
  reserveQty       Int?      @default(0) @map("引当数量")
  deliveryOrderQty Int?      @default(0) @map("出荷指示数量")
  deliveredQty     Int?      @default(0) @map("出荷済数量")
  completeFlg      Int       @default(0) @map("完了フラグ")
  discount         Int       @default(0) @map("値引金額")
  deliveryDate     DateTime? @map("納期") @db.Timestamp(6)
  createDate       DateTime  @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator          String?   @map("作成者名") @db.VarChar(12)
  updateDate       DateTime  @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater          String?   @map("更新者名") @db.VarChar(12)
  order            Order     @relation(fields: [orderNo], references: [orderNo])

  @@id([orderNo, soRowNo])
  @@map("受注データ明細")
}

model Sales {
  salesNo     String   @id(map: "pk_sales") @map("売上番号") @db.VarChar(10)
  orderNo     String   @map("受注番号") @db.VarChar(10)
  salesDate   DateTime @default(dbgenerated("CURRENT_DATE")) @map("売上日") @db.Timestamp(6)
  salesType   Int?     @default(1) @map("売上区分")
  deptCode    String   @map("部門コード") @db.VarChar(6)
  startDate   DateTime @default(dbgenerated("CURRENT_DATE")) @map("部門開始日") @db.Timestamp(6)
  compCode    String   @map("取引先コード") @db.VarChar(8)
  empCode     String   @map("社員コード") @db.VarChar(10)
  salesAmnt   Int      @default(0) @map("売上金額合計")
  cmpTax      Int      @default(0) @map("消費税合計")
  slipComment String?  @map("備考") @db.VarChar(1000)
  updatedNo   Int?     @map("赤黒伝票番号")
  orgnlNo     String?  @map("元伝票番号") @db.VarChar(10)
  createDate  DateTime @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator     String?  @map("作成者名") @db.VarChar(12)
  updateDate  DateTime @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater     String?  @map("更新者名") @db.VarChar(12)

  salesDetails SalesDetail[]

  @@map("売上データ")
}

model SalesDetail {
  salesNo          String          @map("売上番号") @db.VarChar(10)
  rowNo            Int             @map("売上行番号")
  prodCode         String          @map("商品コード") @db.VarChar(16)
  prodName         String          @map("商品名") @db.VarChar(10)
  unitprice        Int             @default(0) @map("販売単価")
  deliveredQty     Int?            @default(0) @map("出荷数量")
  quantity         Int             @default(1) @map("売上数量")
  discount         Int             @default(0) @map("値引金額")
  invoicedDate     DateTime?       @map("請求日") @db.Timestamp(6)
  invoiceNo        String?         @map("請求番号") @db.VarChar(10)
  invoiceDelayType Int?            @map("請求遅延区分")
  autoJournalDate  DateTime?       @map("自動仕訳日") @db.Timestamp(6)
  createDate       DateTime        @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator          String?         @map("作成者名") @db.VarChar(12)
  updateDate       DateTime        @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater          String?         @map("更新者名") @db.VarChar(12)
  sales            Sales?          @relation(fields: [salesNo], references: [salesNo])
  invoices         InvoiceDetail[]

  @@id([salesNo, rowNo], map: "pk_sales_details")
  @@map("売上データ明細")
}

リファクタリング:ER図で見るトランザクションモデル

受注と売上は、それぞれヘッダ(Order, Sales)と明細(OrderDetail, SalesDetail)のペアで構成されています。これは、1回の取引に複数の商品が含まれることを表現する、典型的な設計パターンです。SalesモデルがorderNoを持つことで、どの受注が売上につながったのかを追跡できます。


在庫管理

第5章:モノの流れを管理する:仕入と在庫

商品を販売するには、まずそれを仕入れる必要があります。そして、仕入れた商品がどこにいくつあるのかを正確に把握する「在庫管理」は、ビジネスの生命線です。

レッド:発注から在庫までの要求をテストで表現する

ここでの要求は、発注、仕入、倉庫、在庫といった一連のプロセスをデータとして正しく記録できることです。

// src/app.test.ts
describe("発注/仕入業務のDB設計", () => {
  test("発注を登録できる", async () => {
    const expected = {
      ...purchaseOrders[0],
      purchaseOrderDetails: purchaseOrderDetails,
    };

    await prisma.purchaseOrder.create({
      data: {
        ...purchaseOrders[0],
        purchaseOrderDetails: {
          create: purchaseOrderDetails,
        },
      },
    });

    const result = await prisma.purchaseOrder.findFirst({
      include: {
        purchaseOrderDetails: true,
      },
    });

    expect(result).toEqual(expected);
  });

  test("在庫を登録できる", async () => {
    await prisma.stock.createMany({ data: stocks });
    const result = await prisma.stock.findMany();
    expect(result).toEqual(stocks);
  });
});

グリーン:要求を満たすスキーマ定義

これらのテストをパスさせるために、発注、仕入、在庫に関連するモデルをschema.prismaに定義します。

// 発注データ
model PurchaseOrder {
  poNo        String    @id(map: "pk_purchase_orders") @map("発注番号") @db.VarChar(10)
  poDate      DateTime? @map("発注日") @db.Timestamp(6)
  orderNo     String    @map("受注番号") @db.VarChar(10)
  supCode     String    @map("仕入先コード") @db.VarChar(8)
  supSubNo    Int?      @default(0) @map("仕入先枝番")
  empCode     String    @map("発注担当者コード") @db.VarChar(10)
  dueDate     DateTime? @map("指定納期") @db.Timestamp(6)
  whCode      String    @map("倉庫コード") @db.VarChar(3)
  poAmnt      Int?      @default(0) @map("発注金額合計")
  cmpTax      Int       @default(0) @map("消費税合計")
  slipComment String?   @map("備考") @db.VarChar(1000)
  createDate  DateTime  @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator     String?   @map("作成者名") @db.VarChar(12)
  updateDate  DateTime  @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater     String?   @map("更新者名") @db.VarChar(12)

  purchaseOrderDetails PurchaseOrderDetail[]

  @@map("発注データ")
}

// 仕入データ
model Purchase {
  puNo        String    @id(map: "pk_pu") @map("仕入番号") @db.VarChar(10)
  puDate      DateTime? @map("仕入日") @db.Timestamp(6)
  supCode     String    @map("仕入先コード") @db.VarChar(8)
  supSubNo    Int?      @default(0) @map("仕入先枝番")
  empCode     String    @map("仕入担当者コード") @db.VarChar(10)
  startDate   DateTime  @default(dbgenerated("CURRENT_DATE")) @map("開始日") @db.Timestamp(6)
  poNo        String?   @map("発注番号") @db.VarChar(10)
  deptCode    String    @map("部門コード") @db.VarChar(6)
  puAmmount   Int?      @default(0) @map("仕入金額合計")
  cmpTax      Int       @default(0) @map("消費税合計")
  slipComment String?   @map("備考") @db.VarChar(1000)
  createDate  DateTime  @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator     String?   @map("作成者名") @db.VarChar(12)
  updateDate  DateTime  @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater     String?   @map("更新者名") @db.VarChar(12)

  purchaseDetails PurchaseDetail[]

  @@map("仕入データ")
}

// 在庫データ
model Stock {
  whCode           String    @map("倉庫コード") @db.VarChar(3)
  prodCode         String    @map("商品コード") @db.VarChar(16)
  rotNo            String    @map("ロット番号") @db.VarChar(20)
  stockType        String    @default("1") @map("在庫区分") @db.VarChar(1)
  qualityType      String    @default("G") @map("良品区分") @db.VarChar(1)
  actual           Int       @default(1) @map("実在庫数")
  valid            Int       @default(1) @map("有効在庫数")
  lastDeliveryDate DateTime? @map("最終出荷日") @db.Timestamp(6)
  createDate       DateTime  @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator          String?   @map("作成者名") @db.VarChar(12)
  updateDate       DateTime  @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater          String?   @map("更新者名") @db.VarChar(12)

  warehouse Warehouse? @relation(fields: [whCode], references: [whCode])

  @@id([whCode, prodCode, rotNo, stockType, qualityType], map: "pk_stock")
  @@map("在庫データ")
}

リファクタリング:ER図で見る調達・在庫モデル


調達管理

第6章:カネの流れを管理する:請求と支払

ビジネスは、モノの流れとカネの流れ、両方の管理が不可欠です。`ここではお金の動きを管理するモデルを追加します。

レッド:請求と支払の要求をテストで表現する

モノの流れと同様に、カネの流れもテストで要求を定義します。

// src/app.test.ts
describe("請求業務の処理", () => {
  test("請求を登録できる", async () => {
    const expected: Invoice[] = invoices.map((i) => {
      return {
        ...i,
        invoiceDetails: invoiceDetails.filter(
          (id) => id.invoiceNo === i.invoiceNo,
        ),
      };
    });
    await prisma.$transaction(async (prisma) => {
      await prisma.invoice.createMany({ data: invoices });
      await prisma.invoiceDetail.createMany({ data: invoiceDetails });
    });

    const result = await prisma.invoice.findMany({
      include: {
        invoiceDetails: true,
      },
    });
    expect(result).toEqual(expected);
  });
});

describe("支払業務のDB設計", () => {
  test("支払を登録できる", async () => {
    await prisma.payment.createMany({ data: payments });
    const result = await prisma.payment.findMany();
    expect(result).toEqual(payments);
  });
});

グリーン:要求を満たすスキーマ定義

これらのテストをパスさせるために、請求、入金、支払の各モデルをschema.prismaに定義します。

// 請求データ
model Invoice {
  invoiceNo       String    @id(map: "pk_invoice") @map("請求番号") @db.VarChar(10)
  invoicedDate    DateTime? @map("請求日") @db.Timestamp(6)
  compCode        String    @map("取引先コード") @db.VarChar(8)
  custSubNo       Int?      @default(0) @map("顧客枝番")
  lastReceived    Int?      @default(0) @map("前回入金額")
  monthSales      Int?      @default(0) @map("当月売上額")
  monthReceived   Int?      @default(0) @map("当月入金額")
  monthInvoice    Int?      @default(0) @map("当月請求額")
  cmpTax          Int       @default(0) @map("消費税金額")
  invoiceReceived Int?      @default(0) @map("請求消込金額")
  createDate      DateTime  @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator         String?   @map("作成者") @db.VarChar(12)
  updateDate      DateTime  @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater         String?   @map("更新者") @db.VarChar(12)

  invoiceDetails InvoiceDetail[]

  @@map("請求データ")
}

// 入金データ
model Credit {
  creditNo      String    @id(map: "pk_credit") @map("入金番号") @db.VarChar(10)
  creditDate    DateTime? @map("入金日") @db.Timestamp(6)
  deptCode      String    @map("部門コード") @db.VarChar(6)
  startDate     DateTime  @default(dbgenerated("CURRENT_DATE")) @map("開始日") @db.Timestamp(6)
  custCode      String    @map("顧客コード") @db.VarChar(8)
  custSubNo     Int?      @default(0) @map("顧客枝番")
  payMethodType Int?      @default(1) @map("支払方法区分")
  bankAcutCode  String?   @map("入金口座コード") @db.VarChar(8)
  receivedAmnt  Int?      @default(0) @map("入金金額")
  received      Int?      @default(0) @map("消込金額")
  createDate    DateTime  @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator       String?   @map("作成者") @db.VarChar(12)
  updateDate    DateTime  @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater       String?   @map("更新者") @db.VarChar(12)
  updatePlgDate DateTime? @default(dbgenerated("CURRENT_DATE")) @map("プログラム更新日時") @db.Timestamp(6)
  updatePgm     String?   @map("更新プログラム名") @db.VarChar(50)

  @@map("入金データ")
}

// 支払データ
model Payment {
  payNo         String   @id(map: "pk_pay") @map("支払番号") @db.VarChar(10)
  payDate       Int?     @default(0) @map("支払日")
  deptCode      String   @map("部門コード") @db.VarChar(6)
  startDate     DateTime @default(dbgenerated("CURRENT_DATE")) @map("部門開始日") @db.Timestamp(6)
  supCode       String   @map("仕入先コード") @db.VarChar(8)
  supSubNo      Int?     @default(0) @map("仕入先枝番")
  payMethodType Int?     @default(1) @map("支払方法区分")
  payAmnt       Int?     @default(0) @map("支払金額")
  cmpTax        Int      @default(0) @map("消費税合計")
  completeFlg   Int      @default(0) @map("支払完了フラグ")
  createDate    DateTime @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator       String?  @map("作成者") @db.VarChar(12)
  updateDate    DateTime @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater       String?  @map("更新者") @db.VarChar(12)

  @@map("支払データ")
}

リファクタリング:ER図で見る請求・入金・支払モデル

売上から請求、そして入金までの一連の流れ、また仕入から支払までの一連の流れをシステム上で追跡できるようになりました。


その他

第7章:縁の下の力持ち:与信管理とその他のマスタ

最後に、ビジネスルールやシステム運用を支える、重要なマスタデータを追加します。

レッド:ビジネスルールをテストで定義する

ビジネスの安全性を支えるモデルを追加します。ここでの要求は、目に見える機能だけでなく、システムの健全性を保つための内部的なルールを検証することです。

// src/app.test.ts
describe("与信管理", () => {
  test("与信残高を登録できる", async () => {
    await prisma.creditBalance.createMany({ data: creditBalances });
    const result = await prisma.creditBalance.findMany();
    expect(result).toEqual(creditBalances);
  });
});

グリーン:要求を満たすスキーマ定義

これらのテストをパスさせるために、与信残高と自動採番のモデルをschema.prismaに定義します。

// 与信残高データ
model CreditBalance {
  compCode     String   @id(map: "pk_credit_balance") @map("取引先コード") @db.VarChar(8)
  orderBalance Int?     @default(0) @map("受注残高")
  recBalance   Int?     @default(0) @map("債権残高")
  payBalance   Int?     @default(0) @map("債務残高")
  createDate   DateTime @default(dbgenerated("CURRENT_DATE")) @map("作成日時") @db.Timestamp(6)
  creator      String?  @map("作成者名") @db.VarChar(12)
  updateDate   DateTime @default(dbgenerated("CURRENT_DATE")) @map("更新日時") @db.Timestamp(6)
  updater      String?  @map("更新者名") @db.VarChar(12)

  @@map("与信残高データ")
}

// 自動採番マスタ
model autoNumber {
  slipType   String   @map("伝票種別コード") @db.VarChar(2)
  yearmonth  DateTime @map("年月") @db.Timestamp(6)
  lastSilpNo Int      @default(0) @map("最終伝票番号")

  @@id([slipType, yearmonth])
  @@map("自動採番マスタ")
}

リファクタリング:ER図で見る補助マスタ

これらのモデルは、直接的な業務フローには現れにくいですが、システムの安定稼働とビジネスリスクの管理に不可欠な、まさに「縁の下の力持ち」です。


最終的なデータモデル

こうしてTDDサイクルを経て進化した、販売管理システムの最終的なデータモデルの全体像です。

ドメインに適したデータの作成

設計したデータモデルに適したデータを用意する必要があります。ここでは以下の事例を基に事業に適したサンプルデータを生成します。

食肉と食肉加工品の製造・販売を行うB社の事例

与件

B社は資本金3,000 万円、従業者数は45 名(うちパート従業員21 名)で、食肉と食肉加工品の製造・販売を行う事業者である。現在の事業所は本社、工場、直営小売店1 店舗である。2021 年度の販売額は約9 億円で、取扱商品は牛肉・豚肉・鶏肉・食肉加工品である。

B社はX県の大都市近郊に立地する。高速道路のインターチェンジからも近く、車の利便性は良いエリアだ。B 社の周辺には、大規模な田畑を所有する古くからの住民もいるが、工業団地があるため、現役世代が家族で居住する集合住宅も多い。

1955年、B 社はこの地で牛肉、豚肉、鶏肉、肉の端材を使った揚げたてコロッケなどの総菜を販売する食肉小売店を開業した。当時の食肉消費拡大の波に乗って順調に売り上げを伸ばしたB 社は、1960 年代に入ると、食肉小売事業に加え、地域の百貨店や近隣のスーパーなどの大型小売業へ食肉を納入する事業を手がけるようになった。

百貨店やスーパーを取引先としてきたこともあって、B 社の商品はクオリティの高さに定評がある。仕入れ元からのB 社に対する信頼も厚く、良い食肉を仕入れられる体制が整っている。B 社は、百貨店向けには贈答用を含めた最高級品質の食肉や食肉加工品の販売を行い、直営の食肉小売店では対面接客による買物客のニーズに合わせた販売を行い、スーパー向けには食卓で日常使いしやすいカット肉やスライス肉などの販売を行っており、さまざまな食肉の消費機会に対応できる事業者である。

大型小売業の成長とともにB 社も成長していたが、1980 年代後半以降、スーパーは大手食肉卸売業者と取引を行うようになったため、B 社からスーパーへの納入量は徐々に減少していった。現在、B 社の周囲5 km 圏内には広大な駐車場を構える全国チェーンのスーパーが3 店舗あり、食肉も取り扱っているが、いずれもB 社との取引関係はない。

組織

ビジネスモデル

Seedデータ作成

B社の与件に基づいて、実際のビジネスシーンを想定したサンプルデータを src/seed.ts に実装しました。

実装内容

以下のマスタデータを TypeScript で記述し、Prisma Client を使用してデータベースに投入するスクリプトを作成しました:

組織・人事データ

  • 部門マスタ:21件
    • 4階層の組織構造(本社 → 事業部 → 部門 → 課)
    • 階層はパス表記(psth)で管理(例:/000000/100000/110000/111000
    • 食肉製造・販売事業、食肉加工品事業、コンサルティング事業の3事業部を配置
  • 社員マスタ:45件
    • 正社員24名、パート従業員21名
    • 社長・専務の経営層2名
    • 各事業部への適切な人員配置
    • 職種コード(occuCode)で役職を区別(社長・専務・部門長/課長・一般社員・パート)

取引先データ

  • 取引先グループ:7件(百貨店、スーパー、ホテル・旅館、飲食店、観光施設、食肉卸、畜産業者)
  • 取引先:14件(得意先10件、仕入先4件)
  • 分類区分:3件
  • 取引先分類:13件
  • 取引先分類グループ:42件

商品・在庫データ

  • 商品マスタ:20件
    • 牛肉製品:5件(黒毛和牛サーロイン、ロース、カルビ、ヒレ、切り落とし)
    • 豚肉製品:5件(豚ロース、豚バラ、豚ヒレ、豚コマ、豚肩ロース)
    • 鶏肉製品:5件(鶏もも、鶏むね、手羽先、手羽元、鶏ささみ)
    • 加工品:5件(ローストビーフ、ハム、ソーセージ、ベーコン、コロッケ)
    • 各商品に仕入先を設定
  • 倉庫マスタ:2件(本社倉庫、工場倉庫)

実行方法

Seed データの投入は以下のコマンドで実行できます:

cd db/typescript
npm run seed

または

cd db/typescript
npm run prisma:seed

実行すると以下の処理が行われます:

  1. 既存データの削除(クリーンな状態から開始)
  2. マスタデータの投入(外部キー制約を考慮した順序で投入)
  3. 投入結果の表示(各マスタの件数を表示)

データ構造の特徴

部門マスタの階層構造

本社(000000)
├── 食肉製造・販売事業(100000)
│   ├── 食肉加工部門(110000)
│   │   ├── 牛肉・豚肉・鶏肉課(111000)
│   │   └── 食肉加工品課(112000)
│   ├── 小売販売部門(120000)
│   │   ├── 直営小売店課(121000)
│   │   └── 百貨店・スーパー向け販売課(122000)
│   └── 新規取引先開拓部門(130000)
│       ├── ホテル・旅館向け課(131000)
│       └── 飲食店向け課(132000)
├── 食肉加工品事業(200000)
│   ├── 自社ブランド部門(210000)
│   │   ├── 贈答用製品製造課(211000)
│   │   └── 道の駅・土産物製品販売課(212000)
│   └── 相手先ブランド製造(OEM)部門(220000)
│       └── 客先要望対応課(221000)
└── コンサルティング事業(300000)
    └── 顧客対応部門(310000)
        ├── メニュー提案課(311000)
        └── 半加工商品提供課(312000)

社員の配置

  • 経営層(本社):2名(社長、専務)
  • 食肉製造・販売事業:15名(正社員8名、パート7名)
  • 食肉加工品事業:14名(正社員6名、パート8名)
  • コンサルティング事業:12名(正社員6名、パート6名)
  • その他(経理・総務等):2名(正社員2名)

取引先の分類

得意先は以下のように分類されています:

  • 百貨店グループ:2社(地域百貨店、X県有名百貨店)
  • スーパーグループ:2社(地域スーパーチェーン、広域スーパーチェーン)
  • ホテル・旅館グループ:2社(シティホテル、温泉旅館)
  • 飲食店グループ:2社(焼肉レストラン、イタリアンレストラン)
  • 観光施設グループ:2社(道の駅、観光センター)

仕入先は以下のように分類されています:

  • 食肉卸グループ:2社(地域食肉卸A社、地域食肉卸B社)
  • 畜産業者グループ:2社(地域畜産農家、県内畜産組合)

技術的なポイント

  1. 外部キー制約の考慮

    • データ投入順序を外部キー依存関係に基づいて設計
    • 部門 → 社員、取引先グループ → 取引先 → 得意先/仕入先 の順で投入
  2. 複合キーの扱い

    • 部門マスタは (deptCode, startDate) の複合主キー
    • 社員マスタは複合外部キーで部門を参照
  3. ESLint 対応

    • Seed スクリプトでは console 出力が必要なため、ファイル先頭で eslint-disable を設定
  4. tsx の導入

    • TypeScript ファイルを直接実行するため、tsx パッケージを追加
    • npm run seed コマンドで簡単に実行可能

API サービスの追加

🎯 この章の学習目標

これまで構築してきた販売管理システムのデータベースを、本番環境で運用可能な Web API として公開します。

この章で習得するスキル

  • 📐 レイヤードアーキテクチャ - Application/Service/Domain の 3 層分離設計
  • 🌐 Fastify による高速 API 開発 - TypeScript ネイティブな Web フレームワーク
  • Zod によるバリデーション - 実行時型安全性の確保
  • 🧪 統合テスト - エンドツーエンドのテストスイート構築
  • 📚 Swagger UI - OpenAPI に基づく対話的な API ドキュメント
  • 🗄️ Prisma ORM - 型安全なデータベースアクセス
  • 🚀 本番運用準備 - ヘルスチェック、エラーハンドリング、ロギング

🏗️ レイヤードアーキテクチャとは

Web API を構築する際、コードを役割ごとに 層(レイヤー) に分離することで、保守性と拡張性が大きく向上します。

3 層アーキテクチャの概念

各層の責務

責務 具体例
Application HTTP プロトコル処理 Fastify ルート定義、リクエストバリデーション、Swagger 定義
Service ビジネスロジック データ変換、複数ドメインの調整、トランザクション
Domain データアクセス Prisma を使った CRUD 操作、クエリ実行

レイヤー分離のメリット

  • テストしやすい - 各層を独立してテスト可能
  • 変更に強い - HTTP フレームワークを変更しても Domain 層は影響を受けない
  • 再利用可能 - Domain 層は CLI、バッチ処理など他の用途でも使える
  • 理解しやすい - 各層の責務が明確

TDD で API を構築する

TDD の Red-Green-Refactor サイクルに従って、4 つのステップで API を実装します。


ステップ 1: 型定義とバリデーション(Red → Green → Refactor)

まず、API のリクエスト/レスポンスの型定義と、実行時バリデーションを実装します。TypeScript の型システムはコンパイル時の安全性を提供しますが、実行時には型情報が失われます。そのため、Zod を使って実行時バリデーションを行います。

Red(失敗するテスト)

商品 API を例に、リクエストスキーマのテストを書きます。

// src/schemas/product.test.ts
import { describe, it, expect } from 'vitest';
import { CreateProductSchema, UpdateProductSchema } from './product';

describe('Product Schemas', () => {
  describe('CreateProductSchema', () => {
    it('正常な商品作成リクエストを検証できる', () => {
      const validData = {
        prodCode: 'PROD00001',
        fullname: '黒毛和牛サーロインステーキ 200g',
        name: 'サーロイン',
        kana: 'クロゲワギュウサーロイン',
        unitprice: 5000,
        primeCost: 3500,
        supCode: 'SUP00001',
      };

      const result = CreateProductSchema.safeParse(validData);
      expect(result.success).toBe(true);
    });

    it('負の単価を拒否する', () => {
      const invalidData = {
        prodCode: 'PROD00001',
        fullname: '黒毛和牛サーロインステーキ 200g',
        name: 'サーロイン',
        kana: 'クロゲワギュウサーロイン',
        unitprice: -100, // 負の値
        primeCost: 3500,
        supCode: 'SUP00001',
      };

      const result = CreateProductSchema.safeParse(invalidData);
      expect(result.success).toBe(false);
    });

    it('必須フィールド欠落を拒否する', () => {
      const invalidData = {
        prodCode: 'PROD00001',
        // fullname が欠落
        name: 'サーロイン',
        kana: 'クロゲワギュウサーロイン',
        unitprice: 5000,
        primeCost: 3500,
        supCode: 'SUP00001',
      };

      const result = CreateProductSchema.safeParse(invalidData);
      expect(result.success).toBe(false);
    });
  });

  describe('UpdateProductSchema', () => {
    it('正常な商品更新リクエストを検証できる', () => {
      const validData = {
        fullname: '黒毛和牛サーロインステーキ 250g',
        unitprice: 5500,
      };

      const result = UpdateProductSchema.safeParse(validData);
      expect(result.success).toBe(true);
    });

    it('空のオブジェクトを許可する', () => {
      const result = UpdateProductSchema.safeParse({});
      expect(result.success).toBe(true);
    });
  });
});

実行結果(Red)

$ npm test src/schemas/product.test.ts

FAIL  src/schemas/product.test.ts
  ● Test suite failed to run

    Cannot find module './product' from 'src/schemas/product.test.ts'

Green(最小限の実装)

// src/schemas/product.ts
import { z } from 'zod';

/**
 * 商品作成リクエストスキーマ
 */
export const CreateProductSchema = z.object({
  prodCode: z.string().min(1).max(16),
  fullname: z.string().min(1).max(40),
  name: z.string().min(1).max(10),
  kana: z.string().min(1).max(20),
  unitprice: z.number().int().nonnegative(),
  primeCost: z.number().int().nonnegative(),
  supCode: z.string().min(1).max(8),
});

/**
 * 商品更新リクエストスキーマ(すべてオプション)
 */
export const UpdateProductSchema = z.object({
  fullname: z.string().min(1).max(40).optional(),
  name: z.string().min(1).max(10).optional(),
  kana: z.string().min(1).max(20).optional(),
  unitprice: z.number().int().nonnegative().optional(),
  primeCost: z.number().int().nonnegative().optional(),
  supCode: z.string().min(1).max(8).optional(),
});

/**
 * TypeScript 型の導出
 */
export type CreateProductRequest = z.infer<typeof CreateProductSchema>;
export type UpdateProductRequest = z.infer<typeof UpdateProductSchema>;

実行結果(Green)

$ npm test src/schemas/product.test.ts

 ✓ src/schemas/product.test.ts (6 tests) 6 passed
   ✓ Product Schemas (6 tests) 6 passed
     ✓ CreateProductSchema (3 tests) 3 passed
       ✓ 正常な商品作成リクエストを検証できる
       ✓ 負の単価を拒否する
       ✓ 必須フィールド欠落を拒否する
     ✓ UpdateProductSchema (3 tests) 3 passed
       ✓ 正常な商品更新リクエストを検証できる
       ✓ 空のオブジェクトを許可する

Refactor(改善)

他のエンティティ(Customer、Supplier、Stock など)のスキーマも同様に実装します。


ステップ 2: ドメイン層の実装(Red → Green → Refactor)

ドメイン層は、Prisma Client を使ったデータベースアクセスを担当します。

Red(失敗するテスト)

// src/domain/product.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { ProductDomain } from './product';
import { prisma } from '../lib/prisma';

describe('ProductDomain', () => {
  let domain: ProductDomain;

  beforeEach(async () => {
    domain = new ProductDomain();
    // テスト用データのクリーンアップ
    await prisma.product.deleteMany();
  });

  describe('create', () => {
    it('商品を作成できる', async () => {
      const product = await domain.create({
        prodCode: 'TEST00001',
        fullname: 'テスト商品',
        name: 'テスト',
        kana: 'テストショウヒン',
        unitprice: 1000,
        primeCost: 700,
        supCode: 'SUP00001',
      });

      expect(product.prodCode).toBe('TEST00001');
      expect(product.fullname).toBe('テスト商品');
    });
  });

  describe('findAll', () => {
    it('すべての商品を取得できる', async () => {
      await domain.create({
        prodCode: 'TEST00001',
        fullname: 'テスト商品1',
        name: 'テスト1',
        kana: 'テストショウヒン1',
        unitprice: 1000,
        primeCost: 700,
        supCode: 'SUP00001',
      });

      await domain.create({
        prodCode: 'TEST00002',
        fullname: 'テスト商品2',
        name: 'テスト2',
        kana: 'テストショウヒン2',
        unitprice: 2000,
        primeCost: 1400,
        supCode: 'SUP00001',
      });

      const products = await domain.findAll();
      expect(products).toHaveLength(2);
    });
  });

  describe('findById', () => {
    it('ID で商品を取得できる', async () => {
      await domain.create({
        prodCode: 'TEST00001',
        fullname: 'テスト商品',
        name: 'テスト',
        kana: 'テストショウヒン',
        unitprice: 1000,
        primeCost: 700,
        supCode: 'SUP00001',
      });

      const product = await domain.findById('TEST00001');
      expect(product).not.toBeNull();
      expect(product?.prodCode).toBe('TEST00001');
    });

    it('存在しない ID の場合 null を返す', async () => {
      const product = await domain.findById('NONEXISTENT');
      expect(product).toBeNull();
    });
  });

  describe('update', () => {
    it('商品を更新できる', async () => {
      await domain.create({
        prodCode: 'TEST00001',
        fullname: 'テスト商品',
        name: 'テスト',
        kana: 'テストショウヒン',
        unitprice: 1000,
        primeCost: 700,
        supCode: 'SUP00001',
      });

      const updated = await domain.update('TEST00001', {
        unitprice: 1500,
      });

      expect(updated.unitprice).toBe(1500);
    });
  });

  describe('delete', () => {
    it('商品を削除できる', async () => {
      await domain.create({
        prodCode: 'TEST00001',
        fullname: 'テスト商品',
        name: 'テスト',
        kana: 'テストショウヒン',
        unitprice: 1000,
        primeCost: 700,
        supCode: 'SUP00001',
      });

      await domain.delete('TEST00001');

      const product = await domain.findById('TEST00001');
      expect(product).toBeNull();
    });
  });
});

実行結果(Red)

$ npm test src/domain/product.test.ts

FAIL  src/domain/product.test.ts
  ● Test suite failed to run

    Cannot find module './product' from 'src/domain/product.test.ts'

Green(最小限の実装)

// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client';

export const prisma = new PrismaClient();
// src/domain/product.ts
import { prisma } from '../lib/prisma';
import { Product } from '@prisma/client';

export interface CreateProductData {
  prodCode: string;
  fullname: string;
  name: string;
  kana: string;
  unitprice: number;
  primeCost: number;
  supCode: string;
}

export interface UpdateProductData {
  fullname?: string;
  name?: string;
  kana?: string;
  unitprice?: number;
  primeCost?: number;
  supCode?: string;
}

export class ProductDomain {
  /**
   * 商品を作成
   */
  async create(data: CreateProductData): Promise<Product> {
    return await prisma.product.create({ data });
  }

  /**
   * すべての商品を取得
   */
  async findAll(): Promise<Product[]> {
    return await prisma.product.findMany();
  }

  /**
   * ID で商品を取得
   */
  async findById(prodCode: string): Promise<Product | null> {
    return await prisma.product.findUnique({
      where: { prodCode },
    });
  }

  /**
   * 商品を更新
   */
  async update(prodCode: string, data: UpdateProductData): Promise<Product> {
    return await prisma.product.update({
      where: { prodCode },
      data,
    });
  }

  /**
   * 商品を削除
   */
  async delete(prodCode: string): Promise<void> {
    await prisma.product.delete({
      where: { prodCode },
    });
  }
}

実行結果(Green)

$ npm test src/domain/product.test.ts

 ✓ src/domain/product.test.ts (5 tests) 5 passed
   ✓ ProductDomain (5 tests) 5 passed
     ✓ create (1 test) 1 passed
       ✓ 商品を作成できる
     ✓ findAll (1 test) 1 passed
       ✓ すべての商品を取得できる
     ✓ findById (2 tests) 2 passed
       ✓ ID で商品を取得できる
       ✓ 存在しない ID の場合 null を返す
     ✓ update (1 test) 1 passed
       ✓ 商品を更新できる
     ✓ delete (1 test) 1 passed
       ✓ 商品を削除できる

ステップ 3: サービス層の実装(Red → Green → Refactor)

サービス層は、ビジネスロジックとデータ変換を担当します。

Green(最小限の実装)

// src/service/product.ts
import { ProductDomain, CreateProductData, UpdateProductData } from '../domain/product';
import { Product } from '@prisma/client';

export class ProductService {
  private domain: ProductDomain;

  constructor() {
    this.domain = new ProductDomain();
  }

  /**
   * 商品を作成
   */
  async createProduct(data: CreateProductData): Promise<Product> {
    // ビジネスルールの適用(例:単価が原価より安い場合はエラー)
    if (data.unitprice < data.primeCost) {
      throw new Error('販売単価が売上原価より低い設定はできません');
    }

    return await this.domain.create(data);
  }

  /**
   * すべての商品を取得
   */
  async getAllProducts(): Promise<Product[]> {
    return await this.domain.findAll();
  }

  /**
   * ID で商品を取得
   */
  async getProductById(prodCode: string): Promise<Product | null> {
    return await this.domain.findById(prodCode);
  }

  /**
   * 商品を更新
   */
  async updateProduct(prodCode: string, data: UpdateProductData): Promise<Product> {
    const existing = await this.domain.findById(prodCode);
    if (!existing) {
      throw new Error('商品が見つかりません');
    }

    // ビジネスルールの適用
    const newUnitprice = data.unitprice ?? existing.unitprice;
    const newPrimeCost = data.primeCost ?? existing.primeCost;

    if (newUnitprice < newPrimeCost) {
      throw new Error('販売単価が売上原価より低い設定はできません');
    }

    return await this.domain.update(prodCode, data);
  }

  /**
   * 商品を削除
   */
  async deleteProduct(prodCode: string): Promise<void> {
    const existing = await this.domain.findById(prodCode);
    if (!existing) {
      throw new Error('商品が見つかりません');
    }

    await this.domain.delete(prodCode);
  }
}

ステップ 4: アプリケーション層の実装(Red → Green → Refactor)

最後に、Fastify のエンドポイントと Swagger UI を実装します。

Red(失敗するテスト)

// src/application.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { FastifyInstance } from 'fastify';
import { buildApp } from './application';
import { prisma } from './lib/prisma';

describe('Application Layer', () => {
  let app: FastifyInstance;

  beforeAll(async () => {
    app = await buildApp();
  });

  afterAll(async () => {
    await app.close();
    await prisma.$disconnect();
  });

  beforeEach(async () => {
    // テストデータのクリーンアップ
    await prisma.product.deleteMany();
  });

  describe('GET /', () => {
    it('ルートエンドポイントが API 情報を返す', async () => {
      const response = await app.inject({
        method: 'GET',
        url: '/',
      });

      expect(response.statusCode).toBe(200);
      const data = JSON.parse(response.body);
      expect(data.message).toBe('Sales Management API');
      expect(data.version).toBe('1.0.0');
    });
  });

  describe('GET /health', () => {
    it('ヘルスチェックエンドポイントが正常ステータスを返す', async () => {
      const response = await app.inject({
        method: 'GET',
        url: '/health',
      });

      expect(response.statusCode).toBe(200);
      expect(JSON.parse(response.body)).toEqual({ status: 'ok' });
    });
  });

  describe('POST /products', () => {
    it('商品を作成できる', async () => {
      const response = await app.inject({
        method: 'POST',
        url: '/products',
        payload: {
          prodCode: 'TEST00001',
          fullname: 'テスト商品',
          name: 'テスト',
          kana: 'テストショウヒン',
          unitprice: 1000,
          primeCost: 700,
          supCode: 'SUP00001',
        },
      });

      expect(response.statusCode).toBe(201);
      const data = JSON.parse(response.body);
      expect(data.prodCode).toBe('TEST00001');
    });

    it('バリデーションエラーを返す', async () => {
      const response = await app.inject({
        method: 'POST',
        url: '/products',
        payload: {
          prodCode: 'TEST00001',
          // fullname が欠落
          name: 'テスト',
          kana: 'テストショウヒン',
          unitprice: 1000,
          primeCost: 700,
          supCode: 'SUP00001',
        },
      });

      expect(response.statusCode).toBe(400);
    });
  });

  describe('GET /products', () => {
    it('すべての商品を取得できる', async () => {
      // テストデータを投入
      await app.inject({
        method: 'POST',
        url: '/products',
        payload: {
          prodCode: 'TEST00001',
          fullname: 'テスト商品1',
          name: 'テスト1',
          kana: 'テストショウヒン1',
          unitprice: 1000,
          primeCost: 700,
          supCode: 'SUP00001',
        },
      });

      const response = await app.inject({
        method: 'GET',
        url: '/products',
      });

      expect(response.statusCode).toBe(200);
      const data = JSON.parse(response.body);
      expect(data).toHaveLength(1);
    });
  });

  describe('GET /products/:prodCode', () => {
    it('ID で商品を取得できる', async () => {
      await app.inject({
        method: 'POST',
        url: '/products',
        payload: {
          prodCode: 'TEST00001',
          fullname: 'テスト商品',
          name: 'テスト',
          kana: 'テストショウヒン',
          unitprice: 1000,
          primeCost: 700,
          supCode: 'SUP00001',
        },
      });

      const response = await app.inject({
        method: 'GET',
        url: '/products/TEST00001',
      });

      expect(response.statusCode).toBe(200);
      const data = JSON.parse(response.body);
      expect(data.prodCode).toBe('TEST00001');
    });

    it('存在しない ID の場合 404 を返す', async () => {
      const response = await app.inject({
        method: 'GET',
        url: '/products/NONEXISTENT',
      });

      expect(response.statusCode).toBe(404);
    });
  });

  describe('PUT /products/:prodCode', () => {
    it('商品を更新できる', async () => {
      await app.inject({
        method: 'POST',
        url: '/products',
        payload: {
          prodCode: 'TEST00001',
          fullname: 'テスト商品',
          name: 'テスト',
          kana: 'テストショウヒン',
          unitprice: 1000,
          primeCost: 700,
          supCode: 'SUP00001',
        },
      });

      const response = await app.inject({
        method: 'PUT',
        url: '/products/TEST00001',
        payload: {
          unitprice: 1500,
        },
      });

      expect(response.statusCode).toBe(200);
      const data = JSON.parse(response.body);
      expect(data.unitprice).toBe(1500);
    });
  });

  describe('DELETE /products/:prodCode', () => {
    it('商品を削除できる', async () => {
      await app.inject({
        method: 'POST',
        url: '/products',
        payload: {
          prodCode: 'TEST00001',
          fullname: 'テスト商品',
          name: 'テスト',
          kana: 'テストショウヒン',
          unitprice: 1000,
          primeCost: 700,
          supCode: 'SUP00001',
        },
      });

      const response = await app.inject({
        method: 'DELETE',
        url: '/products/TEST00001',
      });

      expect(response.statusCode).toBe(204);

      // 削除確認
      const getResponse = await app.inject({
        method: 'GET',
        url: '/products/TEST00001',
      });
      expect(getResponse.statusCode).toBe(404);
    });
  });
});

Green(最小限の実装)

// src/application.ts
import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import cors from '@fastify/cors';
import swagger from '@fastify/swagger';
import swaggerUi from '@fastify/swagger-ui';
import { CreateProductSchema, UpdateProductSchema } from './schemas/product';
import { ProductService } from './service/product';

/**
 * Zod バリデーションヘルパー
 */
function validateRequest<T>(
  schema: any,
  body: unknown
): { success: true; data: T } | { success: false; error: any } {
  const result = schema.safeParse(body);
  if (result.success) {
    return { success: true, data: result.data };
  } else {
    return { success: false, error: result.error.errors };
  }
}

/**
 * Fastify アプリケーションを構築
 */
export async function buildApp(): Promise<FastifyInstance> {
  const app = Fastify({
    logger: true,
  });

  // CORS の設定
  await app.register(cors, {
    origin: true,
  });

  // Swagger の設定
  await app.register(swagger, {
    openapi: {
      info: {
        title: 'Sales Management API',
        description: '販売管理システム API ドキュメント',
        version: '1.0.0',
      },
      servers: [
        {
          url: 'http://localhost:3000',
          description: 'Development server',
        },
      ],
      tags: [
        { name: 'products', description: '商品関連 API' },
        { name: 'customers', description: '得意先関連 API' },
        { name: 'suppliers', description: '仕入先関連 API' },
        { name: 'stocks', description: '在庫関連 API' },
      ],
    },
  });

  // Swagger UI の設定
  await app.register(swaggerUi, {
    routePrefix: '/docs',
    uiConfig: {
      docExpansion: 'list',
      deepLinking: true,
    },
    staticCSP: true,
  });

  // サービス層のインスタンス化(シングルトン)
  const productService = new ProductService();

  /**
   * ルートエンドポイント
   */
  app.get('/', async (request: FastifyRequest, reply: FastifyReply) => {
    return {
      message: 'Sales Management API',
      version: '1.0.0',
      endpoints: ['/products', '/customers', '/suppliers', '/stocks'],
      docs: '/docs',
    };
  });

  /**
   * ヘルスチェックエンドポイント
   */
  app.get('/health', async (request: FastifyRequest, reply: FastifyReply) => {
    return { status: 'ok' };
  });

  /**
   * 商品作成エンドポイント
   */
  app.post(
    '/products',
    {
      schema: {
        tags: ['products'],
        summary: '商品を作成',
        body: {
          type: 'object',
          required: ['prodCode', 'fullname', 'name', 'kana', 'unitprice', 'primeCost', 'supCode'],
          properties: {
            prodCode: { type: 'string', maxLength: 16 },
            fullname: { type: 'string', maxLength: 40 },
            name: { type: 'string', maxLength: 10 },
            kana: { type: 'string', maxLength: 20 },
            unitprice: { type: 'integer', minimum: 0 },
            primeCost: { type: 'integer', minimum: 0 },
            supCode: { type: 'string', maxLength: 8 },
          },
        },
        response: {
          201: {
            description: 'Successful response',
            type: 'object',
            properties: {
              prodCode: { type: 'string' },
              fullname: { type: 'string' },
              name: { type: 'string' },
              kana: { type: 'string' },
              unitprice: { type: 'integer' },
              primeCost: { type: 'integer' },
              supCode: { type: 'string' },
            },
          },
        },
      },
    },
    async (request: FastifyRequest, reply: FastifyReply) => {
      try {
        const validation = validateRequest(CreateProductSchema, request.body);
        if (!validation.success) {
          return reply.code(400).send({
            error: 'Validation failed',
            details: validation.error,
          });
        }

        const product = await productService.createProduct(validation.data);
        return reply.code(201).send(product);
      } catch (error) {
        return reply.code(500).send({
          error: 'Product creation failed',
          message: error instanceof Error ? error.message : 'Unknown error',
        });
      }
    }
  );

  /**
   * 商品一覧取得エンドポイント
   */
  app.get(
    '/products',
    {
      schema: {
        tags: ['products'],
        summary: 'すべての商品を取得',
        response: {
          200: {
            description: 'Successful response',
            type: 'array',
            items: {
              type: 'object',
              properties: {
                prodCode: { type: 'string' },
                fullname: { type: 'string' },
                name: { type: 'string' },
                kana: { type: 'string' },
                unitprice: { type: 'integer' },
                primeCost: { type: 'integer' },
                supCode: { type: 'string' },
              },
            },
          },
        },
      },
    },
    async (request: FastifyRequest, reply: FastifyReply) => {
      try {
        const products = await productService.getAllProducts();
        return reply.code(200).send(products);
      } catch (error) {
        return reply.code(500).send({
          error: 'Failed to fetch products',
          message: error instanceof Error ? error.message : 'Unknown error',
        });
      }
    }
  );

  /**
   * 商品取得エンドポイント
   */
  app.get(
    '/products/:prodCode',
    {
      schema: {
        tags: ['products'],
        summary: 'ID で商品を取得',
        params: {
          type: 'object',
          required: ['prodCode'],
          properties: {
            prodCode: { type: 'string' },
          },
        },
        response: {
          200: {
            description: 'Successful response',
            type: 'object',
            properties: {
              prodCode: { type: 'string' },
              fullname: { type: 'string' },
              name: { type: 'string' },
              kana: { type: 'string' },
              unitprice: { type: 'integer' },
              primeCost: { type: 'integer' },
              supCode: { type: 'string' },
            },
          },
          404: {
            description: 'Product not found',
            type: 'object',
            properties: {
              error: { type: 'string' },
            },
          },
        },
      },
    },
    async (request: FastifyRequest<{ Params: { prodCode: string } }>, reply: FastifyReply) => {
      try {
        const product = await productService.getProductById(request.params.prodCode);
        if (!product) {
          return reply.code(404).send({ error: 'Product not found' });
        }
        return reply.code(200).send(product);
      } catch (error) {
        return reply.code(500).send({
          error: 'Failed to fetch product',
          message: error instanceof Error ? error.message : 'Unknown error',
        });
      }
    }
  );

  /**
   * 商品更新エンドポイント
   */
  app.put(
    '/products/:prodCode',
    {
      schema: {
        tags: ['products'],
        summary: '商品を更新',
        params: {
          type: 'object',
          required: ['prodCode'],
          properties: {
            prodCode: { type: 'string' },
          },
        },
        body: {
          type: 'object',
          properties: {
            fullname: { type: 'string', maxLength: 40 },
            name: { type: 'string', maxLength: 10 },
            kana: { type: 'string', maxLength: 20 },
            unitprice: { type: 'integer', minimum: 0 },
            primeCost: { type: 'integer', minimum: 0 },
            supCode: { type: 'string', maxLength: 8 },
          },
        },
        response: {
          200: {
            description: 'Successful response',
            type: 'object',
            properties: {
              prodCode: { type: 'string' },
              fullname: { type: 'string' },
              name: { type: 'string' },
              kana: { type: 'string' },
              unitprice: { type: 'integer' },
              primeCost: { type: 'integer' },
              supCode: { type: 'string' },
            },
          },
        },
      },
    },
    async (request: FastifyRequest<{ Params: { prodCode: string } }>, reply: FastifyReply) => {
      try {
        const validation = validateRequest(UpdateProductSchema, request.body);
        if (!validation.success) {
          return reply.code(400).send({
            error: 'Validation failed',
            details: validation.error,
          });
        }

        const product = await productService.updateProduct(request.params.prodCode, validation.data);
        return reply.code(200).send(product);
      } catch (error) {
        return reply.code(500).send({
          error: 'Product update failed',
          message: error instanceof Error ? error.message : 'Unknown error',
        });
      }
    }
  );

  /**
   * 商品削除エンドポイント
   */
  app.delete(
    '/products/:prodCode',
    {
      schema: {
        tags: ['products'],
        summary: '商品を削除',
        params: {
          type: 'object',
          required: ['prodCode'],
          properties: {
            prodCode: { type: 'string' },
          },
        },
        response: {
          204: {
            description: 'Successful response',
            type: 'null',
          },
        },
      },
    },
    async (request: FastifyRequest<{ Params: { prodCode: string } }>, reply: FastifyReply) => {
      try {
        await productService.deleteProduct(request.params.prodCode);
        return reply.code(204).send();
      } catch (error) {
        return reply.code(500).send({
          error: 'Product deletion failed',
          message: error instanceof Error ? error.message : 'Unknown error',
        });
      }
    }
  );

  return app;
}

/**
 * サーバー起動(CLI から実行する場合)
 */
export async function startServer(): Promise<void> {
  const app = await buildApp();

  try {
    await app.listen({ port: 3000, host: '0.0.0.0' });
    console.log('Server is running on http://localhost:3000');
    console.log('Swagger UI is available at http://localhost:3000/docs');
  } catch (err) {
    app.log.error(err);
    process.exit(1);
  }
}

// モジュールが直接実行された場合のみサーバーを起動
if (require.main === module) {
  startServer();
}

実行結果(Green)

$ npm test src/application.test.ts

 ✓ src/application.test.ts (8 tests) 8 passed
   ✓ Application Layer (8 tests) 8 passed
     ✓ GET / (1 test) 1 passed
       ✓ ルートエンドポイントが API 情報を返す
     ✓ GET /health (1 test) 1 passed
       ✓ ヘルスチェックエンドポイントが正常ステータスを返す
     ✓ POST /products (2 tests) 2 passed
       ✓ 商品を作成できる
       ✓ バリデーションエラーを返す
     ✓ GET /products (1 test) 1 passed
       ✓ すべての商品を取得できる
     ✓ GET /products/:prodCode (2 tests) 2 passed
       ✓ ID で商品を取得できる
       ✓ 存在しない ID の場合 404 を返す
     ✓ PUT /products/:prodCode (1 test) 1 passed
       ✓ 商品を更新できる
     ✓ DELETE /products/:prodCode (1 test) 1 passed
       ✓ 商品を削除できる

Swagger UI の統合

Swagger UI は、OpenAPI 仕様に基づいた対話的な API ドキュメントを提供します。

package.json に依存関係を追加

npm install --save-dev @fastify/swagger @fastify/swagger-ui @fastify/cors

Swagger UI の特徴

  1. 対話的なテスト

    • ブラウザから直接 API をテスト可能
    • リクエスト/レスポンスの確認が容易
  2. 自動ドキュメント生成

    • コードから OpenAPI スキーマを自動生成
    • 常に最新のドキュメントを保持
  3. 型安全性

    • Zod スキーマと Fastify スキーマの組み合わせ
    • コンパイル時と実行時の両方で型安全
  4. 開発者体験の向上

    • API の使い方が一目で分かる
    • チーム間のコミュニケーションコストを削減

Swagger UI へのアクセス

サーバーを起動後、以下の URL にアクセスします:

http://localhost:3000/docs

実行方法

サーバーの起動

cd db/typescript
npm run dev

または

// src/index.ts
import { startServer } from './application';

startServer();
npm run build
npm start

Swagger UI での API テスト

  1. ブラウザで http://localhost:3000/docs を開く
  2. 任意のエンドポイントを選択
  3. 「Try it out」ボタンをクリック
  4. リクエストパラメータを入力
  5. 「Execute」ボタンをクリックして実行

API エンドポイント一覧

以下のエンドポイントが利用可能です:

商品 API

メソッド エンドポイント 説明
POST /products 商品を作成
GET /products すべての商品を取得
GET /products/:prodCode ID で商品を取得
PUT /products/:prodCode 商品を更新
DELETE /products/:prodCode 商品を削除

得意先 API

メソッド エンドポイント 説明
POST /customers 得意先を作成
GET /customers すべての得意先を取得
GET /customers/:custCode ID で得意先を取得
PUT /customers/:custCode 得意先を更新
DELETE /customers/:custCode 得意先を削除

仕入先 API

メソッド エンドポイント 説明
POST /suppliers 仕入先を作成
GET /suppliers すべての仕入先を取得
GET /suppliers/:supCode ID で仕入先を取得
PUT /suppliers/:supCode 仕入先を更新
DELETE /suppliers/:supCode 仕入先を削除

在庫 API

メソッド エンドポイント 説明
POST /stocks 在庫を作成
GET /stocks すべての在庫を取得
GET /stocks/product/:prodCode 商品 ID で在庫を取得
PUT /stocks/:id 在庫を更新
DELETE /stocks/:id 在庫を削除

システム API

メソッド エンドポイント 説明
GET / API 情報を取得
GET /health ヘルスチェック
GET /docs Swagger UI

まとめ

この章では、販売管理システムのデータベースを Web API として公開する方法を学びました:

  1. レイヤードアーキテクチャ - Application/Service/Domain の 3 層分離により、保守性と拡張性を確保
  2. TDD アプローチ - Red-Green-Refactor サイクルで段階的に実装
  3. Zod バリデーション - 実行時型安全性の確保
  4. Swagger UI - 対話的な API ドキュメントの提供
  5. Prisma ORM - 型安全なデータベースアクセス

次のステップとして、以下の機能拡張が考えられます:

  • 認証・認可 - JWT トークンによる API セキュリティ
  • ページネーション - 大量データの効率的な取得
  • 検索・フィルタリング - クエリパラメータによる柔軟な検索
  • バッチ処理 - 複数レコードの一括操作
  • 監査ログ - データ変更履歴の記録
  • キャッシング - Redis によるパフォーマンス向上

まとめ:TDD で育てるデータベース

販売管理システムのデータベースがどのように進化してきたかを追体験しました。

完璧な初期設計を目指すのではなく、「テストを書く → パスさせる → 設計を改善する」 というTDDの短いサイクルを繰り返すことで、変化する要求に柔軟に対応しながら、堅牢でメンテナンス性の高いデータベーススキーマを育てていくことができます。

このアプローチは、不確実性の高い現代のソフトウェア開発において、非常に有効な武器となるでしょう。ぜひ、あなたの次のプロジェクトでも実践してみてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?