はじめに
TanStack Start の Server Function と Hono の HTTP API を、同じ Service / Repository の上に載せて比較した実験記録と、そこから導いた「境界」についての考察。
この記事では、各節で次のラベルを使い、事実と考察を分けて書く。
| ラベル | 意味 |
|---|---|
| [Observed] | 実験で実際に確認したこと |
| [Interpretation] | その結果をどう解釈できるか |
| [Implication] | 設計上、何を考える材料になるか |
| [Not Verified] | 今回の実験では確認していないこと |
「TanStack Start と Hono のどちらを使うべきか」を決める記事ではない。どちらが優れているか、どちらが速いかという結論も出さない。
扱う問いは次の一点である。
FrontendとBackendの境界を、現代のTypeScriptではどのように設計できるのか
この問いを、最小の Developer Portal を実装して 2 つの経路で同じユースケースを実現し、Jupyter Notebook で観測した結果から考察する。
なぜこの実験をするのか
従来の Frontend / Backend 開発では、境界は比較的はっきりしていた。
Frontend と Backend の間には HTTP API があり、境界は「HTTP であること」とほぼ同義だった。
一方、TanStack Start のような Full-stack TypeScript framework では、Server Function によって、Server 側の処理を Frontend のコードからかなり近い形で呼び出せる。対して Hono は、HTTP API という境界を明示的に設計する側に立つ。
そこで次の疑問が出てきた。
この2つは、実際には何が違うのか?
「関数呼び出しのように書ける」と「HTTP API として設計する」の違いを、印象ではなく、同じ Service / Repository を共有した状態で観測して確かめる。これが実験の動機である。
作ったもの
技術構成
TypeScript / React / TanStack Start / TanStack Router / Hono / PostgreSQL / Docker / Jupyter Notebook
ドメイン
Project と Event だけを持つ、非常に小さな Developer Portal である。
Project: id, name, status, createdAt
Event: id, projectId, type, createdAt
UI
/projects Project一覧
/projects/:projectId Project詳細(関連Eventも表示)
Hono API
GET /api/projects
GET /api/projects/:projectId
POST /api/projects/:projectId/events
ドメインを小さくしたのは意図的である。ビジネスロジックの複雑さを排除し、比較対象を「Backend の処理」ではなく「Backend へ到達する入口」に絞るためである。
アーキテクチャ
責務は次のように分離している。
backend/
├── model/
├── controller/
├── service/
└── repository/
| 層 | 責務 |
|---|---|
| Model | ドメインデータの型・構造を表現する |
| Repository | PostgreSQL とのデータアクセスを担当する |
| Service | Project 取得や Event 登録などのユースケースを担当する |
| Controller | HTTP API における HTTP リクエスト / レスポンスの責務を担当する |
重要なのは、Server Function と Hono Controller の両方が同じ Service を利用する点である。
この設計にした理由
比較したいのは Backend のビジネスロジックではなく、「Frontend / Client から Backend の処理へ到達する境界」である。Service と Repository を共通化すれば、2 経路の差は入口に限定される。入口以外の変数を固定することで、観測された差を入口に帰属させやすくなる。
また Controller の責務を「HTTP のリクエスト / レスポンス」に限定したことで、HTTP 固有の関心事と、ユースケースとしての責務が、コード上で分かれる。この分離は後述する「責務上の境界」の議論の前提になる。
Server FunctionとHono
Path A: TanStack Start Server Function
Path B: Hono HTTP API
コードが表現する境界
以下は、境界の表現を示すための最小例である(実装の抜粋ではなく、構造を示す簡略版)。
Server Function → Service
export const getProjects = createServerFn({ method: "GET" }).handler(
async () => projectService.listProjects(),
);
この形では、Frontend のコードから見た境界は「関数の呼び出し」として表現される。引数と戻り値の型は呼び出し側のコードから直接見える。HTTP のパスやメソッドを呼び出し側が組み立てる場面は、コード上には現れない。
Hono Controller → Service
app.get("/api/projects", async (c) => {
const projects = await projectService.listProjects();
return c.json(projects);
});
この形では、境界は「HTTP リクエストとレスポンス」として表現される。パス、メソッド、ステータス、シリアライズ形式が、契約としてコードに明示される。
共通 Service → Repository
export const projectService = {
listProjects: () => projectRepository.findAll(),
};
Service は入口を知らない。HTTP も Server Function も、この層には現れない。この層が、2 つの入口の責務上の境界を担っている。
実験方法
notebooks/architecture-comparison.ipynb で、同じ操作を Server Function と Hono API の両方から実行し、結果と通信の様子を観測した。
実験結果
Project取得
[Observed]
Server Function と Hono API の両方から Project を取得でき、両者で取得したデータ内容は一致した。
[Interpretation]
2 つの入口が、同じ Service を経由して同じ種類のデータを返していることと整合する。
Event登録
[Observed]
- Server Function から Event を登録できた。
- Hono API から Event を登録できた。
- Server Function から登録した Event を Hono API から取得できた。
- Hono API から登録した Event を Server Function から取得できた。
データ整合性
[Observed]
上記の相互取得が成立した。
[Interpretation]
片方の入口から書き込んだデータを、もう片方の入口から読めたことから、両経路が同じ Service / Repository / PostgreSQL を利用していることを確認できた。
[Not Verified]
同時書き込み、トランザクション境界、競合時の挙動は確認していない。
通信境界
[Observed]
| 呼び出し元 | 呼び出し先 | 観測内容 |
|---|---|---|
| Notebook | Server Function | Node adapter ではプロセス内で呼び出されており、アプリケーションの HTTP route にはアクセスしなかった |
| Browser | Server Function | ローカル開発環境で GET /_serverFn/... という fetch を観測した |
| Notebook | Hono |
GET /api/projects などの HTTP API として呼び出した |
Browser から観測した GET /_serverFn/... は、今回観測した GET リクエストについての結果である。Server Function 一般のすべての通信方式についての結果ではない。
[Interpretation]
同じ Server Function でも、呼び出し元によって到達の仕方が異なっていた。Hono は、どこから呼んでも HTTP API として到達する。Backend の処理への到達方法と、通信境界の見え方は、入口によって異なる。
[Implication]
「Backend へ到達する」ことと「HTTP を話す」ことは、設計上、切り分けて考えられる。
[Not Verified]
POST 等の他メソッド、他の adapter、本番ビルド、他のデプロイ形態での通信方式は確認していない。
応答時間について
応答時間は測定したが、Server Function と Hono の性能比較には使えない。測定条件が一致していないためである。
Server Function 側: 起動等(Node / Vite / アプリケーション起動)+ 処理
Hono 側: すでに起動している API への HTTP 往復 + 処理
したがって、この測定値から速度・性能の優劣を述べることはしない。性能比較のグラフやランキングも作らない。
結果から考えるFrontend / Backendの境界
BackendはHTTP APIなのか
[Interpretation]
従来は Frontend → HTTP → Backend を Backend の基本形と考えることが多かった。今回、Server Function は Frontend のコードから関数に近い形で呼び出せた。Backend という論理的な責務と、HTTP という通信プロトコルは、必ずしも同一ではない。
[Implication]
Backend を設計するとき、「責務をどこに置くか」と「どのプロトコルで到達させるか」は別の判断として扱える。
[Not Verified]
この解釈は、今回の最小構成の観測に基づく。大規模アプリケーションで同じ整理が有効かは確認していない。
Server FunctionはBackendをなくすのか
[Observed]
今回の構成でも、Server Function の先には Service、Repository、PostgreSQL があった。
[Interpretation]
Server Function によって Backend が消えたわけではない。Server 側の責務は存在し続けている。変わったのは、Frontend から Server 側の処理へ到達する際の境界の表現である。
HTTP境界を明示する意味
Hono は、Client → HTTP → Hono → Service という明示的な HTTP 境界を設計できる。
[Interpretation]
外部 Client、Webhook、他サービス、別 Frontend、Public API のように、契約を HTTP として明示したい相手がいる場合、パス・メソッド・ステータス・ペイロードという形で境界が文書化可能になる。
「HTTP 境界が必要なら Hono を使う」という話ではない。HTTP を境界として明示すること自体が、アーキテクチャ上の意味を持つ。契約が明示されれば、利用者は実装の内部を知らずに連携できる。一方、境界を明示すれば、その契約を維持するコストも発生する。
[Not Verified]
外部公開 API としての運用(バージョニング、互換性維持、認証)は確認していない。
TypeScriptで境界をどう表現するか
「境界」は一つではない。今回の題材から、次のように整理できる。
| 境界 | 今回の実験で |
|---|---|
| Type Boundary | 未検証(型がどこまで伝播するかは今後の課題) |
| Communication Boundary | 観測した(プロセス内呼び出し / fetch / HTTP) |
| Process Boundary | 一部観測した(Notebook からの呼び出しがプロセス内だったこと) |
| HTTP Boundary | 観測した(Hono は常に HTTP、Server Function は呼び出し元で異なる) |
| Responsibility Boundary | 構造として確認した(Service が共通で、入口と責務が分離) |
| Deployment Boundary | 未検証 |
また、次の3つは別物である。
- コード上の境界: 関数呼び出しに見えるか、HTTP ハンドラに見えるか
- 通信上の境界: 実際にネットワークやプロセスをまたぐか
- 責務上の境界: どの層が何に責任を持つか
Server Function は、コード上は関数呼び出しに近いが、Browser からは fetch として観測された。コード上の見え方と通信上の見え方は一致しない。そして責務上の境界は、どちらの入口でも Service 層が担っていた。
[Implication]
境界を議論するときは、どの種類の境界について話しているのかを明示する必要がある。「境界が曖昧になる」「境界がなくなる」という表現は、どの境界のことなのかが示されて初めて検証可能になる。
TanStack Start × Honoを組み合わせる意味
UI 内部の処理、外部 Client との通信、HTTP API、Webhook は、境界の性質が異なる。
[Interpretation]
境界の性質が違うなら、入口の技術を一つに揃える必然性はない。入口ごとに適した表現を選び、ユースケースは同じ Service 層に収束させる。今回の実験は、この構造が成立すること(両入口からの相互読み書き)を示している。
[Implication]
境界の性質によって、適切な技術を選択できる。ただし、これは「この構成が常に正解」という主張ではない。入口が増えれば、認証、エラー表現、デプロイ、運用の面で別のコストが生じうる。
[Not Verified]
組み合わせた構成の運用コスト、認証の統一、エラー表現の一貫性は確認していない。
今回分からなかったこと
今回の実験だけでは、次の項目は判断できない。推測で結論は出さない。
- 本番環境での性能
- 高負荷時の挙動
- Server Function と Hono の性能差
- 大規模アプリケーションでの保守性
- 認証境界
- 外部公開 API としての運用
- エラー処理の複雑性
- チーム開発時の境界設計
- デプロイ境界
- Server Function と Hono を組み合わせた場合の運用コスト
特に、実験環境(ローカル開発、最小ドメイン、Notebook からの呼び出し)で確認できたことと、フレームワーク一般の特性は区別して読んでほしい。
次に試したいこと
優先度は付けない。
- Error Boundary: Server Function と Hono API で、エラーをどこまで自然に表現できるか
- Type Boundary: TypeScript の型がどこまで共有・伝播するか
- Authentication Boundary: 認証・認可をどこに置くのが自然か
- External Client: Hono API を外部 Client から利用した場合の設計
- Webhook: Hono の HTTP 境界が Webhook 受信にどう適合するか
- Deployment Boundary: Server Function と Hono を本番環境へデプロイした場合の構成
まとめ
今回の実験で重要だったのは、Server Function と Hono のどちらが優れているかを決めることではない。
同じ Service / Repository を使いながら異なる入口を作ったことで、次のことを確認できた。
FrontendからBackendへ処理が渡るとき、「責務の境界」と「通信の境界」は必ずしも同じではない
そして、モダンな TypeScript では、Frontend / Backend の境界を「HTTP API かどうか」だけでなく、責務・型・通信・プロセスといった複数の観点から設計できる。
この記事で確認できたのは最小構成での観測にすぎず、型・認証・デプロイなどは今後の検証対象である。それでも、「どの境界の話をしているのか」を分けて考える視点自体は、今後の設計判断の材料になると考えている。