はじめに
株式会社メタップスホールディングス26卒内定者インターンのTomuです。
個人開発で Next.js × Express × Turborepo × pnpm を使ってDockerレスなモノレポ環境を構築・運用しています。
このTSモノレポ構成では、型とバリデーションを tRPC を使わず、shared ディレクトリで共有・管理しています。
本記事では、tRPC を使わずに「モノレポ × shared 構成」で
フロント/バック間の型・バリデーションをどのように共有しているか を実例ベースで解説します。
※ 運用Tipsについても別記事にしているので、読んでいただけると嬉しいです!
👉 個人開発で7ヶ月回したDockerレスTSモノレポ運用Tips
目次
1. なぜtRPCを使わなかったのか
理由はシンプルで、tRPCの存在を知らなかったからです。
当時(約7ヶ月前)はまだwebアプリ開発を学び始めてちょうど1年で、ほとんど1人で独学開発していることもあり、キャッチアップできていませんでした 🥲
また、モノレポ構築して1ヶ月後にtRPCの存在を知りましたが、移行せず今に至ります。。。
結果として、tRPC 的な思想(I/O の型安全) に自然と辿り着つことができました。
現在はある程度安定して運用できていて、tRPCよりも柔軟に型とバリデーションを管理できるという利点があるなと感じています。
2. 技術・ディレクトリ構造
技術構成
主要技術:Next.js / Express / turborepo / Prisma / NeonDB / Zod / pnpm
CICD:GithubActions / Biome / Vercel / Render
ライブラリ:LIFF / LINE Webhook / OpenAIAPI / Stripe
全体的には「TSフルスタック」といった感じです。
テストは未実装ですが、学習も含めて近々実装したいです。
※ OpenAIAPIとStripeは過去に実装まで行いましたが、要件変更により現在は機能から外しています。
ディレクトリのざっくり全体像
root/
├── .github/ # CI / build・format(Biome)
├── apps/
│ ├── frontend/ # Next.js(LIFFアプリ)
│ │ └── src/
│ │ └── features/ # APIドメイン別・カスタムフック
│ └── backend/ # Express(API) / LINE webhook
│ ├── prisma/ # DBスキーマ & マイグレーション
│ │ ├── schema.prisma
│ │ └── migrations/
│ └── src/
│ └── features/ # APIドメイン別・controllers/services
├── shared/ # Zodスキーマ / 共通型 / Utils
│ └── src/
│ ├── features/ # APIドメイン別・zodスキーマ & 型
│ └── utils/
│
├── pnpm-workspace.yaml
├── turbo.json
└── package.json # ルート共通設定
sharedディレクトリの構成
- frontend / backend間で「型」と「バリデーション」を一元管理するための場所
shared/
└── src/
└── features/
└── common/
│ ├── types/
│ │ └── errors.ts // エラー型(通常エラー / バリデーションエラー)
│ └── validations/ // クエリパラメータのバリデーション
│ └── weekStartParam.ts
└── auth/
├── types/
│ ├── login.ts // ログインAPIで使うレスポンス・ドメイン型
│ └── verify.ts // LIFF verifyで使うレスポンス・ドメイン型
├── validations/
│ ├── login.ts // ログインの Zod バリデーション
│ └── verify.ts // LIFF verify の Zod バリデーション
└── dto.ts // API I/O(Request/Response)のDTO定義
- backend と frontend から使う「型」や「バリデーション」を shared にまとめることで、どちらも同じ型を参照でき、ズレが起きにくくなる
- types と validations を分けることで、「APIレスポンス型だけ使うとき」と 「バリデーション周辺を扱いたいとき」が混ざらない
- dto.ts はレスポンス用の型を整えたもの
backend のディレクトリ構成
- ユースケース単位(login / liff-verify)で controller / service / DTO を分離
- I/O、ロジック、変換処理を小さく分けて保守性を高める設計
apps/backend/
└── src/
└── features/
└── auth/
├── login/
│ ├── controller.ts // I/O担当(validation / response)
│ ├── service.ts // ドメインロジック
│ └── toDTO.ts // 返却値のDTOへの変換
├── liff-verify/
│ ├── controller.ts
│ ├── service.ts
│ └── toDTO.ts
└── router.ts // /auth 配下のルーティング定義
- controllerは「入出力だけ」に責務を限定
- serviceは「ビジネスロジック」だけに集中
- DTO.tsはPrismaクエリの返却値をDTO型に変換
frontend のディレクトリ構成
- API 呼び出し・カスタムフック・共通ロジックを分離して保守性を高める
apps/frontend/
└── src/
└── features/
├── _shared
| ├── path.ts /// APIエンドポイント変数
| └── fetcher.ts /// 汎用フェッチ関数(型安全)
|
├── auth /// Auth関連のユースケース
| ├── useLiffVerify.ts
| └── useLogin.ts
- featuresでバックエンドと同じ「ドメイン単位」に揃えることで
「shared」「frontend」「backend」の対応関係がわかりやすい- _sharedには全てのドメインで使う「APIフェッチ関数」と「APIパスの変数」を置くことで再利用できる
型・バリデーションの共有
ここからは、実際に frontend / backend から shared の型を参照している例 を見ていきます。
例として、ユーザー編集 の 処理を取り上げます。(デモコード)
1. sharedの型・バリデーション(例:ユーザー編集)
import type { UserDTO } from "../dto.ts";
// APIレスポンス型(DTO)
export type UpdateProfileResponse = {
ok: boolean;
user: UserDTO; // ← Prisma.User をそのまま出さず、DTO型に整えたもの
};
// prisma型をそのまま使わず型を再定義
export type UserDTO = {
id: string;
name: string;
email: string;
};
UpdateProfileResponse:ユーザー編集APIのレスポンス型
(バックエンドではcontrollerとserviceで共通利用)
import { z } from "zod";
// バックエンドで扱う完全な入力
export const UpdateProfileSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1, "名前は必須です"),
email: z.string().email("メールアドレスの形式が正しくありません"),
});
// バックエンド用の“完全な”入力型
export type UpdateProfileInput = z.infer<typeof UpdateProfileSchema>;
// フロントのフォームでは id は触らないので Omit
export type UpdateProfileFormInput = Omit<UpdateProfileInput, "id">;
updateProfileSchema:ユーザー編集APIのリクエスト値に対するバリデーション
updateProfileInput:リクエスト値のInput型
updateProfileFormInput:フォーム用のInput型
2. バックエンド側での共有
2-1. コントローラ層でsharedの型・バリデーションをインポート
import type { Request, Response } from "express";
// sharedから型・バリデーションをインポート
import {
UpdateProfileSchema,
type UpdateProfileInput,
} from "@shared/features/users/validations/update-profile";
import type {
ErrorResponse,
ValidationErrorResponse,
} from "@shared/features/common/types/errors";
import type { UpdateProfileResponse } from "@shared/features/users/types/update-profile";
// サービスロジック
import { updateProfileService } from "./service";
// DTO型変換処理
import { toUserDTO } from "./toDTO"
export const updateProfileController = async (
req: Request,
res: Response<UpdateProfileResponse | ErrorResponse | ValidationErrorResponse>,
) => {
try{
// Zodでリクエストボディを検証
const parsed = UpdateProfileSchema.safeParse(req.body);
if (!parsed.success) {
return void res.status(400).json({
ok: false,
message: "Invalid request body",
errors: parsed.error.errors,
});
}
// ここに来る時点で parsed.data は UpdateProfileInput 型
const res = await updateProfileService(parsed.data);
if(!res.ok && "message" in res) {
return void res.status(404).json(res.message);
}
const toDtoRes = {
ok: res.ok,
user: toUserDTO(res.user) // prisma型 → DTO型に変換
};
return void res.status(200).json(toDtoRes);
} catch(error) {
return void res.status(500).json({ ok: false, message: "Internal Server Error:" + error });
}
};
2-2. サービス層でも同様にインポート
import type {
UpdateProfileInput,
} from "@shared/features/users/validations/update-profile";
import type { UpdateProfileResponse } from "@shared/features/users/types/update-profile";
import type { ErrorResponse } from "@shared/features/common/types/errors";
import { updateUserProfile } from "../../../repositories/users";
export const updateProfileService = async (
input: UpdateProfileInput,
): Promise<UpdateProfileResponse | ErrorResponse> => {
// prismaクエリで処理(省略)
const user = await updateUserProfile(input);
if(!user) {
return { ok: false, message: "user is not found"}
}
// 実際は DB 更新などのロジック
return { ok: true, user };
};
2-3. prisma型をDTO型に変換
import type { User } from "@prisma/client";
import type { UserDTO } from "@shared/features/users/dto.ts";
export const toUserDTO = (entity: User): UserDTO => ({
id: entity.id,
name: entity.name,
email: entity.email,
});
3. フロントエンド側での共有
3-1. ユーザー編集API用フック useUpdateProfile
"use client";
import { fetcher } from "@/features/_shared/fetcher";
import { users } from "@/features/_shared/path"; // /api/users/profile など
import type { UpdateProfileResponse } from "@shared/features/users/types/update-profile";
import type {
ErrorResponse,
ValidationErrorResponse,
} from "@shared/features/common/types/errors";
import type { UpdateProfileFormInput } from "@shared/features/users/validations/update-profile";
type UpdateProfileResult =
| UpdateProfileResponse
| ErrorResponse
| ValidationErrorResponse;
export const useUpdateProfile = () => {
const { authToken } = useSelector((state: RootState) => state.authToken);
const updateProfile = async (
input: UpdateProfileFormInput,
): Promise<UpdateProfileResult> => {
// 自前の汎用フェッチ関数に「APIレスポンス型」をセット
const res = await fetcher<UpdateProfileResponse>({
method: "PUT",
path: users.updateProfile,
body: input,
token: authToken
});
return res;
};
return { updateProfile };
};
3-2. フォームバリデーション用フック useUpdateProfileForm
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
UpdateProfileSchema,
type UpdateProfileFormInput,
} from "@shared/features/users/validations/update-profile";
// フロント側では role を使わないので omit したスキーマを使う例
const UpdateProfileFormSchema = UpdateProfileSchema.omit({ role: true });
export const useUpdateProfileForm = (
init?: Partial<UpdateProfileFormInput>,
) => {
const {
register,
handleSubmit,
formState: { errors, isValid, isSubmitting },
} = useForm<UpdateProfileFormInput>({
resolver: zodResolver(UpdateProfileFormSchema),
mode: "onChange",
defaultValues: {
name: init?.name ?? "",
email: init?.email ?? "",
},
});
const isDisabled = !isValid || isSubmitting;
return {
register,
handleSubmit,
errors,
isDisabled,
};
};
3-3. コンポーネント内で useUpdateProfile と useUpdateProfileForm を呼び出す
import { useUpdateProfileForm } from "@/features/users/useUpdateProfileForm";
import { useUpdateProfile } from "@/features/users/useUpdateProfile";
export default function ProfilePage() {
const { register, handleSubmit, errors, isDisabled } =
useUpdateProfileForm();
const { updateProfile } = useUpdateProfile();
const onSubmit = handleSubmit(async (values) => {
const res = await updateProfile(values);
if (!res.ok) {
// ValidationErrorResponse / ErrorResponse の message などを表示
alert(res.message ?? "更新に失敗しました");
return;
}
alert("プロフィールを更新しました");
});
return (
<form onSubmit={onSubmit}>
<div>
<label>名前</label>
<input {...register("name")} />
{errors.name && <p>{errors.name.message}</p>}
</div>
<div>
<label>メールアドレス</label>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
</div>
<button type="submit" disabled={isDisabled}>
更新する
</button>
</form>
);
}
4. alias設定
backend / frontend の両方で shared の型・validation を参照するために、tsconfig.json に alias を設定しているのでインポートがラクになりました。
- backend / frontend の両側で同様の設定を追加
{
"compilerOptions": {
/// その他省略
"paths": {
"@shared/*": [
"../../shared/src/*"
]
}
}
「tRPC」 と「モノレポ × shared構成」の比較
| 観点 | tRPC | モノレポ × shared構成(本記事方式) |
|---|---|---|
| 型安全性 | フル自動で同期(最強) | Zod × TSで十分高い |
| コード構造 | ルーターに集まりがち | controller / service で分離しやすい |
| 自由度 | tRPC流の書き方に寄る | REST / Webhook / RPC 何でもOK |
| 学習コスト | やや高い(DSL理解が必要) | 低い(Zod & TSだけ) |
| 拡張性 | 中規模まで最適 | 大規模でも崩れにくいが工夫が必要 |
| 既存技術との相性 | 外部API(webhookなど) は扱いづらいことがある | Express / Next / webhook すべて自然 |
| 運用コスト | 思想に沿えば運用しやすい | モノレポの共有管理がやや面倒 |
最後に
tRPCを知らないまま始めた TSモノレポ構成でしたが、結果として 十分に戦える設計 になったと感じています。
結論としては、
- 柔軟に開発したい → TSモノレポ
- 型同期のDXを最大化したい → tRPC
という選び方で十分だと思います。
どちらも強力なので、目的に合わせて選ぶのが一番です。この記事がその判断材料になれば嬉しいです。