Nếu bạn từng làm việc với Express.js hoặc các framework MVC như Laravel, Ruby on Rails, bạn chắc chắn quen với pattern này: tách bạch Route và Controller thành hai file riêng biệt.
routes/
books.route.ts
controllers/
books.controller.ts
Trông có vẻ gọn gàng, có tổ chức. Nhưng khi bạn mang tư duy này sang Hono — đặc biệt nếu dùng TypeScript — bạn sẽ gặp ngay một vấn đề khá khó chịu.
Vấn đề với Controller kiểu MVC trong Hono
Giả sử bạn đang xây dựng một API quản lý sách. Theo kiểu MVC quen thuộc, bạn sẽ viết Controller như này:
// 🙁 books.controller.ts
import { Context } from 'hono'
export const getBook = (c: Context) => {
const id = c.req.param('id') // ❌ TypeScript không thể infer ':id' ở đây
return c.json(`get book ${id}`)
}
Rồi ở file route:
// books.route.ts
import { Hono } from 'hono'
import { getBook } from './books.controller'
const app = new Hono()
app.get('/books/:id', getBook)
export default app
Nhìn code chạy vẫn đúng, nhưng TypeScript sẽ không thể biết rằng c.req.param('id') là hợp lệ. Type của c lúc này chỉ là Context generic — không mang theo thông tin về path parameter :id.
Nếu bạn đổi tên param trên route thành :bookId mà quên cập nhật trong Controller, TypeScript sẽ không báo lỗi. Bug âm thầm xuất hiện mà không ai hay.
Để fix điều này, bạn phải viết generic phức tạp:
// 😰 Phải viết thế này mới đúng type
const getBook = (c: Context<{ Bindings: {}; Variables: {} }, '/books/:id'>) => {
...
}
Rõ ràng là không thoải mái chút nào.
Hono khuyến nghị gì?
Hono khuyến nghị viết handler trực tiếp ngay sau định nghĩa route — không tách Controller. Cách này tận dụng toàn bộ khả năng type inference của TypeScript:
// 😃 books.ts
import { Hono } from 'hono'
const app = new Hono()
app.get('/books/:id', (c) => {
const id = c.req.param('id') // ✅ TypeScript infer đúng, có autocomplete
return c.json(`get book ${id}`)
})
export default app
Vì handler được viết ngay tại chỗ định nghĩa route, Hono biết chính xác path pattern là gì, từ đó infer được kiểu dữ liệu của c.req.param() một cách tự động. Không cần generic phức tạp, không lo bug âm thầm.
Vậy tổ chức code lớn hơn thì sao?
Không có nghĩa là bạn phải nhét tất cả vào một file index.ts khổng lồ. Hono cung cấp app.route() để gom nhóm các endpoint theo tài nguyên, mỗi tài nguyên là một file riêng.
Ví dụ: API có cả /authors và /books
// authors.ts
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.json('list authors'))
app.post('/', (c) => c.json('create an author', 201))
app.get('/:id', (c) => c.json(`get ${c.req.param('id')}`))
export default app
// books.ts
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.json('list books'))
app.post('/', (c) => c.json('create a book', 201))
app.get('/:id', (c) => c.json(`get ${c.req.param('id')}`))
export default app
// index.ts
import { Hono } from 'hono'
import authors from './authors'
import books from './books'
const app = new Hono()
app.route('/authors', authors)
app.route('/books', books)
export default app
Cấu trúc thư mục lúc này sẽ là:
src/
index.ts
authors.ts
books.ts
Mỗi file là một mini-app Hono độc lập, chứa cả route lẫn handler. Gọn, rõ ràng, dễ maintain.
Nếu vẫn muốn tách handler ra — dùng factory.createHandlers()
Đôi khi bạn có lý do chính đáng để tách handler ra file riêng: tái sử dụng ở nhiều route, hoặc file route đang quá dài và muốn chia nhỏ. Hono có factory.createHandlers() trong hono/factory để làm điều này mà vẫn giữ được type inference đầy đủ.
Cách hoạt động
createFactory() tạo ra một factory object. Từ factory đó, bạn dùng factory.createHandlers() để định nghĩa một mảng handler — bao gồm middleware và handler cuối cùng. Khi gắn vào route, bạn spread mảng đó ra.
import { createFactory } from 'hono/factory'
const factory = createFactory()
const handlers = factory.createHandlers((c) => {
return c.json('ok')
})
// handlers là một mảng, phải spread khi dùng
app.get('/books', ...handlers)
Trông có vẻ phức tạp hơn so với inline handler thông thường. Vậy lợi ích thực sự nằm ở đâu? Ở chỗ bạn có thể nhét middleware vào trước handler, và type vẫn được infer xuyên suốt.
Ví dụ thực tế: Books API có auth middleware
Giả sử bạn có một middleware kiểm tra token và gắn userId vào context. Nếu dùng factory.createMiddleware(), Hono sẽ hiểu được rằng sau khi middleware chạy, c.var.userId là hợp lệ:
// books.handlers.ts
import { createFactory } from 'hono/factory'
const factory = createFactory()
// Middleware xác thực — gắn userId vào context
const authMiddleware = factory.createMiddleware(async (c, next) => {
const token = c.req.header('Authorization')
if (!token) return c.json({ error: 'Unauthorized' }, 401)
c.set('userId', 'u_123') // giả lập decode token
await next()
})
// GET /books — list tất cả sách của user
export const listBooks = factory.createHandlers(authMiddleware, (c) => {
const userId = c.var.userId // ✅ TypeScript biết userId tồn tại
return c.json({ userId, books: [] })
})
// GET /books/:id — lấy một cuốn sách
export const getBook = factory.createHandlers(authMiddleware, (c) => {
const id = c.req.param('id') // ✅ Vẫn infer đúng path param
const userId = c.var.userId
return c.json({ id, userId })
})
// POST /books — tạo sách mới
export const createBook = factory.createHandlers(authMiddleware, async (c) => {
const body = await c.req.json()
return c.json({ message: 'created', data: body }, 201)
})
Rồi ở file route, dùng bình thường:
// books.ts
import { Hono } from 'hono'
import { listBooks, getBook, createBook } from './books.handlers'
const app = new Hono()
app.get('/', ...listBooks)
app.get('/:id', ...getBook)
app.post('/', ...createBook)