画面は情報設計の代弁者ではない — 共有バックエンドを画面ではなくドメインの語彙で切り直した話
Published on 2026年9月6日
設計結論
共有バックエンドの API は、画面の語彙ではなく、ドメイン(モデル)の語彙で説明できなければならない。
レビューで使う基準は一行に絞った。
この API を、画面名を使わずに説明できるか。
この一行を守るために、設計の順序を「画面 → API」から「情報設計 → ドメイン → API → BFF → 画面」に戻し、画面都合はすべて BFF で吸収することにした。
以下、なぜそうなったのか、何を失敗したのかを書く。
何が起きたか
題材は個人開発のサービス beatfolio。モノレポで、二つに分かれている。
apps/api-server— ドメインロジックを持つバックエンド。複数プロダクトで共有する前提apps/beatfolio— プロダクトの Client App。BFF と画面を持つ
将来は Client App をプロダクトごとに増やす計画だが、現時点でプロダクトは beatfolio 一つだけ。この「複数を想定しているが、実際には一つしかない」状態が、問題の土台になった。
気づいたきっかけは、アーティストのプロフィール保存 API だった。
編集画面はウィザード形式で、名前・ジャンル・Story・SNS リンク・画像を順に入力し、最後に「保存」を一回押す。対応する API は POST /artists/:artistId/profile 一本で、全項目をまとめて受け取り、集約を丸ごと置き換えていた。
一見まともに見える。しかしこれは「編集画面の保存ボタン」をそのまま HTTP にしただけだった。
- 章を一つだけ直したい運営ツールも、全項目を送り直す必要がある
- 「翻訳段落だけ書く」ような書き込みを表現できない
- 別プロダクトがこの情報を使うには、beatfolio のウィザード構成を知る必要がある
共有するはずのバックエンドに、特定プロダクトの画面構成が刻み込まれていた。api-server を切り出したのはドメインロジックを画面から独立させるためだったので、これでは切り出した意味がない。
なぜ起きたか
原因は二つある。
1. 画面から逆算した形を、そのまま共有層に置いていた
それまでの流れはこうだった。
PRD → 要件 → 画面設計 → 画面から API を分解 → データ構造
検証を早く回すには自然な順序で、順序そのものが悪いわけではない。
問題は、画面から逆算した形が、何も挟まずに共有層に置かれたことだ。そして単一クライアントしかないと、それを止めるものがない。比較する相手がいないので、唯一の画面が要求する形がそのまま正解になり、誰もそれを「画面都合」だと指摘できない。
2. 取得の形を更新に流用していた
取得 API はプロフィール全体を返す。それを見て「保存も同じ形で受ければいい」と流用した瞬間に、行為の単位が消えた。
しかし read と write は、単位を決める軸がそもそも違う。
- 更新は 行為 で決まる。章を書く、リンクを差し替える
- 取得は 誰が何のために見るか で決まる。本人が編集のために全部見る、訪問者が公開分を見る
read と write は対称ではないし、対称にしてはいけなかった。
どう直したか
基準: 誰の語彙で説明しているか
最初は四つの設計(情報モデル・API・動線・画面)の寿命差や変更確率まで整理してみたが、正しくても重すぎて、日々のレビューでは使えなかった。
効いたのは、もっと単純な線引きだった。
- 「章を書く」「公開する」「繋ぎ先を変える」 → モデルの語彙
- 「編集画面を保存する」「ウィザードの最終ステップを送信する」 → 画面の語彙
API はモデルの語彙で説明できなければならない。
これで「汎用かどうか」という曖昧な問いが、「モデルが許す行為がすべて露出しているか」という判定できる問いに置き換わる。汎用とは抽象度を上げることではなく、ドメインが本来できることを漏れなく、画面の名前を借りずに出していることだ。
順序: 情報設計を画面より前に置く
PRD → 情報設計(何を解決するか → どんな構造が要るか)
→ ドメイン(集約が何を持ち、何ができるか)
→ API(ドメインが許す行為と読み取りを露出する)
→ BFF(画面の形に組み替える)
→ 画面
プロフィールなら、「知る → 理解する → 応援する」というプロダクトが解きたい動線から、情報は三つに分かれる。
| 分類 | 中身 | 役割 |
|---|---|---|
| 属性 | 名前・画像・ジャンル・タグライン | 一覧で発見されるためのフック |
| Story | 活動の背景を語る章 | 理解してもらうための本体 |
| 応援導線 | SNS リンク・オファー | 外へ送り出す終点 |
この区分はウィザードのステップからではなく、プロダクトが解きたい動線から出ている。これが ArtistProfile という集約の中身になり、API はその後、画面はさらに後だ。
画面設計は捨てない。変わったのは、画面から逆算した形を共有層に置く前に、必ず情報設計とドメインを通すことだ。
形: 更新は行為ごとに小さく、取得は集約一本で大きく
| 種別 | 単位 | 形 |
|---|---|---|
| 更新 | 情報の構造(行為)ごと | 小さく複数。入力はその構造の項目だけ |
| 取得 | 集約ごと | 大きく一本。集約の構造をそのまま返す |
更新 API は構造ごとに分けた。
| 構造 | 行為 | エンドポイント |
|---|---|---|
| 属性 | 属性を直す | POST /artists/:artistId/attributes |
| Story | 章を書く | POST /artists/:artistId/story/chapters/:chapterKey |
| SNS リンク | 繋ぎ先を変える | POST /artists/:artistId/links |
| 画像 | 画像を差し替える | POST /artists/:artistId/profile/image |
| 公開 | 公開する / 取り下げる | POST /artists/:artistId/profile/publish |
ルールは四つ。
- 入力はその構造の項目だけ。属性の更新に Story を渡せない型にする
- 名前は行為にする。画面名・ボタン名を入れない
- 成功応答は更新した構造だけを返す。全体が欲しければ取得 API を呼ぶ
- 不変条件(公開に必要な項目が揃っているか)は publish のときだけ弾く。下書きは部分的でいい
取得 API は集約一本。応答のキー(attributes / story / links / presentation)は更新 API の構造名と一致させ、読んだ形と書く単位を同じ語彙で繋ぐ。購読可否・表示順・ラベル文言といった閲覧者の文脈は入れない。それは「誰が見るか」に依存する話で、集約の構造ではない。
境界: 画面都合は BFF が吸収する
api-server をモデルの語彙に閉じると、画面の語彙との差を誰かが埋める必要がある。それが BFF の仕事になる。
| 種別 | BFF の役割 | 厚み |
|---|---|---|
| 取得 | 集約一本を、画面の区画・表示順・文言に組み替える | 厚い |
| 更新 | 画面の入力を、構造ごとの更新 API に振り分ける | 薄い |
編集画面の「保存」ボタンは今もひとつのまま。BFF の saveMyProfile が受け、内部で attributes → story → links を順に呼ぶ。途中で失敗したら後続は送らず、どこまで保存できたかを添えて画面に返す。各更新は置換型で冪等なので、再送すれば全体が収束する。
ボタンは画面の語彙、更新 API はモデルの語彙。その対応づけを持つのが BFF。 この一文が、api-server と Client App の境界の定義になった。
補足: 境界の内側と外側
ここまでが本題。行為ごとに分けた結果として決め直したトランザクション境界と、BFF の失敗の伝え方は、実装の話なので折りたたんでおく。
境界の内側: トランザクションは usecase の外で張る
境界の内側: トランザクションは usecase の外で張る
更新 API を行為ごとに分けると、「どこで原子性を保証するか」を決め直す必要が出る。丸ごと保存のときは 1 リクエストが 1 トランザクションで、それ以上考えることがなかった。分けた後は、1 つの行為の中と、行為をまたぐ間の二つを別々に扱う。
まず前提を一つ置く。
集約境界 ≠ トランザクション境界
集約境界はドメインモデル上の不変条件の境界で、トランザクション境界は永続化の原子性の境界。両者は一致しなくてよい。別集約でも「登録時には原子的に作られる」という業務要件があれば両者をまたぐトランザクションを張るし、同じ集約でも行為ごとにトランザクションを分ける。
境界を張るのは usecase ではない
usecase は tx を引数で受け取らない。境界を張るのは、権能(capabilities)を組み立てる側だ。
エントリポイント
→ withArtistWriteCapabilitiesById(deps, subId, artistId, work)
→ Actor を解決(誰として書くか)
→ runInTransaction(db, buildCaps, work)
→ トランザクション内の executor をリポジトリにバインドして権能を作る
→ usecase に権能を渡して実行
usecase から見えるのは「渡された権能で読む・書く」だけで、トランザクション内で動いているかどうかは知らない。知らなくてよい。
save(data, tx) のように tx を引数で渡す方式をやめたのは、渡し忘れが静かに通るからだ。tx を書き忘れてもコンパイルは通り、テストも通り、本番で「トランザクション外の書き込み」だけが残る。executor を権能の生成時にバインドしてしまえば、渡し忘れという操作自体が存在しない。
業務エラーでロールバックする
usecase は失敗を例外ではなく Result の err で返す。しかし Drizzle のトランザクションは throw でしかロールバックしない。両立させるために、境界を張る側で err を内部シグナルに載せて throw し、境界の外で Result に戻す。
class RollbackSignal<T, E> extends Error {
constructor(readonly result: Result<T, E>) { super("rollback"); }
}
export const runInTransaction = async (db, buildCaps, work) => {
try {
return await db.transaction(async (tx) => {
const result = await work(buildCaps(tx));
if (!result.ok) throw new RollbackSignal(result);
return result;
});
} catch (error) {
if (error instanceof RollbackSignal) return error.result;
throw error;
}
};
usecase 側に try/catch は置かない。「業務エラーは err」という契約を usecase から見て崩さないまま、永続化の原子性を境界側で担保する。
行為の中は原子的、行為の間は原子的でない
この構造で、構造ごとの更新 API は次の性質を持つ。
| 範囲 | 原子性 | 理由 |
|---|---|---|
| 1 つの更新 API の中(集約を読む → 振る舞いを適用 → 保存) | あり | withArtistWriteCapabilitiesById が張るトランザクション内で完結する |
| BFF が順に呼ぶ複数の更新 API の間 | なし | 別リクエスト。意図的にまたがない |
後者を原子的にしないのは、それをすると「編集画面の保存」という画面の語彙をトランザクション境界にすることになり、api-server が再び画面の形を知ってしまうからだ。下書きの部分的な状態は情報設計上も許容される(不変条件を弾くのは publish のときだけ)ので、行為の間の原子性はドメインが要求していない。
例外は画像で、ストレージへの PUT は外部 I/O なのでトランザクションに入れない。ArtistStorageWriteCapabilities は境界を持たない権能として分け、「アップロード → URL を集約へ書く」の 2 つの usecase を順に呼ぶ。ストレージ書き込みと DB 更新の原子性は求めない。
現時点の前提と、その前提が崩れる契機
構造ごとの更新 API の実装は、集約全体を読み、振る舞いで一部を差し替え、集約全体を upsert する形にしている。並行更新は Last Write Wins。
これが成り立つのは「ArtistProfile を書くのは本人だけ」という現時点の前提があるからだ。同一ユーザーの単発操作で、しかも BFF が直列に送るので、構造同士が競合することはない。
前提が崩れるのは、運営ツールや別プロダクトが本人と並行して特定の構造だけを書き始めたときで、そのときは集約丸ごとの upsert が相手の構造を古いスナップショットで上書きする。API の表面は構造ごとに分かれているが、永続化はまだ分かれていない。契機が来たら、リポジトリの Writer を構造ごとに分ける(属性は profile 行の属性列だけ、リンクは links テーブルだけ)か、集約に version を持たせて楽観的ロックにする。「必要になってから」ではなく、この契機を並行更新ポリシーのドキュメントに書いておく。
境界の外側: BFF は「どこまで保存できたか」を型で返す
境界の外側: BFF は「どこまで保存できたか」を型で返す
行為をまたぐ原子性を持たないと決めた以上、途中で失敗したときに何が起きたかを画面に伝える責務が BFF に残る。「保存に失敗しました」だけでは、属性は保存されたのか、Story はどうなのかが分からない。
直列に呼び、失敗したら止める
saveMyProfile は attributes → story/chapters(章ごと)→ links を順に呼ぶ。1 つ失敗したら後続は送らない。
const steps = createSaveSteps();
await steps.run("attributes", () => artist.attributes.$post({ ... }));
for (const chapter of body.chapters) {
await steps.run(chapterStep(chapter.questionCode), () =>
artist.story.chapters[":chapterKey"].$post({ ... }));
}
await steps.run("links", () => artist.links.$post({ ... }));
steps.run は成功したステップを saved[] に積み、失敗したら PartialSaveFailedError を投げる。
type PartialSaveFailedError = Error & {
type: "PartialSaveFailedError";
saved: readonly SaveStep[]; // 保存済みのステップ
failedAt: SaveStep; // 失敗したステップ
upstream: UpstreamFailure; // api-server が返した失敗そのもの
};
直列にしたのは、これらが互いに依存するからではない(属性とリンクは独立に書ける)。saved[] が「ここまでは確定している」という単純な意味を持てるようにするためだ。並列にすると「属性は成功、Story は失敗、リンクは不明」のような状態を表現する必要が出る。
エラーの翻訳は errorMap が持つ
BFF のルートはステータスコードを決めない。エラーを型で投げ、errorMap がステータス・レスポンスボディ・ログレベルに翻訳する。
| BFF のエラー型 | ステータス | ボディ |
|---|---|---|
UpstreamUnavailableError |
503 | 一時的な障害 |
UpstreamServerError |
502 | api-server の 5xx |
UpstreamRejectedError |
api-server のまま | api-server の 4xx をそのまま |
UpstreamContractViolationError |
502 | 応答の形が契約と違う |
PartialSaveFailedError |
upstream のマッピングに従う |
upstream のボディ + saved / failedAt |
PartialSaveFailedError は自分でステータスを持たない。包んでいる upstream の翻訳をそのまま使い、ボディに saved と failedAt を添えるだけだ。「途中で失敗した」ことと「なぜ失敗したか」を混ぜない。
画面は構造の語彙で伝える
fetcher はボディから saved / failedAt を progress として復元し、画面は章ごとのステップを「Story」という区画に畳んで文言にする。
基本・Story は保存済みです。リンクの保存に失敗しました。もう一度保存すると全体が反映されます。
各更新は置換型で冪等なので、同じ入力で再送すれば保存済みのステップは同じ結果に上書きされ、失敗したステップだけが新たに反映される。全体が収束する。
「どこまで保存できたか」は api-server の語彙(attributes / chapter:beginning / links)で BFF に届き、画面の語彙(基本 / Story / リンク)に翻訳されて表示される。ここでも境界の対応づけを持つのは BFF だ。
残っている課題
- 失敗の種別で文言を分けていない。 api-server が入力を拒否した(4xx)場合も、一時的な障害(5xx)の場合も、「もう一度保存すると」と出る。前者は入力を直さないと再送しても同じ結果になる。
upstreamの種別は BFF まで届いているので、分けられる状態にはある。 - BFF に届く前の失敗は
progressを持たない。 BFF が途中まで送った後にレスポンスだけ失われた場合、画面には部分保存の事実が出ない。冪等なので再送で収束するが、その案内はまだない。 - 公開状態の変化が更新応答に現れない。 公開中のプロフィールが更新で公開条件を割ると
enforcePublishInvariantが非公開へ降ろすが、更新 API は更新した構造だけを返すので、画面はpublishedが変わったことを知らない。更新応答に含めるか、saveMyProfileの終了後に取得 API を一度引くか、どちらかが要る。
おわりに
| 症状 | 共有バックエンドの保存 API が、唯一のクライアントの編集画面と同じ形をしていた |
| 原因 | 画面から逆算した形をそのまま共有層に置いた/取得の形を更新に流用した |
| 基準 | この API を画面名を使わずに説明できるか |
| 形 | 更新は行為ごとに小さく複数、取得は集約ごとに大きく一本 |
| 境界 | 画面の語彙とモデルの語彙の対応づけを持つのが BFF |
| 原子性 | 1 つの行為の中は境界側(権能の組み立て)が張るトランザクションで原子的。行為の間はまたがない |
| 失敗の伝達 | BFF が saved[] / failedAt を型で返し、画面が構造の語彙で「どこまで保存されたか」を伝える |
UI は情報の最上位にあるものではない。共有バックエンドが持つべきなのは画面の形ではなく、「この領域で何を解決するために、どういう情報構造が要るか」という情報設計だ。それが明確なら、別のプロダクトが同じバックエンドを使いたくなったとき、最初のプロダクトの画面構成を知らなくても呼べる。
単一クライアントのうちは、画面がこの情報設計を代弁してしまう。だから意識的に画面の前に情報設計とドメインを置き、画面都合は BFF で吸収する。「この API を画面名を使わずに説明できるか」は、それが侵食されていないかをレビューのたびに確かめるための一行だ。