はじめに
この記事では、BFF で DTO と API 呼び出し関数が増えてきたときに、どのように整理すると読みやすくなるかを扱います。
BFF は、フロントエンドが使いやすい形にデータを整えるための層です。
そのため、画面単位、ペイン単位、モーダル単位で API や DTO が増えやすくなります。
最初は api.ts に DTO と API 呼び出し関数をまとめても問題ありません。
むしろ小さいうちは、その方が分かりやすいこともあります。
ただし、規模が大きくなると次の2つが混ざり始めます。
- DTO: データの形
- API 呼び出し: データを取得する処理
この記事では、この2つを分ける理由と、分けるときの目安を整理します。
BFFは単なるAPI中継ではない
BFF は Backend For Frontend の略です。
フロントエンドのために用意されるバックエンド層です。
BFF は、バックエンド API をそのまま横流しするだけの層ではありません。
画面が使いやすい形にデータを整える役割を持つことがあります。
たとえば、フロントエンド側で複数の API を直接呼び出すと、画面側に次のような処理が増えます。
- 複数 API の呼び出し
- レスポンスの結合
- 不要な項目のフィルタリング
- 表示用データへの変換
- エラーハンドリング
- 認証や認可に応じたレスポンス制御
これらをすべて UI コンポーネントに書くと、画面側の責務が重くなります。
そこで BFF 側で、画面に必要な形に整えて返します。
画面単位BFFではDTOが増えやすい
画面と BFF API を、ある程度 1 対 1 に近い単位で分ける構成があります。
たとえば、次のような形です。
画面
├─ ペインA
│ └─ BFF API A
├─ ペインB
│ └─ BFF API B
└─ モーダルC
└─ BFF API C
この構成では、画面内の部品ごとに必要なデータが違います。
一覧で必要な情報、詳細で必要な情報、モーダルで必要な情報は同じではありません。
その結果、BFF 側でも API や DTO が増えやすくなります。
これは悪いことではありません。
画面に合わせたデータを返すために BFF を置いているなら、画面単位で DTO が増えるのは自然です。
問題は、増えた DTO と API 呼び出し関数を同じ場所に積み続けることです。
最初はapi.tsにまとめてもよい
小さいうちは、1つの api.ts に DTO と API 呼び出し関数をまとめても問題ありません。
export interface UserResponseDto {
id: string;
name: string;
email: string;
}
export interface LogResponseDto {
id: string;
message: string;
createdAt: string;
}
export const fetchUser = async (): Promise<UserResponseDto> => {
const res = await fetch("/api/user");
return res.json();
};
export const fetchLogs = async (): Promise<LogResponseDto[]> => {
const res = await fetch("/api/logs");
return res.json();
};
この段階では、DTO と API 呼び出し関数が同じ場所にあるため、どの API がどの型を返すのか分かりやすいです。
ファイルも少なく、変更箇所も追いやすいです。
最初から細かく分けすぎると、逆に見通しが悪くなることもあります。
増えるとapi.tsが読みにくくなる
BFF API が増えると、api.ts は次のような状態になりやすいです。
api.ts
- UserRequestDto
- UserResponseDto
- LogRequestDto
- LogResponseDto
- SearchConditionDto
- ModalResponseDto
- PaneAResponseDto
- PaneBResponseDto
- fetchUser()
- fetchLogs()
- fetchPaneA()
- fetchPaneB()
- fetchModal()
この状態になると、型定義を見たいだけなのに API 呼び出し処理も目に入ります。
逆に API 呼び出しの流れを見たいだけなのに、大量の DTO が邪魔になります。
DTO と API 呼び出し関数は、どちらも API 周辺のコードです。
しかし、関心は違います。
DTO = 何を受け取るか、何を返すか
API = どこから、どう取得するか
この違いが出てきたら、ファイルを分けるタイミングです。
DTOはデータの形、APIは取得処理
DTO と API 呼び出し関数は、変更理由が違います。
DTO はデータの形を表します。
- 画面に返す項目が増える
- バックエンド API のレスポンス形式が変わる
- 表示用に項目名を変える
- 不要な項目を返さないようにする
API 呼び出し関数は、データを取得する処理を表します。
- URL が変わる
- HTTP メソッドが変わる
- クエリパラメータが増える
- エラーハンドリングを追加する
- 認証ヘッダーを付ける
この2つを同じファイルに置き続けると、データ構造の変更と通信処理の変更が混ざります。
ある程度大きくなったら、次のように分けると見通しがよくなります。
logs/
logs.dto.ts
logs.api.ts
users/
users.dto.ts
users.api.ts
logs.dto.ts はログ関連のデータ構造を置く場所です。
logs.api.ts はログ関連の API 呼び出しを置く場所です。
分けた後の例
DTO は DTO だけでまとめます。
export interface BaseLogDto {
id: string;
createdAt: string;
message: string;
}
export interface WatchLogDto extends BaseLogDto {
targetId: string;
ruleName: string;
}
export interface SystemLogDto extends BaseLogDto {
serviceName: string;
errorCode?: string;
}
API 呼び出し関数は、DTO を import して使います。
import type { SystemLogDto, WatchLogDto } from "./logs.dto";
export const fetchWatchLogs = async (): Promise<WatchLogDto[]> => {
const res = await fetch("/api/watch-logs");
return res.json();
};
export const fetchSystemLogs = async (): Promise<SystemLogDto[]> => {
const res = await fetch("/api/system-logs");
return res.json();
};
この形にすると、データ構造を確認したいときは logs.dto.ts を見ます。
通信処理を確認したいときは logs.api.ts を見ます。
読む目的ごとにファイルが分かれるため、変更箇所を追いやすくなります。
API単位でさらに分けてもよい
DTO がさらに増える場合は、API 単位で細かく分けることもあります。
logs/
dto/
base-log.dto.ts
search-log.request.dto.ts
search-log.response.dto.ts
watch-log.dto.ts
system-log.dto.ts
logs.api.ts
この分け方は、DTO の数が多い場合に有効です。
ただし、最初からこの粒度にする必要はありません。
DTO が少ないうちは、logs.dto.ts にまとめた方が読みやすいです。
分割は、ファイルが読みにくくなってからで十分です。
interface extendsで共通項目をまとめる
DTO が増えると、共通項目が出てきます。
たとえばログ系の DTO では、次の項目が共通することがあります。
- id
- createdAt
- message
このような場合は、共通項目を BaseLogDto にまとめ、用途ごとの DTO で extends できます。
export interface BaseLogDto {
id: string;
createdAt: string;
message: string;
}
export interface WatchLogDto extends BaseLogDto {
targetId: string;
ruleName: string;
}
export interface SystemLogDto extends BaseLogDto {
serviceName: string;
errorCode?: string;
}
これは、クラス継承というより、型の共通部分を部品化する考え方に近いです。
同じ項目を何度も書かずに済みます。
また、共通項目の意味がそろっていることも表しやすくなります。
Base DTOを太らせすぎない
共通化は便利ですが、やりすぎると逆に読みにくくなります。
たとえば、次のような BaseLogDto は扱いづらいです。
export interface BaseLogDto {
id: string;
createdAt: string;
updatedAt: string;
message: string;
userId?: string;
ruleName?: string;
serviceName?: string;
errorCode?: string;
}
一部の DTO にしか存在しない項目を optional で詰め込むと、何でも入る型になります。
この状態になると、BaseLogDto を見ても、どの項目が本当に共通なのか分かりにくくなります。
Base DTO に置くのは、次の条件を満たす項目に絞ると扱いやすいです。
- 全種類に必ず存在する
- 意味が同じ
- 変更理由が近い
逆に、次のような共通化は避けた方がよいです。
- 一部の DTO にしかない項目を optional で詰める
- 用途が違う項目を無理にまとめる
- 共通化のためだけに意味の薄い型を作る
DTO の共通化は、重複を消すことが目的ではありません。
データの意味を読みやすくするために使います。
画面用DTOとバックエンドAPIのDTOを混ぜない
BFF では、画面に返す DTO と、バックエンド API から受け取る DTO が違うことがあります。
たとえば、バックエンド API は詳細な情報を返していても、画面では一部の項目しか使わないことがあります。
export interface BackendUserDto {
id: string;
name: string;
email: string;
status: string;
createdAt: string;
updatedAt: string;
}
export interface UserListItemDto {
id: string;
name: string;
status: string;
}
この2つを同じものとして扱うと、画面の都合とバックエンド API の都合が混ざります。
BFF の役割は、バックエンド API の形をそのままフロントエンドに見せることではありません。
画面が使いやすい形に変換することです。
そのため、必要であれば次のように分けます。
users/
dto/
backend-user.dto.ts
user-list-item.dto.ts
users.api.ts
users.mapper.ts
backend-user.dto.ts はバックエンド API から受け取る形です。
user-list-item.dto.ts は画面や BFF API として返す形です。
users.mapper.ts は変換処理を置く場所です。
このように分けると、どの型がどの境界のためのものか分かりやすくなります。
分けすぎにも注意する
DTO と API 呼び出しを分けると見通しはよくなります。
ただし、分ければ分けるほどよいわけではありません。
小さい機能で次のように細かく分けると、読むファイルが増えすぎます。
users/
dto/
user-request.dto.ts
user-response.dto.ts
api/
fetch-user.api.ts
mapper/
user.mapper.ts
constants/
user.constants.ts
まだ DTO が1つ、API 呼び出しも1つしかないなら、ここまで分ける必要はありません。
最初は api.ts にまとめる。
増えてきたら dto.ts と api.ts を分ける。
さらに増えたら dto/ 配下に分ける。
このくらいの段階的な整理で十分です。
判断基準
DTO と API 呼び出しを分けるかどうかは、次の状態を目安にすると判断しやすいです。
-
api.tsが長くなり、型定義と処理が混ざって読みにくい - 1つの画面に複数の BFF API がある
- ペインやモーダルごとに DTO が分かれている
- 画面用 DTO とバックエンド API の DTO が混ざっている
- 共通項目を持つ DTO が増えている
- 使われていない DTO を判断しにくい
この状態になっていなければ、無理に分けなくてもよいです。
設計は、将来のために最初から細かくするより、読みにくくなったタイミングで責務ごとに分ける方が運用しやすいです。
まとめ
BFF では、画面に必要なデータを集約・変換して返すため、DTO と API 呼び出し関数が増えやすくなります。
小さいうちは、DTO と API 呼び出しを同じ api.ts に置いても問題ありません。
どの API がどの型を返すのか分かりやすいからです。
ただし、画面単位、ペイン単位、モーダル単位で DTO が増えてくると、責務が混ざりやすくなります。
その段階では、次のように分けると見通しがよくなります。
DTO = データの形
API = データを取得する処理
共通項目は interface extends でまとめられます。
ただし、Base DTO を太らせすぎると、かえって意味が分かりにくくなります。
DTO と API 呼び出しを分ける目的は、ファイルを増やすことではありません。
データの形と取得処理を分けて、変更理由を追いやすくすることです。