Sau khi đã chạy được Worker và thiết lập được MVC tối giản, nhu cầu tiếp theo gần như luôn là đưa dữ liệu ra khỏi file tĩnh.
Nếu đang quen với Express, phản xạ thường là: cài Sequelize, tạo file kết nối database, viết model, thêm migration, rồi để app server giữ connection đó trong suốt vòng đời process.
Nhưng với Hono chạy trên Cloudflare Workers, tư duy này cần đổi một chút.
Bạn vẫn có schema, query method, migration và type, nhưng cách “gắn” database vào app không giống Express. Không có một Node server chạy mãi để bạn giữ connection global theo kiểu quen thuộc. Thay vào đó, database binding được Cloudflare inject vào runtime, rồi Hono đọc nó qua c.env.
Bài viết chính là đoạn chuyển mình đó:
thêm D1 binding và schema/query đầu tiên với Drizzle định nghĩa schema bằng TypeScript sinh migration SQL từ schema đưa database binding vào Hono app bằng type rõ ràng chuẩn hóa quy trình migration và Wrangler Trong bài này, mình sẽ hướng dẫn cách cài đặt và thiết lập Drizzle ORM cho một Hono Worker rất nhỏ, đồng thời luôn so sánh với cách nghĩ quen thuộc trong Express để bạn dễ map hơn.
Trước hết, cần cài những package nào?
Để cài đặt Drizzle, package.json cần được thêm các package và scripts sau:
Ý nghĩa của từng phần:
drizzle-orm: thư viện ORM dùng trong code ứng dụng drizzle-kit: công cụ CLI generate migration từ schema (giống Sequelize CLI) db:generate: sinh SQL migration từ schema TypeScript db:types: generate type cho Worker bindings So với Express:
Express thường thêm driver như pg, mysql2, better-sqlite3, hoặc adapter riêng của ORM Với Cloudflare D1, bạn không tự mở connection bằng URL database như trong Node Thay vì cấu hình host, port, username, password, bạn cấu hình binding trong wrangler.toml Đây là khác biệt đầu tiên rất quan trọng: trong Express, app “chủ động đi kết nối database”. Trong Workers, runtime “đưa database binding vào app”.
Bước 1: khai báo D1 binding trong Wrangler
Mở rộng wrangler.toml như sau:
Ở đây:
binding = "DB" là tên mà app sẽ dùng trong code, ví dụ c.env.DB database_name là tên database trên Cloudflare database_id và preview_database_id dùng để Wrangler biết phải nối vào D1 nào So với Express, phần này tương đương với việc bạn tạo DATABASE_URL hoặc một object config database. Nhưng thay vì:
thì trong Hono Worker bạn sẽ làm việc với:
Điều này khiến code chạy gần với runtime của Cloudflare hơn, nhưng cũng buộc bạn phải nghĩ theo hướng binding thay vì connection string.
Bước 2: generate type cho binding
Sau khi có D1 binding, sẽ có thêm 2 lớp type. Tại sao cần 2 lớp type?
Hãy hiểu theo cách này: Wrangler và Hono là 2 thế giới khác nhau, chúng không tự biết về nhau.
Type do Wrangler generate
worker-configuration.d.ts là file Wrangler tự sinh ra để TypeScript biết rằng môi trường runtime của Cloudflare (c.env) có tồn tại binding tên DB:
Đây là type được sinh ra từ lệnh:
File này nói với TypeScript toàn cục: "Ở tầng Cloudflare Worker, có một thứ tên DB."
Nhưng Hono không đọc file này. Hono là một framework độc lập — nó không quan tâm đến Cloudflare hay Wrangler.
Type app tự định nghĩa
Hono dùng generic type để biết app của bạn có những gì.
src/types/env.ts
Type này nhìn hơi nhỏ, nhưng rất quan trọng. Nó là cầu nối để Hono hiểu Bindings của app.
Hiểu đơn giản như sau:
worker-configuration.d.ts → nguồn (Cloudflare định nghĩa D1Database là gì) AppBindings → bạn dùng D1Database đó để khai báo Hono<{ Bindings: AppBindings }> → Hono đọc và gán vào c.env Khi bạn truyền AppBindings vào đây, Hono mới hiểu rằng: "Trong c.env sẽ có DB kiểu D1Database."
Nếu không có bước này, khi bạn gõ c.env.DB, TypeScript sẽ báo lỗi vì Hono không biết c.env có gì bên trong.
Lưu ý: Type do Wrangler thực tế sẽ tạo ra
Khi chạy npm run db:types không chỉ sinh type cho D1 binding — nó sinh type cho toàn bộ môi trường Cloudflare Worker của bạn.
Nhìn vào file thực tế, phần binding của bạn chỉ có vài dòng đầu:
Phần còn lại — hàng nghìn dòng bên dưới — là runtime types của toàn bộ môi trường Cloudflare Workers. Đây là các khai báo cho mọi thứ như DOMException, WebSocket, ReadableStream, D1Database, R2Bucket, KVNamespace, tất cả các AI model types, v.v.
Wrangler sinh ra file này không phải chỉ để mô tả binding của bạn — mà để thay thế hoàn toàn TypeScript lib mặc định (vốn được thiết kế cho trình duyệt, không phải Workers). Không có file này, TypeScript sẽ không biết D1Database là gì, fetch trong Workers hoạt động ra sao, hay ExecutionContext là cái gì.
Nói cách khác:
Phần bạn quan tâm chỉ là phần đầu. Phần còn lại là “nền tảng” mà Cloudflare cần inject để TypeScript hiểu đúng môi trường Workers thay vì môi trường browser.
Bước 3: tạo schema Drizzle
Phần trung tâm nhất của commit 4 là src/db/schema.ts:
Schema này khai báo bảng sitemap_entries với 3 cột:
Nếu bạn quen Express + Sequelize, ý tưởng không lạ:
ORM dùng shape đó để hỗ trợ query và type Nhưng có một khác biệt nhẹ:
với Drizzle, schema trông rất gần với SQL thật bạn không viết class model theo phong cách Active Record: nghĩa là bạn không tạo một class Model có sẵn các method như findAll() hay save() như nhiều ORM quen thuộc trong Express bạn không gọi kiểu SitemapEntry.findAll() Thay vào đó, bạn định nghĩa schema riêng, rồi viết query function riêng. Đây là phong cách rất hợp với project nhỏ cần sự rõ ràng.
Ghi chú về phong cách Active Record
Có nghĩa là với Drizzle trong project này, chúng ta không tạo một class kiểu SitemapEntry rồi gắn method trực tiếp lên class đó như:
Đó là kiểu gần với Active Record: model vừa đại diện cho dữ liệu, vừa tự chứa luôn logic truy vấn/ghi dữ liệu.
Còn trong repo này, mình đang làm theo kiểu khác:
schema.ts chỉ mô tả cấu trúc bảng queries/sitemapQuery.ts chứa các hàm query như getSitemapEntries() Tức là “model” không phải một class biết tự findAll() hay save().
Logic query được tách ra thành các function riêng.
Nếu so với Express:
Sequelize thường làm bạn nghĩ theo hướng gần Active Record hơn Drizzle trong repo này gần kiểu query builder / data mapper hơn, tức là tách schema và query ra riêng Bước 4: tạo database client cho D1
Thêm src/db/client.ts:
Ý nghĩa rất đơn giản:
hàm này nhận vào dbBinding Phần : AppBindings['DB'] chỉ là type annotation của TypeScript, để nói rằng: tham số dbBinding phải có cùng kiểu với DB trong AppBindings dbBinding chính là c.env.DB từ Cloudflare Worker. rồi truyền dbBinding cho drizzle(...) đưa D1 cho Drizzle xử lý để mình không phải làm việc trực tiếp với raw D1 API nữa kết quả trả về là một Drizzle database client để mình query tiếp Bước 5: tạo query method thay cho model kiểu Express
Commit 4 xóa src/models/sitemapModel.ts và thay bằng src/queries/sitemapQuery.ts:
Đây là chỗ nên dừng lại một chút, vì nó thể hiện rõ triết lý khác với Express.
Trong Express, nhiều người quen với:
model class có method tĩnh như findAll Ở repo này, tác giả chọn:
db/schema.ts để mô tả bảng queries/ để chứa các method đọc/ghi cụ thể controller gọi trực tiếp query function Cách này có vài lợi ích:
dễ đọc hơn cho người mới học TypeScript query nằm gần schema nhưng không trộn vào controller Nếu so với Express theo phong cách Mongoose hoặc Sequelize, đây là sự khác biệt khá lớn. Bạn không thao tác với “model instance” nữa, mà thao tác với “query function + schema object”.
Đó là lý do tại sao tên folder cũng thay đổi
Drizzle ORM: schema (có client.ts, sitemapSchema.ts), queries Bước 6: cập nhật Hono app để hiểu Bindings
Khi đã có D1 binding, src/index.ts, src/routes/siteRoutes.ts và controller đều cần type hóa theo AppBindings.
src/index.ts:
src/routes/siteRoutes.ts:
src/controllers/siteController.ts:
và ở route sitemap:
Đây là khác biệt rất rõ với Express:
Express hay truyền req, res nếu cần database, bạn thường import thẳng db từ module khác hoặc gắn thêm property vào req Còn ở Hono Worker:
c.env.DB là nguồn dữ liệu runtime type của c.env được gắn trực tiếp vào app qua Bindings Nói ngắn gọn:
Express quen với “import db trực tiếp” Hono Worker quen với “lấy binding từ context” Bước 7: cấu hình Drizzle Kit để generate migration
Thêm file drizzle.config.ts để cấu hình Drizzle CLI
File này nói cho Drizzle biết:
migration SQL sẽ được ghi ra thư mục nào database dialect là sqlite vì D1 dựa trên SQLite So với Express, phần này không khác nhiều về ý tưởng. Dù là Express hay Hono, bạn vẫn cần một nơi để ORM biết schema và nơi output migration. Khác biệt nằm ở runtime dùng migration đó như thế nào.
Chuẩn hóa migration bằng wrangler d1 migrations apply
Cập nhật package.json scripts mới:
Khác biệt là gì?
migrations apply là flow có tracking migration, biết file nào đã apply rồi nó phù hợp để hướng dẫn ngay từ đầu, vì người đọc sẽ đi theo đúng quy trình schema evolution của D1 execute --file chỉ nên giữ cho seed hoặc các tác vụ SQL chủ đích, không nên dùng để dạy migration chuẩn Wrangler cũng được cấu hình thêm trong wrangler.toml:
Nghĩa là D1 sẽ dùng bảng d1_migrations để ghi nhận migration nào đã được chạy.
Quy trình db migration chuẩn
1. chạy npm run db:generate để để sinh ra migration file
2. viết migration schma SQL
3. dùng wrangler d1 migrations apply để apply migration
4. tạo seed file
Cấu hình để 2 file ở đâu?
migration để ở migrations/ 5. seed dữ liệu mẫu sau khi schema đã sẵn sàng
Cách khác quản lý db client: Controller tạo db, rồi truyền db vào query
Controller:
Cách này hợp nếu bạn muốn:
tách rõ “khởi tạo db” và “chạy query” dễ reuse nhiều query trong cùng một request dễ test hơn vì query function không phụ thuộc trực tiếp vào binding Mình khuyên dùng cách 2 nếu project sẽ lớn dần. Nó rõ trách nhiệm hơn:
controller lấy dependency từ runtime query function chỉ làm query Nếu project vẫn rất nhỏ và bạn ưu tiên ít code, cách 1 vẫn ổn.
Nếu muốn, mình có thể sửa luôn bài viết để dùng cách 2, vì nó cũng dễ giải thích hơn khi so sánh với Express.
Vậy toàn bộ flow cấu hình Drizzle cho project này là gì?
Nếu bạn muốn dựng lại theo đúng tinh thần commit 4 và 5, thứ tự nên là:
1. Cài package
2. Tạo D1 database
Sau đó copy database_id thật vào wrangler.toml.
3. Khai báo binding trong wrangler.toml
4. Generate Worker types
5. Tạo app binding type
6. Tạo schema
7. Tạo Drizzle config
8. Tạo DB client
9. Tạo query method
10. Generate và apply migration
11. Seed dữ liệu
12. Gọi query từ controller
Đây là flow tối thiểu nhưng đã khá sạch và bền để đi tiếp.
Nếu muốn viết package.json theo đúng chuẩn ngay từ đầu, phần scripts nên ở dạng này:
Những khác biệt quan trọng với Express cần luôn nhớ
Đây là phần mình nghĩ đáng nhớ nhất nếu bạn đang chuyển từ Express sang Hono Worker:
Express thường tự tạo database connection từ env vars; Hono Worker nhận database binding từ c.env. Express hay import db như một singleton cấp ứng dụng; Hono Worker trong repo này tạo Drizzle client từ binding ngay lúc cần dùng. Express hay dùng model class hoặc service layer dày hơn; repo này dùng schema + query function, mỏng và dễ đọc hơn. Express migration thường do Prisma, Knex, Sequelize CLI, TypeORM quản lý; Hono + D1 ở đây dùng drizzle-kit để generate và wrangler d1 migrations apply để track/apply. Express hay dựa nhiều vào process.env; Hono trên Workers cần type hóa Bindings để TypeScript hiểu c.env.DB. Đừng nhét query SQL trực tiếp vào controller nếu muốn code dễ mở rộng; cứ giữ nó trong queries/. Nếu app lớn hơn, bạn có thể thêm write methods như createSitemapEntry, updateSitemapEntry, nhưng vẫn nên giữ schema, client và query method tách biệt. Nếu ghi nhớ 5 điểm này, bạn sẽ đỡ bị “mang nguyên tư duy Express” sang Workers rồi thấy mọi thứ hơi lệch.
Kết luận
Phiên bản mới youhono thực ra làm một việc rất rõ ràng: biến một Hono Worker đang đọc JSON thành một app có database thực sự, có schema bằng TypeScript, có query typed, có migration, có seed, và có quy trình local/remote sạch hơn.
Nếu so với Express, khái niệm cốt lõi không thay đổi nhiều. Bạn vẫn có ORM, schema, method và migration. Thứ thay đổi là cách runtime cung cấp database cho app, và cách Hono type hóa binding qua c.env.
Đó là nền tảng rất tốt để đi tiếp sang các bước sau như viết method ghi dữ liệu, thêm validation, thêm auth, hoặc mở rộng schema sang nhiều bảng hơn. Nếu trước đây bạn quen Express, hãy xem đây là cùng một bài toán ORM, nhưng được giải theo phong cách Cloudflare Workers: gọn hơn, sát runtime hơn, và dựa nhiều hơn vào bindings thay vì connection server truyền thống.