バックエンドエンジニアが、Next.js route handlerを型安全&読みやすいAPI設計にするには?を実装してみた
設計するに至った背景
どうも、エイジレスでテックリードをしている栗田です。
今、会社で使っている業務システムをフルスクラッチリプレイスしています。
そもそも、ローコードで一部だけ作ってリリースしたシステムなのですが、
思った以上にカスタムが必要で、その結果として業務を回すのに支障が出る
レベルの不具合が結構出てしまっていました。
また、ローコードの仕様で不具合の解決に数週間かかっちゃうなんていう罠もあったり。
リリースして不具合が出まくるの、
エンジニアとして普通にストレスですよね。
そこで改めて
「エンジニアがいるんだからちゃんと1から作ろう」
ということでこのPJが走り出しました。
こんな方は参考になるかな?
- ローコードで自社システム作ろうと思っている
- でも、一応チームにエンジニアはいる
- typescriptでバックエンドか...いけんのかな?
- フロントは触ったことあるけど、バックエンドどう実装したらいいのかな?
- あまり記事が転がっていないなぁ...
ここまで絞っちゃうと、読者いなくなるか笑
でも、TypescriptでNestJS使わずとも、Next.jsだけでも
いけるんだぜってことを書いていければと思っています。
始まる前と結果
前提、以下の状況です。
- 栗田は仕様を知らない(暗黙のローコードの仕様もある)
- 画面は既存のローコードを踏襲
- メイン担当(本間さん)は既存のローコードの修正で手が離せない
主なメイン機能は現状2つ(追って追加開発あり)ですが、とにかく
最短でリリースするために、エコなスタックで開発しようという話になりました。
とはいえ、前のシステムを糧に、安定稼働かつ生産性の高い設計にするべく
Next.js only かつある程度の設計しっかりやろうと今回の設計を
1から考えることにしました。
結果、1人月のリソースで1ヶ月弱で2つの機能を備えたとこまで、
stagingでの動作環境まで持っていくことができました。
(実運用的にはこの2つがあれば最低限OKライン)
その後は、別のメンバーが引き継いで本番リリースと追加開発を進めています。
スタック/ツール
- Typescript
- Next.js(Api Routes)
- Prisma(ORM)
- zod-prisma-types(バリデーションツール)
- tanstack Query(リクエストツール)
- orval(スキーマ生成ツール)
- biome(速いリント、フォーマッタ)
- react-hook-form(フォーム)
- jotai(状態管理ツール)
- MUI(デザインシステム)
- Auth0(認証システム)
フォルダ構成
src
|_app
| |_(authenticated)...認証済みのレイアウト
| |_page.tsx
| |_(login)
| |_api ...APIのエンドポイント
| |_components ...共通コンポーネント管理
| |_features ...画面単位でのコンポーネント管理
|_generated ...orvalで自動生成されたファイル群
|_handlers ...APIのエラーハンドリング系とか
|_layouts ...大まかなレイアウトを担う系(ヘッダー、ドロワーとか)
|_providers ...グローバル管理系(queryClientProviderとかどうしてもuse clientで囲みたい系のものとか)
|_repositories ...APIのDB操作系(prismaで色々書く)
|_services ...APIの計算や加工処理、Repositoryの呼び出しなど
|_styles ...スタイルに関する定義
|_utils ...共通で使いたい関数
|_middleware.ts ...認証処理
openapi
|_resources
|_path(エンドポイントに応じたスキーマ定義)
|_components(共通で使うコンポーネントスキーマ)
|_openapi.yml(エンドポイントのパス定義)
prisma ...prismaに関する定義
これを作っていく中で、
「appがすなわちfeaturesの要素を含んでいるのだし、
app/page.tsx
と同じとこにコンポーネントを入れてもいいのでは?」
と言う意見があったり、
「featuresやcomponentsを作るなら、appの外には出した方が良かったな」
という反省点はありつつ、
ディレクトリのネスト構造は最悪パス修正すればなんとかなりそうなので、
一旦このように進めることにしました。
個人的にはこちらの記事に同意というスタンスです。
(この辺のフロント設計のこだわりはまた別で記載しようと思います。)
APIの責務分割について
デフォルトのNext.jsだと、Railsみたいにapiフォルダ配下や、
それ以外のフォルダ構成が特に組み込まれていないので、
自分で考える必要があります。
このまま書いてしまうと、 api/〇〇/route.ts
の中に
大量のコードを書くハメになってしまい、テストのしやすさや、
見通しが悪くなってしまう懸念がありました。
そこでちゃんとディレクトリ構成とか考えないとなーと思っていました。
元々Railsでコードを書いていたので、MVCの概念が染み付いています。
なので、基本的にはそこで経験したことを活かした方がとりあえずは
良さそうかなと考えながら、記事を漁ってみて、以下の記事を見つけました。
なるほど、repositoryパターンなら、比較的MVC(+service層)
の設計に近い構成でかけそうだ、ということで試してみました。
階層ごとの責務はこんな感じに分けてみました。
取得の場合
export async function GET(_request: NextRequest) {
try {
// apiから直接Repository層を呼ぶことも許容
const users = await new UserRepository(prisma).findAll();
// orvalで生成したレスポンスの型にはめ込む
// (レスポンスの型保証をしたいため)
// この辺はhonoとか使ったらもっと良い感じに書ける?
const res: UsersResponse = {
users: users.map((user) => ({
id: user.id.toString(),
label: user.display_name ?? "",
})),
};
return NextResponse.json(res);
} catch (error) {
return handleError(error);
}
}
export class SystemUserRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(): Promise<system_users[]> {
return await this.prisma.users.findMany();
}
}
作成や更新の場合
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: number }> },
): Promise<NextResponse> {
try {
const id = (await params).id;
const body: UserUpdateParams = await request.json();
const service = new UserService();
// パラメータ整形と更新処理は、処理の毛色が違うので別の関数で定義
const validParams = await service.generateUpdateParams(id, body);
const { userKeywordIds } = body;
await service.updateUserWithRelation(
validParams,
userKeywordIds || [],
);
// 型安全にレスポンスしたいので、OpenAPIで
// 定義されている型で一度定義してから返却
const res: Ok = { message: "更新しました" };
return NextResponse.json(res);
} catch (error) {
return handleError(error, "更新に失敗しました");
}
export class UserService {
// RailsだとModelに値を詰めて、save前にバリデーションさせるが、
// prismaのzodだと、パラメーターレイヤーでバリデーションする形が主流?
async generateUpdateParams(
id: number,
{ email, lastName, firstName }: UserUpdateParams,
): Promise<z.infer<typeof usersSchema>> {
// pickでバリデーションしたいカラムだけ選択して、parseで検証
// バリデーション失敗すると、catchのerrorにZodErrorが設定される
const user = await new UserRepository(prisma).findById(id);
return usersSchema
.pick({
last_name: true,
first_name: true,
email: true,
})
.parse({
last_name: lastName,
firstName: firstName,
email: email
});
}
async updateUserWithRelation(
params: z.infer<typeof usersSchema>,
userKeywordIds: number[],
) {
//削除するIDと追加するIDを計算して取得
const { keywordsToDelete, keywordsToAdd } =
await new UserKeywordService(prisma).buildLinkKeywords(
Number(params.id),
userKeywordIds.map((k) => Number(k)),
);
await prisma.$transaction(async (tx) => {
if (keywordsToDelete.length > 0) {
await new UserKeywordRepository(tx).deleteByUserAndKeywordIds(Number(params.id), keywordsToDelete);
}
await new UserRepository(tx).updateWithRelations(params, keywordsToAdd);
});
}
}
export class UserRepository {
// transactionを受け付けられるように `Omit<PrismaClient, ITXClientDenyList>を含めている`
constructor(private readonly prisma: PrismaClient | Omit<PrismaClient, ITXClientDenyList>) {}
async updateWithRelations(
params: z.infer<typeof usersSchema>,
keywordsToAdd: number[],
) {
return await this.prisma.users.update({
where: { id: params.id },
data: {
...params,
user_keywords:
keywordsToAdd.length > 0
? {
create: keywordsToAdd.map((keywordId) => ({
keyword_id: keywordId,
})),
}
: undefined,
},
});
}
}
エラーハンドリング
import type { ErrorDetail } from "@/generated/model/resources-components-schemas-responses.yml";
import { camelize } from "humps";
import { NextResponse } from "next/server";
import { ZodError } from "zod";
export const handleError = (error: unknown, defaultMessage = "エラーが発生しました") => {
if (error instanceof ZodError) {
const errors: ErrorDetail[] = error.errors.map((e) => ({
key: camelize(e.path[0].toString()),
message: e.message,
}));
return NextResponse.json({ message: defaultMessage, errors }, { status: 422 });
}
if (error instanceof Error && error.name === "PrismaClientKnownRequestError") {
if (error.message.includes("Unique constraint failed")) {
return NextResponse.json({ message: "既に登録されているデータです" }, { status: 400 });
}
return NextResponse.json({ message: "データベースエラーが発生しました" }, { status: 400 });
}
if (error instanceof Error) {
// 特定のエラータイプに対するカスタム処理
if (error.name === "NotFoundError") {
return NextResponse.json({ message: "リソースが見つかりません" }, { status: 404 });
}
if (error.name === "ValidationError") {
return NextResponse.json({ message: "入力が無効です", details: error.message }, { status: 400 });
}
}
// デフォルトのエラーレスポンス
return NextResponse.json({ message: defaultMessage }, { status: 500 });
};
うちのサービスでは、バリデーションエラー(ZodError)を422,それ以外は400(Bad Request),401(Unauthorized),403(Forbidden),404(Not Found),500(Internal Server Error)のみを扱うように定義しているので、上のような分岐にしています。
イメージとしては、
- services/ → prismaに渡す前に、route.tsに書くには複雑な加工や計算処理を挟むときに切り出す
- repositories/ → prismaにつなぎこみして、DBのcrud処理を行う。
また、transactionを貼る場所に関しては、servicesかrepositories
(servicesに貼ることが多かった)に定義して、route.tsは各処理の呼び出しと、
レスポンスオブジェクトの加工にしてもらうみたいに分けていました。
(レスポンス加工も、他に責務分割してもいいかもしれないです。)
他の現場やプロダクトの設計を見たことがないので、比較はできませんが、
上のような分割で、ある程度の見通しと、ディレクトリごとにどんな処理を
書けばいいのかな?という解像度は上がったのではないかと思います。
地味に嬉しいポイント
prismaだと、「既存のDBから、スキーマを逆生成」できます。
今回であれば、既にローコードのシステムでDBは立てていたので、
本番のDBに繋ぎ込み→スキーマ取り込み→ローカル反映
と言う形で、一からスキーマファイルを書かずに済んだ点も、開発速度を担保できた要因です。
この辺の記事を参考にしました。
あとがき
初めてのTypeScript Onlyの開発でしたが、個人的にはオキニです😇
次作るプロダクトはTypeScript一本に絞っていいと思えるくらいです。
ただ、一つ気になったのは、Prismaだとデフォルトのタイムゾーンが
UTCになってしまうので、もしJSTでDB保存したい場合はquery Extensionsを
つかってオーバーライドさせないといけない点です。
あと、レスポンスの型定義でこれを導入してみて、もっと型安全にできそうだなとか、react-hook-formのバリデーションにzodを組み込めそうだなーとか、改善したいところはまだまだありそうです。
もしうちではこんなフォルダ分割で見通しよくしているよーという意見があれば
ぜひ教えて下さい!
また、エイジレスではフロントもバックエンドも「メンテしやすい設計×生産性」
にこだわって開発をする文化を根付かせようと頑張っているので、
興味ある方はカジュアル面談お待ちしています👍
Discussion