Next.js App Router から複数バックエンドを扱うための BFF + クリーンアーキテクチャ戦略
どうも、トラハックこと、toraco株式会社の稲垣です。
複数のバックエンドを抱えるプロダクトにおいて、Next.js ( App Router ) 製 の Web アプリケーションを新規開発するにあたり、Route Handlers による BFF と、クリーンアーキテクチャを取り入れることで、バックエンドに依存しないクライアント実装を実現しました。
将来的に、通信するバックエンドが増えたり、バックエンドのアーキテクチャが変更になったとしても、クライアントの実装に修正を加えることなく移行が可能です。
とカッコよく書き出しましたが、ここから紹介する設計はベストプラクティスなんてものではなく、プロダクトが成長する過程のカオスを乗り切る苦肉の策です。
しかし、綺麗な理想よりも清濁併せ呑んだ現実を紹介するほうが価値があると信じて書き進めます。
さらに余談ですが、一般的な設計や実装に関しては技術記事を読まなくても生成AIに任せることができてしまう現代になっているように思います。(それが "問題ない" のかは議論の余地がありますが...)
なので、この記事では以下の観点を重要視して書いていきます。
- アーキテクチャ選定や設計段階で考えていること(思考プロセス)
- 具体的なユースケースに沿った実装例
- ポジショントーク(他にも選択肢がある中でなぜその選択をしたのかという想い)
前提
弊社が4年近く開発をサポートさせていただいてるお客様から「モバイルアプリ版に加えてブラウザ版をリリースしたい」というご要望をいただいたことが事の始まりです。
4年前、スタートアップらしく最小限の機能から開発を始めたプロダクトは順調にユーザー数を伸ばし、ユーザーの要望や市場変化に伴い機能を拡充していきました。
その中で技術的にもさまざまな取り組みをした結果、現在は以下のように複数のバックエンドを抱える構成となっています。
- SDK でデータを直接取得する Firestore
- 決済など重要なビジネスロジックを扱う REST API
- 在庫やカートなどリアルタイム性の高いデータを扱う GraphQL
そもそも当初は 1 と 2 だけだったのですが、将来的にはデータベースを Firestore から RDB へ移行するという計画があるため、データ取得はクライアント SDK を使わずに GraphQL に移行する方針で動いています。とはいえ、GraphQL への移行は途中段階であり、ビジネス観点からその移行よりも優先してブラウザ版の開発が必要となったため、「さて、どうしたものか」と悩んだのです。
BFF ( Backend for Frontend ) 導入背景
まず、上述したようなバックエンドサービス群をクライアントで扱うにあたり、その複雑性を吸収するための BFF を導入することにしました。
ちなみに、 GraphQL 自体が「 RDB への移行を見据えてデータベースに依存しない抽象化をするための BFF 」なのですが、現時点では3つのバックエンドを扱う必要があるため、GraphQL にも Route Handlers 経由でアクセスします。その場合は「 client → BFF ( Route Handler ) → BFF ( GraphQL ) → Database 」という流れになります。
Next.js App Router では Route Handlers[1] を用いることで BFF の構築が可能です。
app
├ api // Route Handlers は必ずしも api ディレクトリ配下に置く必要はない
| └ user
| └ [id]
| └ route.ts
└ user
└ [id]
├ fetcher.tsx
└ page.tsx
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
try {
// 例えば Firebase Admin SDK でデータを取得する
const user = await db.collection("users").doc(id).get((doc) => doc.data())
return NextResponse.json({ user, message: 'ユーザー情報を取得しました。' }, { status: 200 })
} catch(e) {
return NextResponse.json({ user: null, message: 'ユーザー情報の取得に失敗しました。' }, { status: 500 })
}
}
export async function getUser(id: string): Promise<User> {
const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/user/${id}`)
const data: { message: string, user: User } = await res.json()
if (res.status !== 200) {
throw new Error(data.message)
}
return data.user
}
import { getUser } from "./fetcher"
type Props = { params: { id: string } }
export default async function UserPage({ params }: Props) {
const { id } = await params
const user = await getUser(id)
return (
<div>{user.name}</div>
)
}
これによって、とあるデータを取得するために利用するバックエンドを移行する際、クライアントから呼び出すエンドポイントを変えずに済みます。
クリーンアーキテクチャ導入背景
さらに、将来的なバックエンドの移行に耐えられるよう、クリーンアーキテクチャを採用することでそのメリットを享受できるのではないかと考えました。
実のところ、先ほど述べた BFF 導入のメリットには穴があります。何かというと、確かにクライアントのエンドポイントを置き換える必要はないけれど、BFF ( Route Handlers ) 内は変更が必要だということです。
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
try {
- // 例えば Firebase Admin SDK でデータを取得する
- const user = await db.collection("users").doc(id).get((doc) => doc.data())
+ // query, variables, headers などの宣言は省略しています
+ const res = await fetch(process.env.GRAPHQL_ENDPOINT, {
+ body: JSON.stringify({ query, variables }),
+ headers: headers,
+ method: 'POST',
+ })
return NextResponse.json({ user, message: 'ユーザー情報を取得しました。' }, { status: 200 })
} catch(e) {
return NextResponse.json({ user: null, message: 'ユーザー情報の取得に失敗しました。' }, { status: 500 })
}
}
上記では諸々省略しているので変更箇所がそれほど多くないように見えますが、実際のとこり、移行範囲が大きくなると重労働になりそうでした。また、それに伴う
しかし、クリーンアーキテクチャを採用して、Route Handlers を
例えば、データ取得方法を SDK から GraphQL に置き換える場合は、
[余談] RSC ( React Server Component ) と GraphQL の相性について
本筋からは少し逸れますが、一般的に App Router ( RSC ) と GraphQL を併用することは望ましくないようです。
Next.js で App Router を採用する場合 RSC をフル活用することになります。
コロケーションの思想に則り、ページのルートコンポーネントではなく、末端のコンポーネント ( RSC ) 内でデータフェッチする設計となります。
「必要な箇所で必要なデータだけを取得する」ということですね。通信回数は増えますが、1通信あたりのレスポンスサイズが小さく、Suspense と併用することで非同期的にコンポーネントのレンダリングを行うことができます。
ただし、「必要な箇所で必要なデータだけを取得する」という思想は GraphQL も同様であり、享受できるメリットも同じであるため、両方を採用する場合、GraphQL を扱う実装コストやライブラリ追加に伴うバンドルサイズの増加などのデメリットが大きくなってしまうのでは?ということが論じられています。これに関しては概ね私も同意です。
そのため、GraphQL を継続利用しつつ、Route Handlers による BFF を挟み、ユースケースごとにエンドポイントを用意することにしました。
BFF + クリーンアーキテクチャの設計と実装例
ディレクトリ設計
大枠として、App Router に依存する実装を app 配下に置き、ビジネスロジックはクリーンアーキテクチャを基に core 配下に置きました。
├ app/
| ├ api/ : Route Handlers による BFF
| └ その他各種 page
└ core/ : ビジネスロジックを集約するディレクトリ
├ domain/
| ├ entity/
| | ├ mapper/
| | | └ user.ts
| | └ user.ts
| ├ firebase/ : Firestore から取得できるドキュメントの型
| | └ user.ts
| └ graphql/
| | └ generated.d.ts : graphql-codegen でスキーマから生成した型ファイル
├ infrastructure/
| ├ repository/ : 今回、データベースへのアクセスは Firebase Admin SDK を用いる場合のみ
| | └ user.ts : Firebase Admin SDK で user ドメインに関連するデータアクセスを行うファイル
| └ service/
| ├ firebase/
| | └ client.ts : Firebase Admin SDK の initialize などを行っている
| ├ graphql/
| | └ client.ts
| └ rest/
| └ client.ts
├ interface/
| ├ repository
| | └ user.ts
| └ service/
| ├ graphql/
| | └ client.ts
| └ rest/
| └ client.ts
└ usecase/
└ user/
└ GetUserUsecase.ts
クリーンアーキテクチャにおけるレイヤーとディレクトリの対応は以下となります。Route Handlers がプレゼンテーション層として振る舞う点がポイントです。
レイヤー | ディレクトリ |
---|---|
プレゼンテーション層 |
app/api/ ( Route Handlers ) |
ドメイン層 | core/domain/ |
インフラ層 | core/infrastructure/ |
インターフェイス層 | core/interface/ |
ユースケース層 | core/usecase/ |
レイヤーの名称や分け方に関してはさまざまな宗教があると思うので、上記が正解ということはなく自身のプロダクトに合ったものにすればOKだと思います。
[基本実装例A] repository でデータ取得する
当プロダクトでは GraphQL や REST なども扱う必要がありますが、まずは Firestore からデータを取得するケースを扱います。
ドメイン層の実装
まずは User ドメインの entity を定義します。
export type User = {
email: string
firstName: string
lastName: string
id: string
}
また、Firestore の users コレクションからは以下のようなドキュメントを取得できるものとします。
export type FsUser = {
created_at: Timestamp
email: string
first_name: string
last_name: string
updated_at: Timestamp
}
Firestore のドキュメントを entity にマッピングする関数も定義しておきます。
export function mapFromFsToUser(data: FsUser, id: string): User {
return {
email: data.email,
firstName: data.first_name,
lastName: data.last_name,
id: id,
}
}
インターフェイス/インフラ層の実装
次に、Firestore にアクセスして users コレクションからデータ取得する repository の interface を定義します。
export interface IUserRepository {
get: (id: string) => Promise<User | Error>
}
interface の実装クラスを定義します。
export class UserRepository implements IUserRepository {
async get(id: string): Promise<User | Error> {
return db
.collection('users')
.doc(id)
.get()
.then((doc) => mapFromFsToUser((doc.data() as FsUser), snapshot.id))
.catch((error) => new Error(error))
}
}
ユースケース層の実装
単一のユーザーを取得するユースケースを作成します。
import { User } from 'core/domain/entity/user'
import { IUserRepository } from 'core/interface/repository/user'
export class GetUserUsecase {
constructor(private userRepository: IUserRepository) {}
async exec(id: string): Promise<User | Error> {
return this.userRepository.get(id)
}
}
プレゼンテーション層の実装
前半に例として挙げた Route Handlers を変更します。ここでは、UserRepository (実装クラス)を GetUserUsecase に渡すことで、**クリーンアーキテクチャでお馴染みの「依存性の注入」**を行います。
import { NextRequest, NextResponse } from 'next/server'
import { UserRepository } from 'core/infrastructure/repository/user'
import { GetUserUsecase } from 'core/usecase/user/GetUserUsecase'
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const userRepository = new UserRepository()
const usecase = new GetUserUsecase(userRepository)
const user = await usecase.exec(id)
if (user instanceof Error) {
return NextResponse.json(
{ message: user.message, user: null },
{ status: 500 }
)
}
return NextResponse.json(
{ message: 'ユーザー情報を取得しました。', user },
{ status: 200 },
)
}
[基本実装例B] service ( GraphQL ) でデータを取得する
GraphQL や REST はユースケースから扱えるように client として実装します。
インターフェイス/インフラ層の実装
以下のように query メソッドを実行できる client とします。
import { Query } from 'core/domain/graphql/generated'
export interface IGraphQLClient {
query(query: string, variables?: Record<string, unknown>): Promise<Query | Error>
}
interface の実装クラスを定義します。GraphQLClient のインスタンス生成時に、エンドポイントと認証トークンを渡せるようにしておきます。
import { Query } from 'core/domain/graphql/generated'
import { IGraphQLClient } from 'core/interface/service/graphql/client'
export class GraphQLClient implements IGraphQLClient {
constructor(
private readonly endpoint: string,
private readonly token?: string,
) {}
async query(query: string, variables?: Record<string, unknown>): Promise<Query | Error> {
try {
const headers: HeadersInit = { 'Content-Type': 'application/json' }
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`
}
const res = await fetch(this.endpoint, {
body: JSON.stringify({ query, variables }),
headers: headers,
method: 'POST',
})
const data = await res.json()
if (data.errors) {
const message = data.errors
.map((error: GraphQlError) => `${error.code}: ${error.message}`)
.join('\n')
return new Error(message)
}
return data.data
} catch (e) {
console.error(e)
return new Error('予期せぬエラーが発生しました。')
}
}
}
[応用実装例A] バックエンドの移行
例えば、ここまで述べてきたユーザー情報の取得を、 Firestore から GraphQL に移行するとしましょう。
ユースケース層とプレゼンテーション層を以下のように変更すれば移行完了です。
+ import { gql } from 'graphql-request'
import { User } from 'core/domain/entity/user'
+ import { mapFromGqlToUser } from 'core/domain/entity/mapper/user'
- import { IUserRepository } from 'core/interface/repository/user'
+ import { IGraphQLClient } from 'core/interface/service/graphql/client'
export class GetUserUsecase {
- constructor(private userRepository: IUserRepository) {}
+ constructor(private graphQLClient: IGraphQLClient) {}
async exec(id: string): Promise<User | Error> {
- return this.userRepository.get(id)
+ const query = gql`
+ node(id: $id) {
+ ... on User {
+ email
+ firstName
+ lastName
+ }
+ }
+ `
+ const variables = { id }
+ const data = this.graphQLClient.query(query, variables)
+ if (data instanceof Error) {
+ return data
+ }
+ return mapFromGqlToUser(data)
}
}
ユースケースでは注入するリポジトリを GraphQLClient に置き換えます。
import { NextRequest, NextResponse } from 'next/server'
- import { UserRepository } from 'core/infrastructure/repository/admin/user'
+ import { GraphQLClient } from 'core/infrastructure/service/graphql/cleint'
import { GetUserUsecase } from 'core/usecase/user/GetUserUsecase'
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
- const userRepository = new UserRepository()
- const usecase = new GetUserUsecase(userRepository)
+ const graphQLClient = new GraphQLClient(process.env.GRAPHQL_ENDPOINT)
+ const usecase = new GetUserUsecase(graphQLClient)
const user = await usecase.exec(id)
if (user instanceof Error) {
return NextResponse.json(
{ message: user.message, user: null },
{ status: 500 }
)
}
return NextResponse.json(
{ message: 'ユーザー情報を取得しました。', user },
{ status: 200 },
)
}
Route Handlers のレスポンスやエンドポイントは変わらないので、当然クライアントの実装には影響を及ぼしません。また、私が感じる最大のメリットは、移行が可能になったユースケースから注入するリポジトリ(実装)をすり替えていけば良く、段階的なバックエンドの移行に適している点です。
[応用実装例B] 1リクエストで複数バックエンドにアクセスする
複数のバックエンドに対してリクエストしたいユースケースがある場合も、クライアントからは BFF に対して1つのリクエストを発行するだけで済ませるということもできます。
例えば、GraphQL と Firestore からそれぞれ取得したデータを1つの entity としてクライアントに返却するケースを書いてみましょう。(リポジトリの実装コードについては省略します。)
import { Order } from 'core/domain/entity/order'
import { IShippingRepository } from 'core/interface/repository/shipping'
import { IGraphQLClient } from 'core/interface/service/graphql/client'
export class GetOrderUsecase {
constructor(
private shippingRepository: IShippingRepository,
private graphQLClient: IGraphQLClient,
) {}
async exec(checkoutId: string): Promise<Order | Error> {
const query = gql`
query(
$id: String!
$status: CheckoutStatus!
) {
checkouts(
id: $id
statut: $status
) {
checkoutAt
status
totalAmount
}
}
`
const variables = { id: checkoutId, status: "succeeded" }
const data = this.graphQLClient.query(query, variables)
const checkout = data.checkouts[0]
const shipping = this.shippingRepository.getByCheckoutId(checkoutId)
return mapToOrder(checkout, shipping)
}
}
import { NextRequest, NextResponse } from 'next/server'
import { ShippingRepository } from 'core/infrastructure/repository/shipping'
import { GraphQLClient } from 'core/infrastructure/repository/graphql/cleint'
import { GetOrderUsecase } from 'core/usecase/user/GetOrderUsecase'
export async function GET(request: NextRequest, { params }: { params: Promise<{ checkoutId: string }> }) {
const { checkoutId } = await params
const shippingRepository = new ShippingRepository()
const graphQLClient = new GraphQLClient(process.env.GRAPHQL_ENDPOINT)
const usecase = new GetUserUsecase(shippingRepository, graphQLClient)
const order = await usecase.exec(checkoutId)
if (order instanceof Error) {
return NextResponse.json(
{ message: order.message, order: null },
{ status: 500 }
)
}
return NextResponse.json(
{ message: '注文情報を取得しました。', order },
{ status: 200 },
)
}
クライアントにとって都合の良い entity を取得するために、複数のバックエンドにアクセスしてそれぞれから取得したデータをマッピングするケースは、バックエンドの移行途中段階ではありうるのではないかと思います。
まとめ
ゼロから開発するプロダクトであれば、このようにバックエンドが複数あるとか移行途中のようなカオスな状態を考慮しなくても良いですが、プロダクトが成熟してくるにつれてこのような問題は起きうるということを今回学びました。
クライアントアプリケーションにおける BFF + クリーンアーキテクチャという選択は、管理するファイルが増えたり形式的な手続きが増えるデメリットはありますが、カオスを吸収するクッションとして活躍してくれていると感じています。
最後に、toraco株式会社では2024年11月1日にエンジニア向けのコミュニティを立ち上げました。
Discord のサーバーで運営しており、以下のリンクから無料で参加できます。コミュニティ内では以下のような投稿・活動がされます!
- もくもく会・作業ラジオ・雑談部屋などオンライン上での交流
- オフラインイベントの案内
- toraco株式会社からの副業や案件の紹介
- 最新の技術情報の共有および議論
- その他、技術領域にこだわらない情報共有および議論
-
Pages Router の場合は API Routes が該当します。 ↩︎
Discussion