Next.js × Go モノレポ構成:4ポータル × 4APIを1人で保守する設計【第3回】
この記事で得られること
- Next.js + Go のモノレポ構成パターン
- pnpm workspace + Turborepo の実践的な使い方
- 4ポータルで共通UIを使い回す設計
- ソロ開発で破綻しないパッケージ分割の考え方
はじめに
第1回で紹介した通り、Saruは4階層のアカウント構造を持つマルチテナントSaaSだ。これを実現するために、4つのフロントエンド + 4つのバックエンドAPIという構成を採用している。
普通に考えると、8つのリポジトリを管理することになる。ソロ開発では破綻する。
そこでモノレポを採用した。本記事では、その構成と設計判断を解説する。
1. なぜNext.js × Go なのか
技術選定の理由
| 領域 | 技術 | 選定理由 |
|---|---|---|
| Frontend | Next.js 14 | App Router、RSC、豊富なエコシステム |
| Backend | Go + Echo | シンプル、高速、型安全、デプロイが楽 |
| DB | PostgreSQL | RLSによるマルチテナント分離 |
なぜフルスタックフレームワーク(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で全部まかなえばいいのでは?」という疑問があるかもしれない。
分けた理由:
- 権限境界の明確化: System APIはシステム管理者のみ、Provider APIはプロバイダーのみがアクセス
- デプロイの独立性: Provider APIだけ更新したい時に他に影響しない
- コードの見通し: 1つのAPIに全エンドポイントがあると複雑になる
共通ロジックは internal/ で共有:
backend/internal/
├── domain/ # ドメインモデル
├── application/ # ユースケース
├── infrastructure/ # DB, 外部サービス
└── interfaces/ # ハンドラ、DTO
4つのAPIは同じ internal/ を参照し、必要なハンドラだけをルーターに登録する。
3. pnpm workspace + Turborepo
pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
シンプル。apps/ と packages/ 配下がワークスペースになる。
turbo.json
{
"$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"]— 依存パッケージを先にビルド -
devはcache: false— 開発サーバーはキャッシュしない -
type-checkは^buildに依存 — 型定義パッケージのビルドが先
よく使うコマンド
# 全アプリをビルド
pnpm build
# 特定のアプリだけ開発
pnpm dev:system # System Portalのみ
# 全体のlint + type-check
pnpm lint && pnpm type-check
4. 共有パッケージの設計
@repo/types — 型定義
// 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コンポーネント
// 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
// 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設定
// 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エントリポイント
// backend/cmd/system-api/main.go
func main() {
e := echo.New()
// System API専用のルート
systemRouter := router.NewSystemRouter(e, services)
systemRouter.RegisterRoutes()
e.Start(":8080")
}
// backend/cmd/provider-api/main.go
func main() {
e := echo.New()
// Provider API専用のルート
providerRouter := router.NewProviderRouter(e, services)
providerRouter.RegisterRoutes()
e.Start(":8081")
}
共通ロジックの共有
// 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
完全な自動同期はしていない。代わりに:
- OpenAPI仕様書を正として定義
- TypeScriptは
openapi-typescriptで生成 - Goは手動で合わせる(将来的には
oapi-codegen導入予定)
# 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. 開発サーバーの起動
# 統合スクリプトで全サービス起動
./scripts/start-dev.sh
内部的には:
- Docker(PostgreSQL, Keycloak, Mailpit)を起動
- Go APIを4つ起動(air でホットリロード)
- Next.jsアプリを必要に応じて起動
# 個別起動も可能
pnpm dev:system # System Portalだけ
pnpm dev:provider # Provider Portalだけ
必要な環境変数
各ポータルで必要な主な環境変数:
# 認証(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 スクリプト例
// ルートの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 workspace | apps/ + packages/ のシンプル構成 |
| ビルド | Turborepo | dependsOn で依存順序を管理 |
| 共通型 | @repo/types | 全アプリで型を共有 |
| 共通UI | @repo/ui | 2つ以上で使うコンポーネントを集約 |
| APIクライアント | @repo/api-client | React Query hooks を共通化 |
| 認証 | @repo/auth | NextAuth設定を共通化 |
| Backend | Go + Echo | 4 API、internal/ で共通ロジック共有 |
モノレポは初期設定のコストがあるが、一度構築すれば「あのアプリにも同じコンポーネントを追加して...」という作業が激減する。
ソロ開発で複数アプリを管理するなら、モノレポは強い選択肢だと思う。
シリーズ記事
- 第1回: 1人では保守できない複雑さに自動化で挑む
- 第2回: WebAuthn認証をCIで自動テスト
- 第3回: Next.js × Go モノレポ構成(本記事)
Discussion