T-CREATOR

Yarn でモノレポ設計:パッケージ分割、共有ライブラリ、リリース戦略

Yarn でモノレポ設計:パッケージ分割、共有ライブラリ、リリース戦略

複数のプロジェクトを同一リポジトリで管理する「モノレポ」は、コードの再利用性を高め、開発効率を大幅に向上させる手法として注目を集めています。しかし、適切な設計なしにモノレポを導入すると、依存関係の複雑化やリリース管理の困難さに直面することになるでしょう。

本記事では、Yarn を使ったモノレポの設計手法について、パッケージの分割方法から共有ライブラリの構築、さらには効率的なリリース戦略まで、実践的な知識を段階的にお伝えします。初心者の方でも理解しやすいよう、具体的なコード例と図解を交えながら解説していきますね。

背景

モノレポとは

モノレポ(Monorepo)とは、複数のプロジェクトやパッケージを単一のリポジトリで管理する開発手法のことです。Google、Facebook、Microsoft といった大手テクノロジー企業でも採用されており、近年では中小規模のプロジェクトでも導入が進んでいます。

従来のマルチレポ(Multirepo)では、各プロジェクトが独立したリポジトリを持っていました。一方、モノレポではすべてのコードが一箇所に集約されるため、コードの共有や変更の追跡が容易になるのです。

以下の図は、マルチレポとモノレポの構造的な違いを示しています。

mermaidflowchart TB
    subgraph multirepo["マルチレポ構造"]
        repo1["リポジトリA<br/>(Web アプリ)"]
        repo2["リポジトリB<br/>(モバイルアプリ)"]
        repo3["リポジトリC<br/>(共有ライブラリ)"]
    end

    subgraph monorepo["モノレポ構造"]
        root["単一リポジトリ"]
        root --> pkgA["パッケージA<br/>(Web アプリ)"]
        root --> pkgB["パッケージB<br/>(モバイルアプリ)"]
        root --> pkgC["パッケージC<br/>(共有ライブラリ)"]
    end

この図からわかるように、モノレポでは複数のプロジェクトが階層的に整理されており、統一的な管理が可能になります。

Yarn Workspaces の役割

Yarn Workspaces は、モノレポを実現するための Yarn 組み込み機能です。複数のパッケージ間で依存関係を効率的に管理し、node_modules の重複を防ぐことができます。

Yarn Workspaces を使用すると、以下のメリットが得られるでしょう。

  • 依存関係の一元管理: ルートディレクトリで yarn install を実行するだけで、すべてのパッケージの依存関係がインストールされます
  • シンボリックリンクの自動作成: パッケージ間の参照が自動的に設定され、ローカル開発がスムーズになります
  • ディスク容量の節約: 共通の依存パッケージがルートレベルで共有されるため、重複インストールが避けられます

モノレポが求められる場面

モノレポは特に以下のようなケースで威力を発揮します。

  1. フロントエンドとバックエンドの統合管理: Web アプリケーションと API サーバーを同じリポジトリで管理したい場合
  2. 複数プラットフォーム対応: Web、iOS、Android など複数のプラットフォーム向けアプリで共通コードを共有したい場合
  3. コンポーネントライブラリの開発: UI コンポーネントライブラリと、それを使用するアプリケーションを一緒に開発したい場合
  4. マイクロサービスアーキテクチャ: 複数のマイクロサービスと共有ユーティリティを効率的に管理したい場合

課題

従来のマルチレポでの問題点

マルチレポ構成では、以下のような課題に直面することが多いです。

依存関係の管理が煩雑になります。共有ライブラリを更新するたびに、それを使用するすべてのリポジトリでバージョンアップ作業が必要になるのです。この作業は手動で行う必要があり、更新漏れが発生しやすくなります。

コードの重複も深刻な問題でしょう。各リポジトリで似たようなユーティリティ関数や型定義が個別に作成され、メンテナンスコストが増大します。

変更の追跡が困難です。関連する変更が複数のリポジトリに分散するため、レビューやデバッグが複雑になります。

以下の図は、マルチレポでの依存関係管理の課題を示しています。

mermaidflowchart LR
    lib["共有ライブラリ<br/>v1.0.0"]
    app1["アプリA<br/>(lib v1.0.0)"]
    app2["アプリB<br/>(lib v1.0.0)"]

    lib2["共有ライブラリ<br/>v1.1.0に更新"]
    app1_old["アプリA<br/>(lib v1.0.0のまま)"]
    app2_new["アプリB<br/>(lib v1.1.0に更新)"]

    lib --> lib2
    app1 --> app1_old
    app2 --> app2_new

    style app1_old fill:#ffcccc
    style app2_new fill:#ccffcc

この図から、ライブラリ更新時にバージョンの不整合が発生しやすいことがわかります。

モノレポ導入時の設計課題

モノレポを導入する際にも、適切な設計がなければ新たな問題が生じます。

パッケージの分割基準が不明確だと、どの機能をどのパッケージに配置すべきか判断できず、結果として肥大化した巨大パッケージが生まれてしまいます。

循環依存のリスクも見逃せません。パッケージ A が B に依存し、B が A に依存するような状況が発生すると、ビルドエラーや予期しない動作の原因となるのです。

ビルド時間の増大も課題でしょう。すべてのパッケージを毎回ビルドすると、開発者の待ち時間が長くなり、生産性が低下します。

リリース管理の複雑化にも注意が必要です。どのパッケージをいつリリースするか、バージョン番号をどう管理するかといった戦略が必要になります。

解決策

Yarn Workspaces の基本設定

まずは Yarn Workspaces を使ったモノレポの基本構成から見ていきましょう。

ルートディレクトリの package.json を以下のように設定します。この設定により、Yarn がワークスペースとして複数のパッケージを認識できるようになります。

json{
  "name": "my-monorepo",
  "private": true,
  "workspaces": ["packages/*", "apps/*"],
  "packageManager": "yarn@4.0.0"
}

private: true の設定は重要です。これにより、ルートパッケージが誤って npm に公開されることを防ぎます。workspaces 配列には、パッケージが配置されるディレクトリのパターンを指定しましょう。

次に、ディレクトリ構造を設計します。一般的な構成は以下のようになります。

gomy-monorepo/
├── package.json
├── yarn.lock
├── packages/
│   ├── shared-utils/
│   │   └── package.json
│   ├── ui-components/
│   │   └── package.json
│   └── api-client/
│       └── package.json
└── apps/
    ├── web-app/
    │   └── package.json
    └── mobile-app/
        └── package.json

この構造では、packages​/​ に共有ライブラリを、apps​/​ に実際のアプリケーションを配置しています。このように用途別にディレクトリを分けることで、コードベースの見通しが良くなりますね。

パッケージ分割の設計戦略

効果的なパッケージ分割には、明確な基準が必要です。以下の図は、パッケージ分割の判断フローを示しています。

mermaidflowchart TD
    start["機能の実装が必要"] --> q1{"複数のアプリで<br/>共有される?"}
    q1 -->|はい| q2{"ドメイン固有の<br/>ロジックか?"}
    q1 -->|いいえ| app["アプリ内に実装"]

    q2 -->|はい| domain["ドメインパッケージ<br/>(例: @myapp/order)"]
    q2 -->|いいえ| q3{"UI コンポーネント?"}

    q3 -->|はい| ui["UI パッケージ<br/>(例: @myapp/ui)"]
    q3 -->|いいえ| util["ユーティリティパッケージ<br/>(例: @myapp/utils)"]

このフローに従うことで、各機能を適切なパッケージに配置できます。

具体的な分割基準として、以下の 4 つのカテゴリを推奨します。

#カテゴリ用途
1ユーティリティ汎用的なヘルパー関数@myapp/utils
2UI コンポーネント再利用可能な UI 部品@myapp/ui
3ドメインロジックビジネスロジックの実装@myapp/order
4設定・型定義共通の型や設定値@myapp/types

各パッケージの package.json は以下のように設定します。ここでは共有ユーティリティパッケージの例を示します。

json{
  "name": "@myapp/utils",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "test": "jest"
  }
}

@myapp​/​ というスコープを付けることで、パッケージ名の衝突を防ぎ、組織内のパッケージであることを明示できます。

共有ライブラリの構築

共有ライブラリは、モノレポの中核となる重要な要素です。適切に設計された共有ライブラリは、コードの再利用性を高め、開発速度を向上させます。

まず、共有ユーティリティパッケージの実装から見ていきましょう。

TypeScript の設定ファイル(tsconfig.json)を作成します。この設定により、型安全なコードとして共有ライブラリを構築できます。

json{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "declaration": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

declaration: true の設定が重要です。これにより、TypeScript の型定義ファイル(.d.ts)が自動生成され、パッケージを使用する側で型補完が利用できるようになります。

次に、実際のユーティリティ関数を実装します。以下は日付操作の共通関数の例です。

typescript// packages/shared-utils/src/date.ts

/**
 * 日付を YYYY-MM-DD 形式にフォーマットします
 * @param date - フォーマット対象の日付
 * @returns フォーマットされた日付文字列
 */
export function formatDate(date: Date): string {
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(
    2,
    '0'
  );
  const day = String(date.getDate()).padStart(2, '0');

  return `${year}-${month}-${day}`;
}
typescript/**
 * 2つの日付の差分を日数で計算します
 * @param date1 - 開始日
 * @param date2 - 終了日
 * @returns 日数の差分
 */
export function daysDiff(date1: Date, date2: Date): number {
  const msPerDay = 24 * 60 * 60 * 1000;
  const diff = Math.abs(date2.getTime() - date1.getTime());

  return Math.floor(diff / msPerDay);
}

エントリーポイント(index.ts)で関数をエクスポートします。このファイルが、パッケージの公開インターフェースとなるのです。

typescript// packages/shared-utils/src/index.ts

export { formatDate, daysDiff } from './date';
export { validateEmail, validatePhone } from './validation';
export { deepClone, debounce } from './common';

UI コンポーネントライブラリも、同様の構造で構築します。React コンポーネントの例を見てみましょう。

typescript// packages/ui-components/src/Button/Button.tsx

import React from 'react';

export interface ButtonProps {
  /** ボタンのラベル */
  label: string;
  /** クリック時のイベントハンドラ */
  onClick: () => void;
  /** ボタンのスタイルバリエーション */
  variant?: 'primary' | 'secondary' | 'danger';
  /** 無効化フラグ */
  disabled?: boolean;
}
typescript/**
 * 汎用ボタンコンポーネント
 * アプリケーション全体で統一されたボタンスタイルを提供します
 */
export const Button: React.FC<ButtonProps> = ({
  label,
  onClick,
  variant = 'primary',
  disabled = false,
}) => {
  const className = `btn btn-${variant} ${
    disabled ? 'btn-disabled' : ''
  }`;

  return (
    <button
      className={className}
      onClick={onClick}
      disabled={disabled}
    >
      {label}
    </button>
  );
};

コンポーネントライブラリのエントリーポイントも同様に設定します。

typescript// packages/ui-components/src/index.ts

export { Button } from './Button/Button';
export type { ButtonProps } from './Button/Button';

export { Input } from './Input/Input';
export type { InputProps } from './Input/Input';

export { Modal } from './Modal/Modal';
export type { ModalProps } from './Modal/Modal';

パッケージ間の依存関係管理

パッケージ間で共有ライブラリを使用する際の設定方法を解説します。

アプリケーションの package.json に、ローカルパッケージへの依存を追加します。以下は Web アプリケーションの例です。

json{
  "name": "@myapp/web-app",
  "version": "1.0.0",
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "@myapp/utils": "workspace:*",
    "@myapp/ui": "workspace:*"
  }
}

workspace:* という記法が重要です。これにより、Yarn は npm レジストリではなく、ワークスペース内のローカルパッケージを参照するようになります。

ルートディレクトリで以下のコマンドを実行すると、すべての依存関係がインストールされ、シンボリックリンクが自動的に作成されます。

bashyarn install

実際にアプリケーションで共有ライブラリを使用する例を見てみましょう。

typescript// apps/web-app/src/pages/UserProfile.tsx

import React from 'react';
import { Button } from '@myapp/ui';
import { formatDate } from '@myapp/utils';

interface User {
  name: string;
  email: string;
  createdAt: Date;
}
typescriptexport const UserProfile: React.FC<{ user: User }> = ({
  user,
}) => {
  const handleEdit = () => {
    console.log('Edit user:', user.name);
  };

  return (
    <div className='user-profile'>
      <h2>{user.name}</h2>
      <p>Email: {user.email}</p>
      <p>Member since: {formatDate(user.createdAt)}</p>

      <Button
        label='プロフィールを編集'
        onClick={handleEdit}
        variant='primary'
      />
    </div>
  );
};

このように、共有ライブラリをインポートするだけで、すぐに利用できるようになります。TypeScript の型定義も自動的に認識されるため、IDE での補完が効きますね。

循環依存の防止

循環依存は、モノレポ設計における最大の落とし穴の一つです。以下の図は、循環依存が発生するパターンを示しています。

mermaidflowchart LR
    pkgA["パッケージA<br/>(UI コンポーネント)"] -->|依存| pkgB["パッケージB<br/>(ビジネスロジック)"]
    pkgB -->|依存| pkgA

    style pkgA fill:#ffcccc
    style pkgB fill:#ffcccc

この状況を避けるために、以下の設計原則を守りましょう。

依存関係の方向を一方向に保つことが基本です。上位層のパッケージが下位層のパッケージに依存する形にし、その逆は避けます。

依存関係の階層を定義すると、以下のようになります。

#レイヤー説明
1アプリケーション層実際のアプリケーション@myapp/web-app
2ドメイン層ビジネスロジック@myapp/order
3プレゼンテーション層UI コンポーネント@myapp/ui
4基盤層ユーティリティ・型定義@myapp/utils

上位層は下位層に依存できますが、下位層は上位層に依存してはいけません。

循環依存を検出するためのツールを導入することも効果的です。以下のスクリプトを package.json に追加しましょう。

json{
  "scripts": {
    "check-deps": "yarn workspaces foreach -A exec madge --circular --extensions ts,tsx src/"
  },
  "devDependencies": {
    "madge": "^6.1.0"
  }
}

このコマンドを実行すると、循環依存が検出された場合にエラーが表示されます。

bashyarn check-deps

ビルド最適化戦略

モノレポでは、変更されたパッケージのみをビルドする「増分ビルド」が重要です。

Turborepo や Nx といったビルドツールを活用すると、効率的なビルドが実現できます。ここでは Turborepo を使った例を紹介しましょう。

まず、Turborepo をインストールします。

bashyarn add -D turbo

ルートディレクトリに turbo.json を作成し、ビルドパイプラインを定義します。この設定により、依存関係を考慮した並列ビルドが可能になります。

json{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": []
    },
    "lint": {
      "outputs": []
    }
  }
}

dependsOn: ["^build"] の設定により、依存先のパッケージが先にビルドされるようになります。outputs には、キャッシュ対象となる出力ディレクトリを指定しましょう。

package.json にビルドスクリプトを追加します。

json{
  "scripts": {
    "build": "turbo run build",
    "test": "turbo run test",
    "lint": "turbo run lint"
  }
}

以下のコマンドで、すべてのパッケージを効率的にビルドできます。変更されていないパッケージはキャッシュから復元されるため、ビルド時間が大幅に短縮されるのです。

bashyarn build

リリース戦略の設計

モノレポでのリリース管理には、主に 2 つのアプローチがあります。

**Fixed versioning(固定バージョニング)**は、すべてのパッケージが同じバージョン番号を共有する方式です。リリース管理がシンプルになりますが、変更のないパッケージもバージョンが上がってしまいます。

**Independent versioning(独立バージョニング)**は、各パッケージが独自のバージョン番号を持つ方式です。柔軟性が高い反面、管理が複雑になるでしょう。

以下の図は、2 つのバージョニング戦略の違いを示しています。

mermaidflowchart TB
    subgraph fixed["Fixed Versioning"]
        f_release["リリース v2.0.0"]
        f_release --> f_pkg1["@myapp/ui<br/>v2.0.0"]
        f_release --> f_pkg2["@myapp/utils<br/>v2.0.0"]
        f_release --> f_pkg3["@myapp/api<br/>v2.0.0"]
    end

    subgraph independent["Independent Versioning"]
        i_release["リリース"]
        i_release --> i_pkg1["@myapp/ui<br/>v2.1.0(変更あり)"]
        i_release --> i_pkg2["@myapp/utils<br/>v1.5.0(変更なし)"]
        i_release --> i_pkg3["@myapp/api<br/>v3.0.0(変更あり)"]
    end

この図から、Fixed versioning ではすべてが同一バージョンに、Independent versioning では個別のバージョンになることがわかります。

Changesets を使った自動バージョニングの実装方法を見ていきましょう。Changesets は、変更履歴とバージョン管理を自動化する優れたツールです。

bashyarn add -D @changesets/cli
yarn changeset init

初期化すると、.changeset ディレクトリと設定ファイルが作成されます。config.json を編集して、バージョニング戦略を設定します。

json{
  "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "fixed": [],
  "linked": [],
  "access": "restricted",
  "baseBranch": "main",
  "updateInternalDependencies": "patch"
}

fixed を空配列にすることで、Independent versioning を選択しています。Fixed versioning を使いたい場合は、"fixed": [["@myapp​/​*"]] のように設定しましょう。

開発者が変更を加えた際の workflow は以下のようになります。

変更を加えたら、changeset を作成します。

bashyarn changeset

対話的なプロンプトが表示され、以下の情報を入力します。

kotlinWhich packages would you like to include?
› [x] @myapp/ui
  [ ] @myapp/utils
  [ ] @myapp/api

What kind of change is this for @myapp/ui?
› patch (0.0.1)
  minor (0.1.0)
  major (1.0.0)

Please enter a summary for this change:
› ボタンコンポーネントにアイコン表示機能を追加

この操作により、.changeset ディレクトリに変更内容を記録したファイルが作成されます。Git にコミットする際、この changeset ファイルも一緒にコミットしましょう。

bashgit add .changeset/
git commit -m "feat: ボタンにアイコン表示機能を追加"

リリース時には、以下のコマンドでバージョンを更新します。

bashyarn changeset version

このコマンドを実行すると、changeset ファイルに基づいて package.json のバージョンが自動更新され、CHANGELOG.md も生成されます。

最後に、パッケージをビルドして公開します。

bashyarn build
yarn changeset publish

CI/CD での自動リリースも設定できます。GitHub Actions の例を見てみましょう。

yaml# .github/workflows/release.yml

name: Release

on:
  push:
    branches:
      - main

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
yaml- name: Install dependencies
  run: yarn install --frozen-lockfile

- name: Build packages
  run: yarn build

- name: Create Release Pull Request
  uses: changesets/action@v1
  with:
    publish: yarn changeset publish
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

この設定により、main ブランチへのプッシュ時に自動的にリリース PR が作成されます。PR をマージすると、パッケージが自動的に公開されるのです。

具体例

実践的なモノレポプロジェクト構築

ここでは、EC サイトのモノレポプロジェクトを例に、実際の構築手順を見ていきましょう。このプロジェクトには、Web アプリケーション、管理画面、共有 UI コンポーネント、API クライアントが含まれます。

以下の図は、プロジェクト全体のアーキテクチャを示しています。

mermaidflowchart TB
    subgraph apps["アプリケーション層"]
        web["Web アプリ<br/>@myshop/web"]
        admin["管理画面<br/>@myshop/admin"]
    end

    subgraph packages["共有パッケージ層"]
        ui["UI コンポーネント<br/>@myshop/ui"]
        api["API クライアント<br/>@myshop/api-client"]
        utils["ユーティリティ<br/>@myshop/utils"]
        types["型定義<br/>@myshop/types"]
    end

    web --> ui
    web --> api
    web --> types
    admin --> ui
    admin --> api
    admin --> types
    ui --> utils
    api --> types

この図から、依存関係が一方向に保たれていることがわかります。アプリケーション層が共有パッケージ層に依存し、その逆はありません。

まず、プロジェクトの初期化から始めます。

bashmkdir my-shop-monorepo
cd my-shop-monorepo
yarn init -y

ルートの package.json を編集して、ワークスペースを設定します。

json{
  "name": "my-shop-monorepo",
  "private": true,
  "workspaces": ["packages/*", "apps/*"],
  "packageManager": "yarn@4.0.0",
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev --parallel",
    "test": "turbo run test",
    "lint": "turbo run lint"
  }
}

ディレクトリ構造を作成します。

bashmkdir -p packages/{ui,api-client,utils,types}
mkdir -p apps/{web,admin}

型定義パッケージの実装

型定義パッケージは、すべてのパッケージで共有される TypeScript の型を提供します。

json{
  "name": "@myshop/types",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch"
  },
  "devDependencies": {
    "typescript": "^5.0.0"
  }
}

共通の型定義を作成します。

typescript// packages/types/src/product.ts

/**
 * 商品の基本情報
 */
export interface Product {
  /** 商品ID */
  id: string;
  /** 商品名 */
  name: string;
  /** 商品説明 */
  description: string;
  /** 価格(円) */
  price: number;
  /** 在庫数 */
  stock: number;
  /** カテゴリID */
  categoryId: string;
  /** 画像URL */
  imageUrl: string;
  /** 作成日時 */
  createdAt: Date;
  /** 更新日時 */
  updatedAt: Date;
}
typescript/**
 * 商品一覧の検索条件
 */
export interface ProductSearchParams {
  /** カテゴリで絞り込み */
  categoryId?: string;
  /** 価格の下限 */
  minPrice?: number;
  /** 価格の上限 */
  maxPrice?: number;
  /** 検索キーワード */
  keyword?: string;
  /** ページ番号(1始まり) */
  page?: number;
  /** 1ページあたりの件数 */
  limit?: number;
}

ユーザー関連の型も定義します。

typescript// packages/types/src/user.ts

/**
 * ユーザー情報
 */
export interface User {
  /** ユーザーID */
  id: string;
  /** メールアドレス */
  email: string;
  /** ユーザー名 */
  name: string;
  /** ユーザーの役割 */
  role: 'customer' | 'admin';
  /** 作成日時 */
  createdAt: Date;
}
typescript/**
 * ログイン情報
 */
export interface LoginCredentials {
  /** メールアドレス */
  email: string;
  /** パスワード */
  password: string;
}

/**
 * 認証トークン
 */
export interface AuthToken {
  /** アクセストークン */
  accessToken: string;
  /** リフレッシュトークン */
  refreshToken: string;
  /** 有効期限(UNIX タイムスタンプ) */
  expiresAt: number;
}

エントリーポイントですべての型をエクスポートします。

typescript// packages/types/src/index.ts

export type {
  Product,
  ProductSearchParams,
} from './product';

export type {
  User,
  LoginCredentials,
  AuthToken,
} from './user';

API クライアントパッケージの実装

API クライアントパッケージは、バックエンド API との通信を抽象化します。型定義パッケージに依存する形で実装しましょう。

json{
  "name": "@myshop/api-client",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "dependencies": {
    "@myshop/types": "workspace:*",
    "axios": "^1.6.0"
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch"
  }
}

API クライアントの基底クラスを実装します。

typescript// packages/api-client/src/client.ts

import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';

/**
 * API クライアントの基底クラス
 * すべての API リクエストの共通処理を提供します
 */
export class ApiClient {
  private instance: AxiosInstance;

  constructor(baseURL: string) {
    this.instance = axios.create({
      baseURL,
      timeout: 10000,
      headers: {
        'Content-Type': 'application/json',
      },
    });

    this.setupInterceptors();
  }
typescript  /**
   * リクエスト/レスポンスのインターセプターを設定
   */
  private setupInterceptors(): void {
    // リクエストインターセプター
    this.instance.interceptors.request.use(
      (config) => {
        // ローカルストレージからトークンを取得して設定
        const token = localStorage.getItem('accessToken');
        if (token) {
          config.headers.Authorization = `Bearer ${token}`;
        }
        return config;
      },
      (error) => Promise.reject(error)
    );

    // レスポンスインターセプター
    this.instance.interceptors.response.use(
      (response) => response,
      (error) => {
        // 401エラーの場合はログアウト処理
        if (error.response?.status === 401) {
          localStorage.removeItem('accessToken');
          window.location.href = '/login';
        }
        return Promise.reject(error);
      }
    );
  }
typescript  /**
   * GETリクエストを実行
   */
  async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
    const response = await this.instance.get<T>(url, config);
    return response.data;
  }

  /**
   * POSTリクエストを実行
   */
  async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
    const response = await this.instance.post<T>(url, data, config);
    return response.data;
  }

  /**
   * PUTリクエストを実行
   */
  async put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
    const response = await this.instance.put<T>(url, data, config);
    return response.data;
  }

  /**
   * DELETEリクエストを実行
   */
  async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
    const response = await this.instance.delete<T>(url, config);
    return response.data;
  }
}

商品 API のクライアントを実装します。型定義パッケージの型を活用することで、型安全な API クライアントが実現できますね。

typescript// packages/api-client/src/product.ts

import { Product, ProductSearchParams } from '@myshop/types';
import { ApiClient } from './client';

/**
 * 商品APIクライアント
 */
export class ProductApi {
  constructor(private client: ApiClient) {}

  /**
   * 商品一覧を取得
   */
  async list(params?: ProductSearchParams): Promise<Product[]> {
    return this.client.get<Product[]>('/products', { params });
  }
typescript  /**
   * 商品詳細を取得
   */
  async get(id: string): Promise<Product> {
    return this.client.get<Product>(`/products/${id}`);
  }

  /**
   * 商品を作成(管理者用)
   */
  async create(data: Omit<Product, 'id' | 'createdAt' | 'updatedAt'>): Promise<Product> {
    return this.client.post<Product>('/products', data);
  }

  /**
   * 商品を更新(管理者用)
   */
  async update(id: string, data: Partial<Product>): Promise<Product> {
    return this.client.put<Product>(`/products/${id}`, data);
  }

  /**
   * 商品を削除(管理者用)
   */
  async delete(id: string): Promise<void> {
    return this.client.delete<void>(`/products/${id}`);
  }
}

エントリーポイントで API クライアントをエクスポートします。

typescript// packages/api-client/src/index.ts

import { ApiClient } from './client';
import { ProductApi } from './product';

/**
 * APIクライアントのファクトリー関数
 */
export function createApiClient(baseURL: string) {
  const client = new ApiClient(baseURL);

  return {
    products: new ProductApi(client),
  };
}

export type { ApiClient };

UI コンポーネントパッケージの実装

UI コンポーネントパッケージでは、アプリケーション全体で使用する共通コンポーネントを提供します。

json{
  "name": "@myshop/ui",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "dependencies": {
    "@myshop/utils": "workspace:*",
    "react": "^18.2.0"
  },
  "peerDependencies": {
    "react": "^18.2.0"
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch"
  }
}

商品カードコンポーネントを実装します。

typescript// packages/ui/src/ProductCard/ProductCard.tsx

import React from 'react';
import { Product } from '@myshop/types';
import { formatPrice } from '@myshop/utils';

export interface ProductCardProps {
  /** 表示する商品 */
  product: Product;
  /** カートに追加ボタンのクリックハンドラ */
  onAddToCart?: (product: Product) => void;
}
typescript/**
 * 商品カードコンポーネント
 * 商品の画像、名前、価格を表示し、カートへの追加が可能です
 */
export const ProductCard: React.FC<ProductCardProps> = ({
  product,
  onAddToCart,
}) => {
  const handleAddToCart = () => {
    if (onAddToCart) {
      onAddToCart(product);
    }
  };

  return (
    <div className='product-card'>
      <img
        src={product.imageUrl}
        alt={product.name}
        className='product-card__image'
      />

      <div className='product-card__content'>
        <h3 className='product-card__name'>
          {product.name}
        </h3>
        <p className='product-card__description'>
          {product.description}
        </p>

        <div className='product-card__footer'>
          <span className='product-card__price'>
            {formatPrice(product.price)}
          </span>

          {product.stock > 0 ? (
            <button
              onClick={handleAddToCart}
              className='product-card__button'
            >
              カートに追加
            </button>
          ) : (
            <span className='product-card__out-of-stock'>
              在庫切れ
            </span>
          )}
        </div>
      </div>
    </div>
  );
};

ページネーションコンポーネントも実装しましょう。

typescript// packages/ui/src/Pagination/Pagination.tsx

import React from 'react';

export interface PaginationProps {
  /** 現在のページ番号(1始まり) */
  currentPage: number;
  /** 総ページ数 */
  totalPages: number;
  /** ページ変更時のハンドラ */
  onPageChange: (page: number) => void;
}
typescript/**
 * ページネーションコンポーネント
 * ページ間の移動を提供します
 */
export const Pagination: React.FC<PaginationProps> = ({
  currentPage,
  totalPages,
  onPageChange,
}) => {
  const pages = Array.from(
    { length: totalPages },
    (_, i) => i + 1
  );

  return (
    <div className='pagination'>
      <button
        onClick={() => onPageChange(currentPage - 1)}
        disabled={currentPage === 1}
        className='pagination__button'
      >
        前へ
      </button>

      {pages.map((page) => (
        <button
          key={page}
          onClick={() => onPageChange(page)}
          className={`pagination__page ${
            page === currentPage
              ? 'pagination__page--active'
              : ''
          }`}
        >
          {page}
        </button>
      ))}

      <button
        onClick={() => onPageChange(currentPage + 1)}
        disabled={currentPage === totalPages}
        className='pagination__button'
      >
        次へ
      </button>
    </div>
  );
};

Web アプリケーションの実装

Web アプリケーションでは、これまで作成した共有パッケージを活用します。

json{
  "name": "@myshop/web",
  "version": "1.0.0",
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "next": "^14.0.0",
    "@myshop/ui": "workspace:*",
    "@myshop/api-client": "workspace:*",
    "@myshop/types": "workspace:*",
    "@myshop/utils": "workspace:*"
  },
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  }
}

商品一覧ページを実装します。共有パッケージの型、API クライアント、UI コンポーネントをすべて活用していますね。

typescript// apps/web/src/pages/products/index.tsx

import React, { useState, useEffect } from 'react';
import { Product } from '@myshop/types';
import { createApiClient } from '@myshop/api-client';
import { ProductCard, Pagination } from '@myshop/ui';

const apiClient = createApiClient(
  process.env.NEXT_PUBLIC_API_URL || ''
);
typescriptexport default function ProductsPage() {
  const [products, setProducts] = useState<Product[]>([]);
  const [currentPage, setCurrentPage] = useState(1);
  const [totalPages, setTotalPages] = useState(1);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    loadProducts();
  }, [currentPage]);

  const loadProducts = async () => {
    setLoading(true);
    try {
      const data = await apiClient.products.list({
        page: currentPage,
        limit: 12,
      });
      setProducts(data);
      // 実際にはAPIからtotalPagesも取得する
      setTotalPages(5);
    } catch (error) {
      console.error('商品の取得に失敗しました:', error);
    } finally {
      setLoading(false);
    }
  };
typescript  const handleAddToCart = (product: Product) => {
    console.log('カートに追加:', product.name);
    // カート追加のロジック
  };

  if (loading) {
    return <div>読み込み中...</div>;
  }

  return (
    <div className="products-page">
      <h1>商品一覧</h1>

      <div className="products-grid">
        {products.map((product) => (
          <ProductCard
            key={product.id}
            product={product}
            onAddToCart={handleAddToCart}
          />
        ))}
      </div>

      <Pagination
        currentPage={currentPage}
        totalPages={totalPages}
        onPageChange={setCurrentPage}
      />
    </div>
  );
}

ビルドとデプロイの実行

すべてのパッケージをビルドして、動作確認を行います。

ルートディレクトリで依存関係をインストールします。

bashyarn install

すべてのパッケージをビルドします。Turborepo により、依存関係の順序が自動的に解決されます。

bashyarn build

開発サーバーを起動して、動作確認を行いましょう。

bashyarn dev

このコマンドにより、Web アプリケーションと管理画面が並行して起動します。ホットリロードも有効になるため、コードの変更が即座に反映されますね。

まとめ

Yarn を使ったモノレポ設計について、パッケージ分割から共有ライブラリの構築、リリース戦略まで包括的に解説してきました。

モノレポの成功には、明確な依存関係の設計が不可欠です。一方向の依存関係を保ち、循環依存を避けることで、保守性の高いコードベースが実現できます。

共有ライブラリの適切な分割も重要なポイントでしょう。ユーティリティ、UI コンポーネント、ドメインロジック、型定義といったカテゴリに分けることで、各パッケージの責務が明確になります。

ビルド最適化とリリース自動化により、開発体験が大きく向上します。Turborepo による増分ビルドと、Changesets による自動バージョニングを組み合わせることで、効率的な開発フローが構築できるのです。

モノレポは、初期の設計コストはかかりますが、長期的にはコードの再利用性向上、変更の追跡容易化、開発効率の向上といった大きなメリットをもたらします。

本記事で紹介した手法を参考に、ぜひあなたのプロジェクトでもモノレポ設計に挑戦してみてください。最初は小規模から始めて、徐々に拡張していくアプローチがおすすめですよ。

関連リンク