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 podcast

Vertical Slice Architecture trong Thực Chiến

VSA vs Layered Architecture — mỗi "lát" dọc là một feature hoàn chỉnh

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ế

Cấu trúc thư mục VSA trong edu-ai-platform

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-contract là điểm "giao tiếp" duy nhất giữa api/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ị.

Nhận bài viết mới

Mình sẽ gửi email khi có bài mới. Không spam.