Vertical Slice Architecture trong Thực Chiến
Từ Layered Architecture đến Vertical Slice — cách tôi tổ chức codebase trong dự án Cloudflare-native với Hono, React và Turborepo. Technical deep-dive kèm code thực tế.
Nghe bài viết này dưới dạng podcastVertical Slice Architecture trong Thực Chiến

Khi tôi bắt đầu dự án edu-ai-platform — một nền tảng học ngôn ngữ Cloudflare-native với Hono + React + Vite — câu hỏi đầu tiên không phải là "dùng framework nào?" mà là: tổ chức code như thế nào để không chết khi codebase lớn?
Sau nhiều năm làm việc với NestJS layered architecture trong dự án banking tại MSB, tôi quyết định thử một hướng khác: Vertical Slice Architecture (VSA). Bài viết này là ghi chép thực chiến — không lý thuyết suông.
01 — Vấn đề với Layer Architecture
Layered architecture (hay N-tier) là mô hình quen thuộc: Controller → Service → Repository → Database. Nó hoạt động tốt ở quy mô nhỏ, nhưng khi project lớn lên, bạn sẽ gặp phải Shotgun Surgery.
Shotgun Surgery: Chỉ cần thêm một field vào feature "Create Vocabulary", bạn phải sửa:
VocabController,VocabService,VocabRepository,VocabDTO, migration file — rải rác khắp các folder layer.
So sánh nhanh
| Layered | Vertical Slice | |
|---|---|---|
| Thêm field | Sửa N files ở N layers | Sửa trong 1 folder |
| Service | Dễ thành God Object | Handler nhỏ, single responsibility |
| Unit test | Khó: mock quá nhiều | Dễ: tất cả logic trong 1 slice |
| Coupling | Cao giữa modules | Loose qua Shared Kernel |
02 — Vertical Slice Architecture là gì?
VSA được popularize bởi Jimmy Bogard (tác giả AutoMapper, MediatR). Ý tưởng cốt lõi: thay vì cắt ngang theo layers, hãy cắt dọc theo features.
Mỗi "slice" là một feature hoàn chỉnh, chứa tất cả code cần thiết để thực hiện một use case — từ HTTP handler, validation, business logic, đến data access — trong cùng một folder.
03 — Cấu trúc thư mục thực tế

Dưới đây là cấu trúc tôi đang dùng trong edu-ai-platform — Cloudflare Workers với Hono, Turborepo monorepo:
apps/api/src/
├── features/ # Mỗi feature = 1 vertical slice
│ ├── vocabulary/
│ │ ├── create-vocab/
│ │ │ ├── handler.ts # HTTP handler (Hono route)
│ │ │ ├── schema.ts # Zod validation schema
│ │ │ ├── query.ts # D1 database query
│ │ │ ├── types.ts # Input/Output types
│ │ │ └── index.ts # Public API của slice
│ │ ├── list-vocab/
│ │ └── router.ts # Gom các slice vào 1 router
│ ├── review-session/
│ │ ├── start-session/
│ │ ├── submit-answer/
│ │ └── router.ts
│ └── quiz-game/
│
├── shared/ # Shared Kernel - chỉ những gì thực sự chung
│ ├── db.ts # D1 client factory
│ ├── auth.ts # Auth middleware
│ ├── errors.ts # AppError, HttpError
│ └── types.ts # Env, Bindings, etc.
│
└── index.ts # App entry — mount routers
💡 Nguyên tắc vàng: Nếu chỉ có 1 feature dùng một piece of code → để trong feature folder đó. Chỉ move vào
shared/khi ≥ 2 features dùng, và sau khi bạn thực sự thấy sự trùng lặp — đừng abstract sớm.
04 — Một Slice hoàn chỉnh: Create Vocabulary
Hãy xem một slice thực tế từ đầu đến cuối.
schema.ts — Validation với Zod
// features/vocabulary/create-vocab/schema.ts
import { z } from 'zod'
export const CreateVocabSchema = z.object({
word: z.string().min(1).max(100),
meaning: z.string().min(1).max(500),
example: z.string().optional(),
tags: z.array(z.string()).default([]),
difficulty: z.enum(['easy', 'medium', 'hard']).default('medium'),
})
export type CreateVocabInput = z.infer<typeof CreateVocabSchema>
query.ts — Data Access với Cloudflare D1
// features/vocabulary/create-vocab/query.ts
import type { D1Database } from '@cloudflare/workers-types'
import type { CreateVocabInput } from './schema'
export async function insertVocab(
db: D1Database,
userId: string,
input: CreateVocabInput
) {
const id = crypto.randomUUID()
const result = await db
.prepare(`
INSERT INTO vocabularies (id, user_id, word, meaning, example, tags, difficulty)
VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING *
`)
.bind(id, userId, input.word, input.meaning,
input.example ?? null,
JSON.stringify(input.tags),
input.difficulty)
.first()
return result
}
handler.ts — HTTP Handler với Hono
// features/vocabulary/create-vocab/handler.ts
import { zValidator } from '@hono/zod-validator'
import { Hono } from 'hono'
import type { AppEnv } from '../../../shared/types'
import { CreateVocabSchema } from './schema'
import { insertVocab } from './query'
const app = new Hono<AppEnv>()
app.post(
'/',
zValidator('json', CreateVocabSchema),
async (c) => {
const input = c.req.valid('json')
const userId = c.get('userId') // từ auth middleware
const vocab = await insertVocab(c.env.DB, userId, input)
return c.json({ data: vocab }, 201)
}
)
export default app
Nhận xét: Handler này cực kỳ thin — không có business logic phức tạp. Nó chỉ: validate → call query → return response. Mọi thứ liên quan đến feature này đều nằm trong 1 folder. Muốn xóa feature? Xóa folder là xong.
05 — Shared Kernel: Quy tắc "Actually Shared"
Sai lầm phổ biến nhất khi làm VSA: overuse shared/. Shared Kernel chỉ nên chứa những gì thực sự dùng ở mọi nơi:
// shared/types.ts
// Infrastructure bindings — thực sự dùng ở mọi nơi
export type AppEnv = {
Bindings: {
DB: D1Database // Cloudflare D1
KV: KVNamespace // Cloudflare KV
R2: R2Bucket // Cloudflare R2
AI: Ai // Workers AI
GROQ_API_KEY: string
}
Variables: {
userId: string // Set bởi auth middleware
userEmail: string
}
}
// Error types
export class AppError extends Error {
constructor(
public message: string,
public statusCode: number = 400,
public code?: string
) {
super(message)
}
}
06 — Tích hợp với Turborepo Monorepo
VSA kết hợp cực tốt với Turborepo. Mỗi app có VSA bên trong, còn truly shared infrastructure được đẩy lên thành internal packages:
edu-ai-platform/
├── apps/
│ ├── api/ # Hono — Cloudflare Workers
│ │ └── src/features/ # ← VSA lives here
│ └── web/ # React + Vite — Cloudflare Pages
│ └── src/features/ # ← VSA on frontend too!
│
├── packages/
│ ├── db-schema/ # Drizzle schema + migrations
│ ├── api-contract/ # Shared types giữa api và web
│ └── ui/ # Shared React components
│
├── turbo.json
└── package.json # pnpm workspaces
Package
api-contractlà điểm "giao tiếp" duy nhất giữaapi/vàweb/. Nó chứa Zod schemas và TypeScript types được generate từ backend. Frontend consume trực tiếp — không cần OpenAPI, không cần code-gen phức tạp.
07 — Khi nào NÊN và KHÔNG NÊN dùng VSA
✅ Nên dùng VSA khi:
- Domain phức tạp, nhiều use cases rõ ràng
- Team ≥ 2 người, mỗi người own 1 feature
- Cần iterate nhanh từng feature
- Microservices / Serverless (Cloudflare Workers)
- Dự án dài hạn, codebase sẽ grow
❌ Không phù hợp khi:
- CRUD đơn giản, ít business logic
- Team 1 người, thời gian ngắn
- Prototype / MVP cần ship nhanh
- Features có quá nhiều cross-cutting concerns
08 — Kết luận
Vertical Slice Architecture không phải silver bullet. Nhưng trong dự án edu-ai-platform với stack Cloudflare-native (Hono + D1 + R2 + KV), VSA đã chứng minh giá trị rõ ràng:
- Organize by feature, not by layer — đây không phải lý thuyết, là thực tế
- Shared Kernel phải thực sự nhỏ — resist the urge to abstract sớm
- VSA + Turborepo = combo mạnh cho Cloudflare-native stack
- Mỗi Handler nên thin — business logic ở query hoặc domain service
- Xóa feature = xóa folder — đó mới là true modularity
Nếu bạn đang làm một dự án với nhiều use cases rõ ràng và muốn code dễ maintain lâu dài, hãy thử VSA. Bắt đầu nhỏ: chọn 1 feature hiện tại và refactor nó thành 1 slice. Bạn sẽ thấy sự khác biệt ngay lập tức.
Bài viết tiếp theo: Cloudflare D1 + Drizzle ORM — từ Schema đến Production Migration
Chưa có bình luận
Để lại bình luận
Bình luận sẽ được phê duyệt trước khi hiển thị.