この記事で得られること

  • Next.js + Go のモノレポ構成パターン
  • pnpm workspace + Turborepo の実践的な使い方
  • 4ポータルで共通UIを使い回す設計
  • ソロ開発で破綻しないパッケージ分割の考え方

はじめに

第1回で紹介した通り、Saruは4階層のアカウント構造を持つマルチテナントSaaSだ。これを実現するために、4つのフロントエンド + 4つのバックエンドAPIという構成を採用している。

普通に考えると、8つのリポジトリを管理することになる。ソロ開発では破綻する。

そこでモノレポを採用した。本記事では、その構成と設計判断を解説する。

1. なぜNext.js × Go なのか

技術選定の理由

領域技術選定理由
FrontendNext.js 14App Router、RSC、豊富なエコシステム
BackendGo + Echoシンプル、高速、型安全、デプロイが楽
DBPostgreSQLRLSによるマルチテナント分離

なぜフルスタックフレームワーク(Next.js API Routes)を使わないのか?

  • 関心の分離: フロントエンドとバックエンドのデプロイサイクルを分けたい
  • 言語の強み: Goの方が複雑なビジネスロジックを書きやすい(個人の感想)
  • スケーラビリティ: 将来的にAPIだけスケールさせる可能性

認証フローは以下の分担:Keycloakがユーザー認証、NextAuthがOAuth/セッション管理、Go APIはKeycloakのアクセストークン(JWT)を検証して権限チェックを行う。

2. プロジェクト構成

saru/
├── apps/                    # 6つのNext.jsアプリ
│   ├── system/              # System Portal (管理者)
│   ├── provider/            # Provider Portal (サービス提供者)
│   ├── reseller/            # Reseller Portal (販売代理)
│   ├── consumer/            # Consumer Portal (利用者)
│   ├── customer/            # Customer Portal (レガシー名、consumerと統合予定)
│   └── landing/             # ランディングページ
│
├── packages/                # 共有パッケージ
│   ├── types/               # TypeScript型定義
│   ├── ui/                  # 共通UIコンポーネント
│   ├── api-client/          # APIクライアント + React Query hooks
│   ├── auth/                # NextAuth設定
│   ├── config/              # ESLint, TypeScript設定
│   └── env-validator/       # 環境変数バリデーション
│
├── backend/                 # Go バックエンド
│   ├── cmd/
│   │   ├── system-api/      # System API (port 8080)
│   │   ├── provider-api/    # Provider API (port 8081)
│   │   ├── reseller-api/    # Reseller API (port 8082)
│   │   ├── consumer-api/    # Consumer API (port 8083)
│   │   └── migrate/         # マイグレーションCLI
│   └── internal/            # 共通ロジック
│
├── e2e/                     # Playwright E2Eテスト
├── pnpm-workspace.yaml      # pnpm workspace設定
└── turbo.json               # Turborepo設定

なぜ4つのAPIに分けているのか

「1つのAPIで全部まかなえばいいのでは?」という疑問があるかもしれない。

分けた理由:

  1. 権限境界の明確化: System APIはシステム管理者のみ、Provider APIはプロバイダーのみがアクセス
  2. デプロイの独立性: Provider APIだけ更新したい時に他に影響しない
  3. コードの見通し: 1つのAPIに全エンドポイントがあると複雑になる

共通ロジックは internal/ で共有:

backend/internal/
├── domain/           # ドメインモデル
├── application/      # ユースケース
├── infrastructure/   # DB, 外部サービス
└── interfaces/       # ハンドラ、DTO

4つのAPIは同じ internal/ を参照し、必要なハンドラだけをルーターに登録する。

3. pnpm workspace + Turborepo

pnpm-workspace.yaml

1
2
3
packages:
  - "apps/*"
  - "packages/*"

シンプル。apps/packages/ 配下がワークスペースになる。

turbo.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "dependsOn": ["^lint"]
    },
    "type-check": {
      "dependsOn": ["^build"]
    }
  }
}

ポイント:

  • "dependsOn": ["^build"] — 依存パッケージを先にビルド
  • devcache: false — 開発サーバーはキャッシュしない
  • type-check^build に依存 — 型定義パッケージのビルドが先

よく使うコマンド

1
2
3
4
5
6
7
8
# 全アプリをビルド
pnpm build

# 特定のアプリだけ開発
pnpm dev:system    # System Portalのみ

# 全体のlint + type-check
pnpm lint && pnpm type-check

4. 共有パッケージの設計

@repo/types — 型定義

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// packages/types/src/product.ts
export interface Product {
  id: string;
  code: string;
  name: string;
  status: ProductStatus;
  providerId: string;
  // ...
}

export type ProductStatus = 'draft' | 'published' | 'archived' | 'discontinued';

なぜ型だけ別パッケージにするのか:

  • 複数のアプリで同じ型を使う
  • バックエンドのレスポンス型と揃える
  • 変更があれば全アプリに反映される

@repo/ui — 共通UIコンポーネント

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// packages/ui/src/components/ProductStatusBadge.tsx
import { ProductStatus } from '@repo/types';

const statusConfig = {
  draft: { label: '下書き', variant: 'secondary' },
  published: { label: '公開中', variant: 'success' },
  archived: { label: 'アーカイブ', variant: 'muted' },
  discontinued: { label: '販売終了', variant: 'destructive' },
};

export function ProductStatusBadge({ status }: { status: ProductStatus }) {
  const config = statusConfig[status];
  return <Badge variant={config.variant}>{config.label}</Badge>;
}

共通化の判断基準:

条件配置先
2つ以上のポータルで同一実装packages/ui/
1つのポータル専用apps/[portal]/src/components/
ポータル固有のビジネスロジック含む各アプリに配置

@repo/api-client — APIクライアント + React Query

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// packages/api-client/src/hooks/useProducts.ts
import { useQuery, useMutation } from '@tanstack/react-query';
import type { Product } from '@repo/types';

export function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: () => apiClient.get<Product[]>('/products'),
  });
}

export function useCreateProduct() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (data: CreateProductRequest) =>
      apiClient.post<Product>('/products', data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['products'] });
    },
  });
}

なぜhooksを共通化するのか:

  • 同じAPIを叩くロジックを各アプリで書かない
  • キャッシュ戦略を統一
  • 型安全なAPI呼び出し

@repo/auth — NextAuth設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// packages/auth/src/index.ts
import NextAuth from 'next-auth';
import Keycloak from 'next-auth/providers/keycloak';

export const { auth, handlers, signIn, signOut } = NextAuth({
  providers: [
    Keycloak({
      clientId: process.env.KEYCLOAK_CLIENT_ID!,
      clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
      issuer: process.env.KEYCLOAK_ISSUER!,
    }),
  ],
  // ...
});

4つのポータルすべてで同じKeycloak設定を使う。

5. Go バックエンドの構成

4つのAPIエントリポイント

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// backend/cmd/system-api/main.go
func main() {
    e := echo.New()

    // System API専用のルート
    systemRouter := router.NewSystemRouter(e, services)
    systemRouter.RegisterRoutes()

    e.Start(":8080")
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// backend/cmd/provider-api/main.go
func main() {
    e := echo.New()

    // Provider API専用のルート
    providerRouter := router.NewProviderRouter(e, services)
    providerRouter.RegisterRoutes()

    e.Start(":8081")
}

共通ロジックの共有

1
2
3
4
5
6
7
8
// backend/internal/application/product_service.go
type ProductService struct {
    repo repository.ProductRepository
}

func (s *ProductService) Create(ctx context.Context, req CreateProductRequest) (*Product, error) {
    // System API, Provider API 両方から呼ばれる
}

どのAPIからも同じServiceを使う。違いはルーターで登録するエンドポイントだけ。

認可の多層防御

認可チェックの流れ:
1. Go API: JWTの署名検証 + アカウント種別チェック
2. Go API: ビジネスロジック層で操作権限チェック
3. PostgreSQL RLS: データアクセス時のテナント分離(最終防衛線)

API分割とRLSは別の責務を担う:

  • API分割: エンドポイントレベルのアクセス制御(誰がどの機能を呼べるか)
  • RLS: データレベルの分離(どのデータにアクセスできるか)

この多層防御により、仮にAPIの権限チェックに漏れがあっても、RLSが被害範囲を限定する(適切に設定されている前提)。

6. 型の共有:TypeScript ↔ Go

完全な自動同期はしていない。代わりに:

  1. OpenAPI仕様書を正として定義
  2. TypeScriptは openapi-typescript で生成
  3. Goは手動で合わせる(将来的には oapi-codegen 導入予定)
1
2
3
4
# packages/types/package.json
"scripts": {
  "generate:types": "openapi-typescript ../../specs/shared-schemas.yaml -o src/generated/shared-schemas.ts"
}

正直な話: 完全自動化はできていない。Goの型とTypeScriptの型がズレることはある。E2Eテストで主要なフローは検知できるが、網羅的ではない。将来的にはOpenAPIスキーマのCIチェックで補完予定。

7. 開発サーバーの起動

1
2
# 統合スクリプトで全サービス起動
./scripts/start-dev.sh

内部的には:

  1. Docker(PostgreSQL, Keycloak, Mailpit)を起動
  2. Go APIを4つ起動(air でホットリロード)
  3. Next.jsアプリを必要に応じて起動
1
2
3
# 個別起動も可能
pnpm dev:system     # System Portalだけ
pnpm dev:provider   # Provider Portalだけ

必要な環境変数

各ポータルで必要な主な環境変数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 認証(Keycloak)
KEYCLOAK_CLIENT_ID=xxx
KEYCLOAK_CLIENT_SECRET=xxx
KEYCLOAK_ISSUER=http://localhost:8180/realms/saru

# API接続
NEXT_PUBLIC_API_URL=http://localhost:808x

# NextAuth
NEXTAUTH_SECRET=xxx
NEXTAUTH_URL=http://localhost:300x

package.json スクリプト例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// ルートのpackage.json
{
  "scripts": {
    "dev": "turbo run dev",
    "dev:system": "turbo run dev --filter=system",
    "dev:provider": "turbo run dev --filter=provider",
    "build": "turbo run build",
    "lint": "turbo run lint",
    "type-check": "turbo run type-check"
  }
}

8. ソロ開発で破綻しないために

やっていること

施策効果
共通パッケージ化同じコードを4箇所に書かない
Turborepoビルドキャッシュで高速化
E2Eテストリグレッションを自動検知
統合スクリプト起動手順を覚えなくていい

やらないこと

避けていること理由
マイクロサービス化4つのAPIはすでに十分複雑
過度な抽象化3回以上重複してから共通化
自動型同期設定コストが高い、E2E+将来のCIチェックで対応

まとめ

構成要素採用技術ポイント
ワークスペースpnpm workspaceapps/ + packages/ のシンプル構成
ビルドTurborepodependsOn で依存順序を管理
共通型@repo/types全アプリで型を共有
共通UI@repo/ui2つ以上で使うコンポーネントを集約
APIクライアント@repo/api-clientReact Query hooks を共通化
認証@repo/authNextAuth設定を共通化
BackendGo + Echo4 API、internal/ で共通ロジック共有

モノレポは初期設定のコストがあるが、一度構築すれば「あのアプリにも同じコンポーネントを追加して…」という作業が激減する。

ソロ開発で複数アプリを管理するなら、モノレポは強い選択肢だと思う。


シリーズ記事