Back to blog

Kiến Trúc Monolithic: Khi Nào Và Cách Xây Dựng Đúng Cách

software-architecturemonolithbackendsystem-designdesign-patterns
Kiến Trúc Monolithic: Khi Nào Và Cách Xây Dựng Đúng Cách

Kiến trúc monolithic là một trong những khái niệm bị hiểu nhầm nhiều nhất trong phát triển phần mềm. Các developer thường coi "monolith" như một từ xấu — đồng nghĩa với code cũ, nợ kỹ thuật, và ác mộng về khả năng mở rộng. Nhưng điều đó sai.

Một monolith có cấu trúc tốt là lựa chọn kiến trúc hoàn toàn hợp lệ — thậm chí thường là lựa chọn tốt nhất — đặc biệt trong giai đoạn đầu của một sản phẩm. Các công ty như Stack Overflow, Shopify, Basecamp và GitHub đã chạy với monolith trong nhiều năm và mở rộng đến hàng triệu người dùng.

Trong bài viết này, chúng ta sẽ đề cập:

✅ Monolith thực sự là gì (và không phải là gì)
✅ Các loại kiến trúc monolithic khác nhau
✅ Khi nào monolith là lựa chọn đúng đắn
✅ Cách cấu trúc modular monolith được tổ chức tốt
✅ Chiến lược mở rộng cho monolith
✅ Các anti-pattern phổ biến cần tránh
✅ Chiến lược monolith-first
✅ Khi nào nên xem xét tách ra


Monolith Là Gì?

Một monolith là ứng dụng mà tất cả chức năng được đóng gói và triển khai như một đơn vị duy nhất. Toàn bộ codebase — giao diện người dùng, logic nghiệp vụ, và tầng truy cập dữ liệu — nằm cùng nhau và được triển khai cùng nhau.

Khi người dùng thực hiện yêu cầu, monolith xử lý tất cả: xác thực, logic nghiệp vụ, truy cập dữ liệu, và phản hồi — tất cả trong cùng một process.

Monolith Không Phải Là

Monolith không nhất thiết là:

  • ❌ Không có cấu trúc — Monolith có thể được tổ chức rất đẹp với các module rõ ràng và ranh giới phân tách
  • ❌ Không mở rộng được — Monolith có thể mở rộng ngang sau load balancer
  • ❌ Di sản — Hệ thống mới bắt đầu như monolith mỗi ngày
  • ❌ Tệ hại — Công cụ phù hợp cho nhiều tình huống

Vấn đề không phải ở monolith — mà là monolith có cấu trúc kém.


Các Loại Kiến Trúc Monolithic

Không phải tất cả monolith đều giống nhau. Có một phổ từ có cấu trúc tốt đến hỗn loạn:

1. Modular Monolith

Tiêu chuẩn vàng của kiến trúc monolithic. Code được tổ chức thành các module được định nghĩa rõ ràng với ranh giới rõ ràng. Các module giao tiếp thông qua interface rõ ràng, không phải bằng cách truy cập vào nội bộ của nhau.

src/
├── modules/
│   ├── users/
│   │   ├── user.entity.ts
│   │   ├── user.service.ts
│   │   ├── user.controller.ts
│   │   └── user.repository.ts
│   ├── orders/
│   │   ├── order.entity.ts
│   │   ├── order.service.ts
│   │   ├── order.controller.ts
│   │   └── order.repository.ts
│   └── products/
│       ├── product.entity.ts
│       ├── product.service.ts
│       ├── product.controller.ts
│       └── product.repository.ts
└── shared/
    ├── database/
    ├── middleware/
    └── config/

Các module giao tiếp thông qua service interface, không phải truy cập database trực tiếp qua ranh giới:

// ✅ Tốt: Module Orders gọi module Users thông qua service interface
class OrderService {
  constructor(private readonly userService: UserService) {}
 
  async createOrder(userId: string, items: OrderItem[]) {
    const user = await this.userService.findById(userId); // Thông qua interface
    // ... logic tạo order
  }
}
 
// ❌ Tệ: Module Orders trực tiếp query bảng Users
class OrderService {
  async createOrder(userId: string, items: OrderItem[]) {
    const user = await this.db.query('SELECT * FROM users WHERE id = ?', [userId]); // Bỏ qua ranh giới
    // ...
  }
}

2. Layered Monolith

Code được tổ chức theo tầng kỹ thuật thay vì theo tính năng. Đây là cấu trúc phổ biến nhất trong các framework như Spring MVC hoặc ASP.NET:

src/
├── controllers/       # Tầng HTTP
├── services/          # Tầng logic nghiệp vụ
├── repositories/      # Tầng truy cập dữ liệu
└── models/            # Model dữ liệu

Điều này hoạt động tốt cho các ứng dụng nhỏ hơn nhưng trở nên có vấn đề khi mở rộng — các tính năng không liên quan bị rối với nhau trong mỗi tầng.

3. Big Ball of Mud

Anti-pattern: không có cấu trúc, không có ranh giới, mọi thứ phụ thuộc vào mọi thứ. Là sự tiến hóa thường thấy từ một codebase từng nhỏ nhưng phát triển mà không có kỷ luật kiến trúc.

Đây là điều làm cho monolith có tiếng xấu. Và hoàn toàn có thể tránh được.


Khi Nào Monolith Là Lựa Chọn Đúng?

Câu trả lời phụ thuộc vào ngữ cảnh của bạn — quy mô team, độ phức tạp của domain, và yêu cầu mở rộng.

✅ Chọn Monolith Khi:

1. Bạn đang bắt đầu một dự án mới

Bạn chưa hiểu rõ domain của mình. Microservices yêu cầu ranh giới được định nghĩa rõ ràng, và việc tách service sớm thường dẫn đến các điểm cắt sai. Bắt đầu đơn giản, tìm hiểu domain, sau đó phát triển.

"Đừng bắt đầu với microservices. Bắt đầu với monolith và tách ra khi bạn có lý do rõ ràng." — Martin Fowler

2. Team của bạn nhỏ (< 10 developer)

Microservices giới thiệu overhead vận hành rất lớn: service discovery, distributed tracing, nhiều lần triển khai, lỗi giao tiếp giữa các service, network latency. Một team nhỏ sẽ dành nhiều thời gian cho infrastructure hơn là tính năng.

3. Domain của bạn chưa được hiểu rõ

Service cần interface ổn định, được định nghĩa rõ ràng. Nếu bcn vẫn đang tìm hiểu sản phẩm của mình làm gì, monolith cho phép bạn tự do refactor mà không cần phối hợp thay đổi cross-service.

4. Bạn cần phản hồi nhanh và lặp lại liên tục (fast iteration - feedback loop)

Monolith là một lần triển khai duy nhất. Một git push cập nhật mọi thứ. Không cần version hóa API, phối hợp phát hành, hay quản lý tương thích service.

5. Yêu cầu mở rộng của bạn vừa phải

Một monolith được tối ưu hóa tốt trên một server duy nhất có thể xử lý hàng nghìn yêu cầu đồng thời. Stack Overflow phục vụ hàng triệu yêu cầu mỗi ngày với một đội server nhỏ chạy monolith.

📊 Ma Trận Quyết Định

Yếu TốChọn MonolithChọn Microservices
Quy Mô Team< 10 developer> 20 developer
Độ Rõ Ràng DomainĐang phát triển / chưa rõRanh giới được định nghĩa rõ
Tần Suất Triển KhaiNhiều lần/ngàyTheo team-per-service
Yêu Cầu Mở Rộng< 100k người dùng/ngàyHàng triệu người dùng (theo tính năng)
Trưởng Thành OpsDevOps cơ bảnKubernetes, service mesh
Time-to-MarketNhanh là quan trọngChấp nhận độ phức tạp

Cấu Trúc Monolith Được Tổ Chức Tốt

Chìa khóa cho một monolith bền vững là thực thi ranh giới ngay cả khi không có sự tách biệt service vật lý.

Tổ Chức Module Theo Tính Năng

Tổ chức theo khả năng nghiệp vụ, không phải theo tầng kỹ thuật:

src/
├── modules/
│   ├── catalog/           # Bounded context danh mục sản phẩm
│   │   ├── application/   # Use cases / services
│   │   ├── domain/        # Entities, value objects, domain logic
│   │   ├── infrastructure/ # Repository implementations, external APIs
│   │   └── api/           # Controllers, DTOs
│   ├── orders/            # Bounded context quản lý đơn hàng
│   │   ├── application/
│   │   ├── domain/
│   │   ├── infrastructure/
│   │   └── api/
│   └── payments/          # Bounded context xử lý thanh toán
│       ├── application/
│       ├── domain/
│       ├── infrastructure/
│       └── api/
└── shared/
    ├── database.ts
    ├── logger.ts
    └── config.ts

Định Nghĩa Interface Module Rõ Ràng

Mỗi module nên expose một public API — một tập hợp service mà các module khác có thể sử dụng:

// modules/users/index.ts — Public interface cho module Users
export { UserService } from './application/user.service';
export type { User, CreateUserDto } from './domain/user.entity';
 
// Mọi thứ khác là nội bộ — không được export
// modules/orders/application/order.service.ts
import { UserService } from '@/modules/users'; // Chỉ import từ public interface
 
class OrderService {
  constructor(private readonly userService: UserService) {}
  // ...
}

Sử Dụng Dependency Injection Để Loose Coupling

Trong Spring Boot:

// Tách biệt rõ ràng: OrderService phụ thuộc vào interface UserService
@Service
public class OrderService {
    private final UserService userService;
    private final OrderRepository orderRepository;
 
    public OrderService(UserService userService, OrderRepository orderRepository) {
        this.userService = userService;
        this.orderRepository = orderRepository;
    }
 
    public Order createOrder(String userId, List<OrderItem> items) {
        User user = userService.findById(userId)
            .orElseThrow(() -> new UserNotFoundException(userId));
 
        Order order = Order.create(user, items);
        return orderRepository.save(order);
    }
}

Đảm Bảo Ranh Giới Bằng Architecture Tests

Sử dụng các công cụ như ArchUnit (Java) hoặc eslint-plugin-import (TypeScript) để fail build nếu các module vi phạm ranh giới:

// ArchUnit test: Module Orders không được import từ package nội bộ của Users
@Test
void ordersShouldNotDependOnUsersInternals() {
    JavaClasses importedClasses = new ClassFileImporter()
        .importPackages("com.example");
 
    ArchRule rule = noClasses()
        .that().resideInAPackage("..orders..")
        .should().dependOnClassesThat()
        .resideInAPackage("..users.infrastructure..");
 
    rule.check(importedClasses);
}

Chiến Lược Mở Rộng Cho Monolith

Một quan niệm sai lầm phổ biến: monolith không thể mở rộng. Chúng hoàn toàn có thể.

Mở Rộng Ngang (Scale Out)

Triển khai nhiều instance sau load balancer. Mỗi instance là stateless (session state trong Redis, database, hoặc JWT):

Điều này hoạt động hiệu quả cho hầu hết các ứng dụng và đơn giản hơn nhiều để vận hành so với microservices.

Mở Rộng Dọc (Scale Up)

Thêm CPU và RAM cho server. Các nhà cung cấp cloud hiện đại làm điều này cực kỳ dễ dàng. Đối với nhiều ứng dụng, đây là chiến lược mở rộng rẻ nhất và đơn giản nhất.

Read Replicas

Giảm tải các hoạt động đọc nhiều sang database read replica:

class ProductRepository {
  constructor(
    private readonly writeDb: DatabaseClient,
    private readonly readDb: DatabaseClient,  // Read replica
  ) {}
 
  async save(product: Product) {
    return this.writeDb.query('INSERT INTO products...', [...]);
  }
 
  async findAll() {
    return this.readDb.query('SELECT * FROM products');  // Đọc từ replica
  }
}

Caching

Thêm Redis caching cho các hoạt động tốn kém:

class ProductService {
  async findPopularProducts(): Promise<Product[]> {
    const cacheKey = 'products:popular';
 
    // Kiểm tra cache trước
    const cached = await this.redis.get(cacheKey);
    if (cached) return JSON.parse(cached);
 
    // Cache miss — query database
    const products = await this.productRepository.findPopular();
 
    // Lưu vào cache trong 10 phút
    await this.redis.setex(cacheKey, 600, JSON.stringify(products));
 
    return products;
  }
}

Background Jobs

Offload các hoạt động chậm sang background worker:

// Thay vì gửi email đồng bộ trong HTTP request
class OrderService {
  async createOrder(dto: CreateOrderDto) {
    const order = await this.orderRepository.save(dto);
 
    // Queue email thay vì gửi trực tiếp
    await this.jobQueue.add('send-order-confirmation', {
      orderId: order.id,
      userEmail: dto.email,
    });
 
    return order; // Trả về ngay lập tức, email được gửi async
  }
}

Các Anti-Pattern Phổ Biến Cần Tránh

1. God Class / God Service

Một class làm mọi thứ. Dấu hiệu: hàng nghìn dòng, hàng chục method, được import ở khắp nơi.

// ❌ Anti-pattern: UserService làm quá nhiều thứ
@Service
public class UserService {
    public User createUser(...) { ... }
    public void sendWelcomeEmail(...) { ... }
    public void processPayment(...) { ... }
    public void generateReport(...) { ... }
    public void handleReturns(...) { ... }
    public void updateInventory(...) { ... }
    // ... 50 method khác
}

Giải pháp: Tách theo bounded context. Mỗi service có một trách nhiệm rõ ràng, duy nhất.

2. Tight Coupling Thông Qua Bảng Database Dùng Chung

Nhiều module ghi trực tiếp vào cùng bảng, bỏ qua ranh giới service:

// ❌ Anti-pattern: OrderRepository trực tiếp query bảng Users
@Repository
public class OrderRepository {
    public List<Order> findOrdersWithUserEmail() {
        return entityManager.createQuery(
            "SELECT o FROM Order o JOIN User u ON o.userId = u.id"  // Cross-boundary JOIN
        ).getResultList();
    }
}

Giải pháp: Gọi UserService.findById() thay thế. Mỗi module sở hữu bảng của mình.

3. Shared Mutable State (Global Singletons)

State dùng chung mà nhiều module đọc và ghi mà không có sự phối hợp dẫn đến race condition và hành vi không thể đoán trước.

// ❌ Anti-pattern: Global mutable state
let currentDiscount = 0;  // Race condition nếu nhiều request thay đổi điều này
 
export function applyDiscount(amount: number) {
  currentDiscount = amount;  // Dùng chung cho tất cả request
}

Giải pháp: Sử dụng database hoặc Redis cho state dùng chung với locking/transaction thích hợp.

4. Circular Dependencies

Module A phụ thuộc vào Module B mà lại phụ thuộc vào Module A. Đây là tín hiệu thiết kế ranh giới kém.

Giải pháp: Giới thiệu một interface hoặc trích xuất một khái niệm dùng chung vào module thứ ba.

5. Bỏ Qua Test Vì "Chỉ Là Monolith"

Monolith cần test không kém gì microservices — có thể nhiều hơn, vì một bug trong tiện ích dùng chung có thể phá vỡ mọi thứ cùng một lúc.


Chiến Lược Monolith-First

Martin Fowler đã phổ biến chiến lược monolith-first: xây dựng hệ thống của bạn như một modular monolith trước, sau đó tách service chỉ khi bạn có lý do rõ ràng.

Lý luận:

  1. Bạn chưa biết ranh giới domain — Ranh giới service đòi hỏi kiến thức domain sâu. Có được kiến thức đó bằng cách xây dựng monolith.
  2. Microservices tốn kém — Hệ thống phân tán có vấn đề phân tán (network failure, latency, lỗi từng phần). Đừng trả giá đó trước khi cần.
  3. Bạn có thể tách sau — Modular monolith được cấu trúc tốt được thiết kế để tách. Ranh giới module sạch trở thành ranh giới service.

Khi Nào Nên Tách Một Service

Xem xét tách một module thành service riêng khi bạn có lý do cụ thể, rõ ràng:

  • Mở rộng độc lập — Một tính năng cần gấp 10 lần tài nguyên tính toán so với các tính năng khác
  • Không tương thích công nghệ — Một tính năng cần GPU, runtime khác, hoặc ngôn ngữ khác
  • Tự chủ team — Một team cần triển khai độc lập mà không phối hợp với người khác
  • Cô lập lỗi — Một tính năng bị lỗi không nên kéo down toàn bộ hệ thống
  • SLA khác nhau — Một tính năng cần uptime 99.99% trong khi các tính năng khác chịu được downtime

Không phải vì:

  • "Microservices thì hiện đại"
  • "Netflix dùng microservices"
  • "Monolith không mở rộng được" (chúng có thể)

Câu Chuyện Thành Công Với Monolith Thực Tế

Stack Overflow

Stack Overflow phục vụ hàng triệu page view mỗi ngày từ một số lượng nhỏ server chạy .NET monolith. Kiến trúc của họ được cố ý giữ đơn giản:

  • 9 web server
  • 4 SQL server
  • Redis để caching
  • Một monolith có thể triển khai

Họ đã chọn sự đơn giản thay vì độ phức tạp phân tán — và nó hoạt động tuyệt vời.

Shopify

Shopify chạy như một Rails monolith trong nhiều năm khi mở rộng đến hàng tỷ giao dịch. Họ chỉ mới gần đây bắt đầu module hóa — và ngay cả khi đó, vào modular monolith với ranh giới được đảm bảo và duy trì nghiêm ngặt, không phải microservices.

"Chúng tôi có modular monolith — một đơn vị có thể triển khai duy nhất với ranh giới module nghiêm ngặt." — Shopify Engineering

Basecamp / HEY

DHH và team Basecamp là người ủng hộ vocal cho monolith. Các ứng dụng của họ (Basecamp, HEY) chạy như Rails monolith trên phần cứng khiêm tốn và phục vụ hàng trăm nghìn khách hàng trả tiền.


Monolith vs Microservices: Đánh Đổi Thực Tế

Khía CạnhMonolithMicroservices
Triển KhaiMột artifactNhiều service cần phối hợp
Dev Cục BộMột process để chạyDocker Compose với 10+ service
DebugStack trace đầy đủCần distributed tracing
TestingIntegration test dễ dàngĐộ phức tạp contract testing
RefactoringDễ dàng (cùng codebase)Cần API versioning
Mở RộngMở rộng toàn bộ appMở rộng từng service riêng lẻ
Tự Chủ TeamPhối hợp phát hànhTriển khai độc lập
Cô Lập LỗiMột bug có thể phá vỡ mọi thứService lỗi độc lập
LatencyGọi trong process (nanosecond)Gọi qua network (millisecond)
Overhead OpsThấpCao (k8s, service mesh, tracing)
Ranh Giới DomainTùy chọn (nhưng nên có)Bắt buộc (nếu không sẽ hỗn loạn)

Khi Nào Nên Tách Monolith

Monolith đã phục vụ tốt bạn. Nhưng cuối cùng, các điểm đau cụ thể có thể biện minh cho việc tách một service. Đây là các tín hiệu:

Tín Hiệu 1: Một Tính Năng Là Nút Thắt Cổ Chai

Video transcoding của bạn tốn nhiều CPU và làm chậm phản hồi API. Tách nó thành worker service có thể mở rộng độc lập.

Tín Hiệu 2: Yêu Cầu Công Nghệ Khác Nhau

Service ML inference của bạn cần Python và GPU. Ứng dụng chính của bạn là Java. Tách service ML.

Tín Hiệu 3: Xung Đột Team

Nhiều team triển khai vào cùng codebase dẫn đến merge conflict, phối hợp triển khai, và nút thắt cổ chai. Ranh giới service cho phép tự chủ team.

Tín Hiệu 4: Ranh Giới Domain Rõ Ràng

Sau khi chạy monolith một năm, bạn hiểu chính xác "payments" kết thúc và "orders" bắt đầu ở đâu. Ranh giới ổn định. Bây giờ bạn có thể tách.

Cách Tách: Strangler Fig Pattern

Đừng viết lại. Bóp nghẹt monolith dần dần:

  1. Xây dựng service mới cạnh monolith
  2. Route traffic đến service mới (qua API gateway hoặc feature flag)
  3. Xác minh nó hoạt động
  4. Xóa code khỏi monolith

Phương pháp này có rủi ro thấp và có thể đảo ngược (rollback).


Ví Dụ Thực Tế: E-Commerce Monolith Trong Spring Boot

Đây là cách một Spring Boot monolith có cấu trúc tốt trông như thế nào cho ứng dụng e-commerce:

src/main/java/com/example/store/
├── modules/
│   ├── catalog/
│   │   ├── CatalogModule.java           # Quản lý ranh giới module (module boundary)
│   │   ├── application/
│   │   │   └── ProductService.java
│   │   ├── domain/
│   │   │   ├── Product.java
│   │   │   └── ProductRepository.java   # Interface
│   │   └── infrastructure/
│   │       └── JpaProductRepository.java # Implementation
│   ├── orders/
│   │   ├── OrdersModule.java
│   │   ├── application/
│   │   │   └── OrderService.java
│   │   ├── domain/
│   │   │   ├── Order.java
│   │   │   └── OrderRepository.java
│   │   └── infrastructure/
│   │       └── JpaOrderRepository.java
│   └── payments/
│       ├── PaymentsModule.java
│       ├── application/
│       │   └── PaymentService.java
│       ├── domain/
│       │   └── Payment.java
│       └── infrastructure/
│           └── StripePaymentGateway.java # External adapter
├── shared/
│   ├── DatabaseConfig.java
│   ├── SecurityConfig.java
│   └── EventBus.java                    # In-process event bus
└── StoreApplication.java

Giao tiếp cross-module qua in-process event bus (không phải HTTP):

// Khi order được tạo, publish event
@Service
public class OrderService {
    private final EventBus eventBus;
 
    public Order createOrder(CreateOrderCommand command) {
        Order order = Order.create(command);
        orderRepository.save(order);
 
        // Publish event — module Payments lắng nghe mà không cần tight coupling
        eventBus.publish(new OrderCreatedEvent(order.getId(), order.getTotalAmount()));
 
        return order;
    }
}
 
// Module Payments xử lý event độc lập
@Component
public class OrderCreatedEventHandler {
    @EventListener
    public void handle(OrderCreatedEvent event) {
        paymentService.initiatePayment(event.getOrderId(), event.getAmount());
    }
}

Phương pháp này cho bạn loose coupling trong monolith — sự loose coupling tương tự như với microservices, nhưng không có network overhead.


Tóm Tắt

Kiến trúc monolithic không phải là vấn đề cần giải quyết — đó là một công cụ phù hợp hoàn hảo với nhiều tình huống:

Khi monolith thắng:

  • Team nhỏ xây dựng sản phẩm mới
  • Ranh giới domain không rõ ràng hoặc đang phát triển
  • Cần lặp lại nhanh và triển khai đơn giản
  • Yêu cầu mở rộng không biện minh cho độ phức tạp phân tán

Cách làm đúng:

  • Tổ chức theo khả năng nghiệp vụ (modular monolith)
  • Thực thi ranh giới module thông qua interface và architecture test
  • Sử dụng dependency injection để loose coupling
  • Thêm caching, read replica, và background job khi mở rộng

Sai lầm phổ biến cần tránh:

  • God class và service
  • Cross-module database coupling
  • Shared mutable state
  • Không có test

Khi nào nên phát triển:

  • Nút thắt cổ chai mở rộng cụ thể cho một tính năng
  • Ranh giới domain rõ ràng, ổn định mà bạn muốn triển khai độc lập
  • Quy mô team tạo ra ma sát phối hợp triển khai

Mục tiêu không bao giờ là "có microservices" — mục tiêu là cung cấp phần mềm hiệu quả. Thường xuyên, một monolith có cấu trúc tốt làm điều đó tốt hơn một hệ thống phân tán bị tách sớm.


Tiếp Theo Trong Series Software Architecture

Đây là bài viết thứ 2 trong 12 bài của series Software Architecture Patterns:

Bài viết liên quan:

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.