「なんとなくミドルウェア」 を卒業する。 Hono で学んだ "層" の考え方
〜認証 ・ ログ ・ エラー処理を 「積み木」 にする設計思考〜
認証チェック、リクエストログ、エラーハンドリング。
あなたの API サーバーで、これらは どこに 書かれていますか?
もし答えが「ルートハンドラの中に、全部」なら。
この記事は、あなたのために書きました。
「なんとなくミドルウェア」 の正体
みなさんは、こんな経験はありませんか?
「Express でも Hono でも、
ミドルウェアって app.use( ) で追加するやつでしょ?」
使い方は知っている。
公式ドキュメントのサンプルも写経した。
CORS のミドルウェアも入れた。
Bearer 認証も追加した。
でも —— なぜその順番なのか、説明できますか?
「なんとなく、上から順に書いてある」
「なんとなく、動いているから大丈夫」
「なんとなく、他のプロジェクトからコピペした」
この「なんとなく」が、僕を何度も苦しめました。
ある日、認証ミドルウェアの前にログミドルウェアを置いていたせいで、
認証失敗のリクエストがログに残らないことに気づきました。
別の日には、エラーハンドリングの位置が悪くて、
ミドルウェア内で発生した例外がキャッチされず、
クライアントに生のスタックトレースが返っていた。
「なんとなく」は、いつか必ず事故になる。
この記事では、僕が Hono を使った個人開発のなかで辿り着いた、
ミドルウェアを「積み木」として設計する考え方を共有します。
ミドルウェアは 「処理」 ではなく 「層」
まず、発想の転換が必要です。
ミドルウェアを「処理の追加」と捉えていると、
「あれもこれも」と機能を詰め込みたくなります。
結果、ひとつのミドルウェアが肥大化して、
何をやっているのかわからなくなる。
ミドルウェアは「処理」ではなく「層」です。
地層を想像してみてください。
一番下に岩盤(エラーハンドリング)がある。
その上に土壌(ログ)が積もる。
さらにその上に表土(認証)がある。
そして地表(ルートハンドラ)に植物が生える。
リクエストは地表から入り、地層を通って岩盤に達する。
レスポンスは岩盤から地表に向かって戻ってくる。
この「上から下へ、下から上へ」の流れを意識すると、
ミドルウェアの 順番 に意味が生まれます。
Hono のミドルウェアも、まさにこの構造で動きます。
app.use( ) で登録した順に、リクエストは上のミドルウェアから
下のミドルウェアへ流れ、レスポンスは逆順に返る。
Express を使ったことがある方なら、
この構造自体は馴染みがあるかもしれません。
でも、「なぜその順番にするのか」を言語化できることと、
「なんとなく動いている」ことには、決定的な差があるのです。
ルートハンドラに全部押し込んでいた
フリーランスとして個人開発を始めた頃、
僕の API コードはこんな有様でした。
app.get('/api/users/:id', async (c) => {
// ログ出力
console.log(`[${new Date().toISOString()}] GET /api/users/${c.req.param('id')}`)
// 認証チェック
const token = c.req.header('Authorization')?.replace('Bearer ', '')
if (!token) {
return c.json({ error: 'Unauthorized' }, 401)
}
const user = await verifyToken(token)
if (!user) {
return c.json({ error: 'Invalid token' }, 401)
}
// ビジネスロジック
try {
const targetUser = await db.user.findUnique({ where: { id: c.req.param('id') } })
if (!targetUser) {
return c.json({ error: 'Not found' }, 404)
}
return c.json(targetUser)
} catch (e) {
console.error(e)
return c.json({ error: 'Internal server error' }, 500)
}
})ログ、認証、エラー処理、ビジネスロジック。
全部が 1 つのハンドラに同居している。
1 つのエンドポイントなら、まだ読めます。
でも、エンドポイントが 10 個、20 個と増えたらどうなるか。
同じログ出力コードが 20 箇所にコピペされる。
認証チェックのロジックが微妙に違うバージョンが 3 つ存在する。
エラーレスポンスの形式が統一されていない。
僕のコードベースは、3 ヶ月で「読みたくないコード」になりました。
しかも厄介なのは、「動いている」ことです。
テストは通る。
API は正常にレスポンスを返す。
でも、新しいエンドポイントを追加するたびに、
同じボイラープレートをコピペする手が止まる。
「これ、絶対にどこかで間違えるな…」
その予感は、的中しました。
「積み木」 という捉え方
転機は、Hono の公式ドキュメントを読み直していたときでした。
Hono のミドルウェアは、next( ) を呼ぶことで
「次の層」に処理を渡します。
next( ) の前に書いた処理は「リクエスト時」に、
next( ) の後に書いた処理は「レスポンス時」に実行される。
app.use(async (c, next) => {
// ← リクエスト時にここが実行される
await next()
// ← レスポンス時にここが実行される
})このシンプルな構造を見て、僕はハッとしました。
これは「積み木」だ。
1 つひとつのミドルウェアは、独立した「積み木のブロック」。
上に積む順番で、振る舞いが変わる。
1 つ抜いても、他のブロックは崩れない。
子どもの頃、積み木で遊んだ記憶が蘇りました。
最初に大きなブロック(土台)を置いて、
その上に小さなブロックを重ねていく。
土台がしっかりしていれば、上に何を積んでも安定する。
API サーバーのミドルウェアも、同じだったのです。
土台(最下層):
エラーハンドリング
👉️ 何が起きても、必ずここで受け止める
中間層:
ログ
👉️ すべてのリクエストとレスポンスを記録する
上層:
認証
👉️ 許可されたリクエストだけを通す
この 3 つの積み木を正しい順番で重ねれば、
ルートハンドラには ビジネスロジックだけ が残る。
以前書いた「Hono のエラー設計。ログと例外を『最初に』決める理由」
は、実はこの積み木の「土台」にあたる部分でした。
あの記事でカスタム例外クラスと onError を設計したことで、
今回の「層の考え方」が見えるようになったのです。
【3 つの積み木】 僕のミドルウェア設計パターン
具体的に、僕がどういう順番で
ミドルウェアを積んでいるかをお見せします。
リクエスト →
[1. エラーハンドリング層] ← 最下層: 例外を受け止める
[2. ログ層] ← 中間層: リクエスト / レスポンスを記録する
[3. 認証層] ← 上層: アクセスを制御する
[ルートハンドラ] ← ビジネスロジックだけ
[3. 認証層]
[2. ログ層]
[1. エラーハンドリング層]
← レスポンスリクエストは 1 → 2 → 3 → ハンドラの順に通過します。
レスポンスはハンドラ → 3 → 2 → 1 の順に戻ります。
この順番には、明確な理由があります。
なぜエラーハンドリングが最下層なのか
エラーは、どの層でも発生しうるからです。
ログ層でエラーが出るかもしれない。
認証層で想定外の例外が飛ぶかもしれない。
ルートハンドラのビジネスロジックで DB 接続が切れるかもしれない。
どこで何が起きても、最終的にエラーハンドリング層が受け止める。
これが「最後の砦」です。
最下層に置くからこそ、他のすべての層を守れるのです。
なぜログが中間層なのか
ログは、認証の成功 / 失敗に関わらず記録したいからです。
もしログ層が認証層の上(後)にあったら、
認証に失敗したリクエストはログに残りません。
不正アクセスの試行を検知できなくなる。
逆に、エラーハンドリング層の上にログ層を置くことで、
エラーが発生したリクエストの情報もログに残ります。
なぜ認証が最上層なのか
認証は「通すか、弾くか」の判断です。
弾いたリクエストは、ビジネスロジックに到達させたくない。
最上層に置くことで、認証を通過したリクエストだけが
ルートハンドラに届きます。
この「なぜ」を言語化できるかどうかが、
「なんとなくミドルウェア」と「設計されたミドルウェア」の
分かれ道なのです。
【実装】 Hono で 「3 層の積み木」 を組み立てる
ここからは、実際のコードで 3 つの積み木を組み立てます。
前提として、以下の環境を想定しています。
☑ Hono v4 系
☑ TypeScript
☑ Node.js(Cloudflare Workers でも同様の構成が可能)
【積み木 1】 エラーハンドリング層 (最下層)
まず、土台を作ります。
以前の「Hono のエラー設計」で紹介したカスタム例外クラスを使います。
// middleware/error-handler.ts
import { Context } from 'hono'
import { AppException, InternalServerErrorException } from '../exceptions'
import { logger } from '../logger'
export const errorHandler = () => {
return async (c: Context, next: () => Promise<void>) => {
try {
await next()
} catch (err) {
const exception = err instanceof AppException
? err
: new InternalServerErrorException()
if (exception.statusCode >= 500) {
logger.error(exception.message, {
stack: err instanceof Error ? err.stack : undefined,
path: c.req.path,
method: c.req.method,
code: exception.code,
})
} else {
logger.warn(exception.message, {
path: c.req.path,
code: exception.code,
})
}
return c.json(
{
success: false,
error: { code: exception.code, message: exception.message },
},
exception.statusCode
)
}
}
}ポイントは、try { await next( ) } catch (err) {} という構造です。
next( ) を呼ぶことで、
上の層(ログ層、認証層、ルートハンドラ)すべてが実行されます。
そのどこかで例外が投げられたら、この catch で受け止める。
まさに「最後の砦」です。
【積み木 2】 ログ層 (中間層)
次に、リクエストとレスポンスを記録するログ層を作ります。
// middleware/request-logger.ts
import { Context } from 'hono'
import { logger } from '../logger'
export const requestLogger = () => {
return async (c: Context, next: () => Promise<void>) => {
const start = Date.now()
const method = c.req.method
const path = c.req.path
// リクエスト時: ログ開始
logger.info(`--> ${method} ${path}`, {
type: 'request',
method,
path,
userAgent: c.req.header('User-Agent'),
})
await next()
// レスポンス時: ログ完了(所要時間を記録)
const duration = Date.now() - start
const status = c.res.status
logger.info(`<-- ${method} ${path} ${status} ${duration}ms`, {
type: 'response',
method,
path,
status,
duration,
})
}
}next( ) の前後で処理を分けているのが肝です。
next( ) の前 = リクエストが入ってきた瞬間を記録
next( ) の後 = レスポンスが返る直前を記録(所要時間つき)
これで、すべてのリクエストの「入り」と「出」が
構造化ログとして残ります。
認証に失敗したリクエストも、エラーが発生したリクエストも、すべて。
【積み木 3】 認証層 (上層)
最後に、認証ミドルウェアを作ります。
// middleware/auth.ts
import { Context } from 'hono'
import { UnauthorizedException } from '../exceptions'
export const authGuard = () => {
return async (c: Context, next: () => Promise<void>) => {
const authHeader = c.req.header('Authorization')
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedException(
'Authorization header is missing or invalid',
'AUTH_HEADER_MISSING'
)
}
const token = authHeader.replace('Bearer ', '')
const payload = await verifyToken(token) // JWT 検証など
if (!payload) {
throw new UnauthorizedException(
'Token is invalid or expired',
'TOKEN_INVALID'
)
}
// 検証済みのユーザー情報をコンテキストに格納
c.set('currentUser', payload)
await next()
}
}認証に失敗した場合、UnauthorizedException を投げるだけです。
try-catch は書きません。
投げられた例外は、
下の層(エラーハンドリング層)が受け止めてくれます。
ログ層がリクエスト情報を記録してくれます。
各層は、自分の責務だけを果たせばいい。
これが「積み木」の強さです。
【積み木を重ねる】 app.use( ) の順番
3 つの積み木を、Hono のアプリケーションに重ねます。
// index.ts
import { Hono } from 'hono'
import { errorHandler } from './middleware/error-handler'
import { requestLogger } from './middleware/request-logger'
import { authGuard } from './middleware/auth'
import { NotFoundException } from './exceptions'
const app = new Hono()
// === 積み木を重ねる(順番が重要!) ===
// 1. 最下層: エラーハンドリング(すべての例外を受け止める)
app.use(errorHandler())
// 2. 中間層: ログ(すべてのリクエストを記録する)
app.use(requestLogger())
// 3. 上層: 認証(保護されたルートにのみ適用)
app.use('/api/*', authGuard())
// === ルートハンドラ(ビジネスロジックだけ!) ===
app.get('/api/users/:id', async (c) => {
const id = c.req.param('id')
const currentUser = c.get('currentUser')
const user = await db.user.findUnique({ where: { id } })
if (!user) {
throw new NotFoundException('User not found', 'USER_NOT_FOUND')
}
return c.json({ success: true, data: user })
})
export default appルートハンドラを見てください。
ログ出力のコードがない。
認証チェックのコードがない。
try-catch もない。
あるのは、ビジネスロジックだけ。
「ユーザーを取得して、なければ 404 を投げる。あれば返す。」
これだけです。
ログは中間層が勝手に記録してくれる。
認証は上層が通過済み(currentUser が使える)。
エラーは最下層が受け止めてくれる。
各層が自分の仕事をしてくれるから、
ルートハンドラは「やりたいこと」だけに集中できるのです。
【積み木の足し引き】 層の柔軟性
積み木の良いところは、差し替えが効くことです。
たとえば、新しいエンドポイントグループを追加するとき。
// 管理者専用ルート: 認証 + 管理者権限チェック
app.use('/admin/*', authGuard())
app.use('/admin/*', adminGuard()) // 追加の積み木
// 公開 API: 認証なし(ログとエラーハンドリングだけ)
app.get('/public/health', (c) => c.json({ status: 'ok' }))公開 API には認証の積み木を載せない。
管理者 API には、認証の上にさらに権限チェックの積み木を載せる。
積み木を足す・引くだけで、ルートごとの振る舞いが変わる。
ルートハンドラのコードは一切変更しなくていい。
これが、ミドルウェアを「層」として設計する最大の恩恵です。
【新しい積み木の追加例】 レートリミット
将来、レートリミット(リクエスト制限)を
追加したくなったとしましょう。
// 積み木を 1 つ追加するだけ
app.use(errorHandler())
app.use(requestLogger())
app.use(rateLimiter({ max: 100, window: '1m' })) // 新しい積み木
app.use('/api/*', authGuard())既存のコードに影響なし。
新しい積み木を、適切な位置に差し込むだけ。
ログ層の下、認証層の上に置く理由は明確です。
レートリミットに引っかかったリクエストもログには残したい。
でも、認証より先にチェックして、
過剰なリクエストはそもそも認証処理に進ませたくない。
順番の「なぜ」が言語化できるから、新しい積み木の置き場所も迷わない。
【積み木設計のルール】 3 つの原則
最後に、僕がミドルウェアを設計するときに守っている 3 つの原則をまとめます。
【原則 1】 1 つの積み木に、 1 つの責務
1 つのミドルウェアが「認証もログもエラー処理も」やるのは NG です。
太ったミドルウェアは、テストが難しく、差し替えが効かなくなります。
1 つの積み木 = 1 つの責務。
迷ったら「このミドルウェアを一文で説明できるか?」
と自問してみてください。
一文で説明できないなら、分割のサインです。
【原則 2】 順番の 「なぜ」 を言語化する
app.use( ) の並び順を、コメントで説明できるようにしておく。
// 1. エラーハンドリング: すべての例外の最後の砦
// 2. ログ: 認証失敗も含め、全リクエストを記録
// 3. 認証: 保護ルートへのアクセス制御この 3 行のコメントがあるだけで、半年後の自分や、
チームメンバーが「なぜこの順番なのか」を即座に理解できます。
【原則 3】 ルートハンドラには 「やりたいこと」 だけ残す
ルートハンドラから、ログ、認証チェック、try-catch を追い出す。
残るのはビジネスロジックだけ。
ルートハンドラが「何をしているか」を 3 秒で理解できる状態。
それが、積み木設計のゴールです。
以前「Server Actions の『境界』を先に決める」という記事で、
バリデーション・認可・エラーの「どこでやるか」を
先に決める重要性を書きました。
今回のミドルウェア設計は、
その思想を Hono の API サーバー に適用したものです。
フレームワークは違っても、設計の原則は同じ。
「責務を分離し、層を重ねる」。
この考え方が身につくと、
Next.js でも Hono でも Express でも、
どんな環境でも「なんとなく」から卒業できます。
「積み木」 は、 未来の自分を守る
かつての僕は、ルートハンドラに全部押し込んでいました。
コピペで増殖するログ出力。微妙に違う認証チェック。
統一されていないエラーレスポンス。
「動いているから、まあいいか」
そう思っていた 3 ヶ月後の僕は、
自分のコードを読みたくなくなっていました。
今は違います。
新しいエンドポイントを追加するとき、
僕がやることはビジネスロジックを書くだけ。
ログは勝手に残る。
認証は通過済み。
エラーは最下層が受け止めてくれる。
「なんとなく」を卒業したら、
コードが読みやすくなり、追加が怖くなくなり、
何より開発が楽しくなりました。
もし今、あなたの API サーバーのルートハンドラに
「ログ出力」「認証チェック」「try-catch」が同居しているなら。
まずは 1 つだけ、ミドルウェアとして切り出してみてください。
1 つ切り出せたら、2 つ目も。
そして 3 つ目も。
気づけば、あなたのルートハンドラには
「やりたいこと」だけが残っているはずです。
あわせて読みたい
▼ 積み木の「土台」——エラー設計の具体的なコードと考え方を知りたいときに
カスタム例外クラスと onError フックの実装パターンを、
コピペで使える形で公開しています。
この記事の「積み木 1: エラーハンドリング層」の詳細版です。
▼ そもそもなぜ Hono なのか? 軽さと速さの魅力を知りたいときに
個人開発における「重い鎧」を脱ぎ捨てた原点の記事。
Hono が開発体験をどう変えてくれたのか、出会いの物語を綴っています。
▼ Hono × Cloudflare でパフォーマンスを体感したいときに
ミドルウェア設計を整えた API を、
Cloudflare Workers にデプロイして爆速を体感する。
Hono の本領を引き出す実践的な構成を紹介しています。
▼ Next.js 側での「認可」設計——ミドルウェア思考をフロントエンドに応用したいときに
NextAuth + Prisma で認証・認可を最初に設計する方法。
Hono のミドルウェアと同じく「後回しにしない」設計思想を
フロントエンドに適用した記事です。
▼ Server Actions の「境界」——バリデーション・認可・エラーをどこで処理するか迷ったときに
責務の分離と「どこでやるか」を先に決める。
ミドルウェアの積み木設計と同じ原則を、
Next.js の Server Actions に適用した実装ガイドです。
▼ 環境変数の「事故」を防ぎ、設定管理を鉄壁にしたいときに
ミドルウェアの外側にある「設定」も、事故の温床になります。
Zod で環境変数を型安全に管理し、
起動時に設定漏れを検知する方法を解説しています。
▼ 「動けばいい」からの卒業——テストで品質を守りたいときに
ミドルウェアを分離すると、テストも書きやすくなります。
「動けばいい」で地獄を見た僕が、
テストコードに救われるまでの失敗談と再起の記録です。
ひとりごと
「積み木」という比喩に辿り着いたのは、
実は珈琲を淹れているときでした。
豆を挽いて、フィルターをセットして、お湯を注ぐ。
この手順を入れ替えたら、珈琲にはならない。
「順番に意味がある」
——当たり前のことなのに、コードになると忘れてしまう。
僕が「なんとなくミドルウェア」を卒業できたのは、
「なぜこの順番なのか」を自分の言葉で
説明できるようになったからです。
この記事があなたにとって、
「なんとなく」を「意図的に」に変える
きっかけになれたら嬉しいです。

ここから先は
最後まで読んでいただき、ありがとうございます。もしこの記事が「役に立った」「心に響いた」と感じたら、珈琲一杯分サポートいただけると嬉しいです。あなたの温かい応援を力に、また次の創作活動に励みます。
