Abstract banners labeled Innovation Zone, Design Zone, and Development Zone with geometric shapes and digital elements

DDD Không Phải Là Pattern — Đây Là Lý Do 80% Team Áp Dụng Sai Domain-Driven Design

Senior developer bước vào buổi họp kiến trúc với sự tự tin: “Chúng ta sẽ áp dụng DDD. Entity, Repository, Service — tôi đã đọc hết sách Evans rồi.”

Ba tháng sau, team vẫn bế tắc ở câu hỏi đơn giản: Logic nghiệp vụ “đơn hàng trên 5 triệu cần duyệt thủ công” — đặt ở Service hay Entity?

Đây không phải vấn đề kỹ thuật. Đây là dấu hiệu team đang dùng DDD như một bộ pattern, thay vì như một tư duy thiết kế. Và đây chính là lý do 80% team áp dụng DDD sai từ bước đầu tiên.

80% Team Dùng DDD Sai Ngay Từ Bước Đầu

Khi mới tiếp cận Domain-Driven Design, hầu hết developer đều trải qua giai đoạn này: đọc xong chapter về tactical patterns, ngay lập tức bắt đầu tạo class OrderEntity, UserRepository, PaymentService.

Kết quả? Codebase có đủ “DDD vocabulary” nhưng business logic vẫn nằm lung tung — một nửa trong Service, một nửa trong Controller, thậm chí trong Repository.

Triệu chứng phổ biến của DDD sai cách:

  • OrderService dài 500 dòng với hàng chục if/else check business rules
  • Order entity chỉ chứa getter/setter, không có bất kỳ behavior nào
  • Repository làm cả validation lẫn data transformation
  • Developer không phân biệt được “Application concern” hay “Domain concern”

Đây là Anemic Domain Model — anti-pattern được Martin Fowler mô tả từ năm 2003, và vẫn còn phổ biến đến ngày nay.

Giải Pháp Hiển Nhiên: Áp Dụng DDD Pattern

Khi nhận ra vấn đề, phản ứng tự nhiên của nhiều team là: thêm pattern. Tạo đúng class name, thêm annotation, tái cơ cấu package structure.

// Anemic Domain Model — business logic NGOÀI entity
public class Order {
    private Long id;
    private List<OrderItem> items;
    private OrderStatus status;
    private BigDecimal totalAmount;
}

public class OrderService {
    public void approveOrder(Long orderId) {
        Order order = orderRepository.findById(orderId);
        if (order.getTotalAmount().compareTo(new BigDecimal("5000000")) > 0) {
            order.setStatus(OrderStatus.PENDING_APPROVAL);
        } else {
            order.setStatus(OrderStatus.APPROVED);
        }
        orderRepository.save(order);
    }
}

Order là một data container. Mọi business rule đều sống trong Service. Khi requirement thay đổi, bạn phải tìm kiếm và sửa rải rác trong cả codebase.

Vấn Đề Thực Sự: Domain-Driven Design Là Tư Duy, Không Phải Pattern Set

Eric Evans trong cuốn sách gốc dành 80% nội dung cho Strategic DDD — Bounded Context, Ubiquitous Language, Context Mapping. Chỉ 20% cho Tactical patterns. Nhưng hầu hết tutorial và bootcamp làm ngược lại hoàn toàn.

Strategic DDD = Tư duy phân vùng hệ thống:

  • Bounded Context: ranh giới ngữ nghĩa — “Order” trong Billing Context khác “Order” trong Shipping Context
  • Ubiquitous Language: developer và domain expert dùng cùng một từ cho cùng một khái niệm
  • Context Mapping: các Bounded Context giao tiếp với nhau như thế nào

Tactical DDD = Công cụ implement bên trong một Bounded Context: Entity, Value Object, Aggregate, Repository, Domain Service, Domain Event.

Thứ tự đúng: Hiểu domain → vẽ Bounded Context → xác định Ubiquitous Language → sau đó mới dùng tactical patterns. Bỏ qua Strategic DDD, tactical patterns chỉ là “fancy naming” cho code thường.

Rich Domain Model: Business Rules Sống Trong Domain Object

Refactor lại ví dụ trên với Rich Domain Model — nơi business rules thuộc về nơi chúng có nghĩa nhất:

// Rich Domain Model — business logic TRONG entity
public class Order {
    private OrderId id;
    private List<OrderItem> items;
    private OrderStatus status;
    private Money totalAmount;
    private static final Money APPROVAL_THRESHOLD = Money.of(5_000_000, "VND");

    // Business rule nằm trong Order — Tell, Don't Ask
    public void requestApproval() {
        if (this.status != OrderStatus.DRAFT) {
            throw new DomainException("Chỉ có thể request approval cho đơn ở trạng thái DRAFT");
        }
        if (this.totalAmount.isGreaterThan(APPROVAL_THRESHOLD)) {
            this.status = OrderStatus.PENDING_APPROVAL;
        } else {
            this.status = OrderStatus.APPROVED;
        }
    }

    public void approve(ApproverId approverId) {
        if (this.status != OrderStatus.PENDING_APPROVAL) {
            throw new DomainException("Không thể approve đơn không ở trạng thái PENDING_APPROVAL");
        }
        this.status = OrderStatus.APPROVED;
        DomainEvents.raise(new OrderApprovedEvent(this.id, approverId));
    }
}
// Application Service trở nên mỏng — chỉ orchestrate
public class OrderApplicationService {
    public void requestApproval(OrderId orderId) {
        Order order = orderRepository.findById(orderId);
        order.requestApproval(); // domain tự quyết định
        orderRepository.save(order);
    }
}

Nguyên tắc này gọi là Tell, Don’t Ask: thay vì hỏi object về state rồi quyết định bên ngoài, hãy nói cho object biết nó cần làm gì. Khi requirement thay đổi, bạn chỉ sửa Order.requestApproval() — không cần tìm kiếm trong Service hay Controller.

Building Blocks: Entity, Value Object, Aggregate, Repository

Tactical patterns không phải là đích đến — chúng là công cụ để model domain một cách chính xác.

Entity — Identity quan trọng hơn giá trị:

// Hai Customer khác nhau dù cùng tên — vì họ có CustomerId khác nhau
Customer c1 = customerRepo.findById(new CustomerId("C001"));
Customer c2 = customerRepo.findById(new CustomerId("C002"));
// c1.equals(c2) == false — dù mọi field khác đều giống nhau

Value Object — Giá trị quan trọng hơn identity, luôn immutable:

public final class Money {
    private final BigDecimal amount;
    private final String currency;

    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new DomainException("Không thể cộng hai tiền tệ khác nhau");
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }

    public boolean isGreaterThan(Money other) {
        return this.amount.compareTo(other.amount) > 0;
    }
}

Aggregate — Ranh giới nhất quán (consistency boundary):

// WRONG: Aggregate quá lớn — lock contention, performance issues
public class Order {
    private Customer customer;   // có lifecycle riêng
    private Shipment shipment;   // có lifecycle riêng
}

// RIGHT: Aggregate vừa đủ — chỉ giữ những gì PHẢI nhất quán cùng nhau
public class Order {
    private OrderId id;                // Aggregate Root
    private List<OrderItem> items;     // luôn cần nhất quán với Order
    private CustomerId customerId;     // chỉ reference by ID

    public void addItem(ProductId productId, Money price, int quantity) {
        if (items.size() >= 50) {
            throw new DomainException("Đơn hàng không được quá 50 sản phẩm");
        }
        items.add(new OrderItem(productId, price, quantity));
    }
}

Repository — Persistence Ignorance:

// Interface sống trong Domain layer — thuần business language
public interface OrderRepository {
    Order findById(OrderId id);
    void save(Order order);
    List<Order> findPendingApproval();
}
// Implementation sống trong Infrastructure layer — Domain không biết và không cần biết

Domain Service vs Application Service: Ranh Giới Quan Trọng

Đây là điểm gây nhầm lẫn nhất khi học DDD — hai loại Service với trách nhiệm hoàn toàn khác nhau.

// Domain Service — business logic không thuộc về entity cụ thể nào
public class ShippingCostCalculator {
    public Money calculate(Order order, Address destination) {
        if (order.getTotalAmount().isGreaterThan(Money.of(2_000_000, "VND"))
                && destination.isInCity()) {
            return Money.ZERO;
        }
        return baseRate.multiply(destination.getDistanceKm());
    }
}

// Application Service — orchestrate use case, không chứa business logic
public class CheckoutApplicationService {
    public void checkout(CheckoutCommand command) {
        Order order = orderRepository.findById(command.getOrderId());
        Address destination = addressRepository.findById(command.getAddressId());
        Money shippingCost = shippingCostCalculator.calculate(order, destination);
        order.checkout(destination, shippingCost);
        orderRepository.save(order);
    }
}

Rule of thumb: Nếu một Service method chứa if/else liên quan đến business rules, nó thuộc về Domain Service hoặc Entity — không phải Application Service.

Khi Nào KHÔNG Nên Dùng Domain-Driven Design

DDD không phải silver bullet. Cost thực sự là learning curve dốc, cần domain expert collaborate liên tục, và upfront design investment 10–20% thời gian dự án.

  • CRUD đơn giản: Admin panel, báo cáo — logic gần như không có, Spring CRUD là đủ
  • Requirements chưa ổn định: Nếu tuần nào cũng thay đổi core concept, tactical patterns sẽ liên tục phải refactor
  • Team nhỏ, timeline ngắn: 3 developer, 2 tháng ra MVP — overhead của DDD sẽ giết chết timeline
  • Không có domain expert: Cần người hiểu nghiệp vụ thực sự để xây Ubiquitous Language

Dấu hiệu DDD sẽ mang lại value: domain phức tạp với nhiều business rules, nhiều stakeholder dùng ngôn ngữ khác nhau cho cùng một khái niệm, team từ trung bình trở lên và sản phẩm có roadmap dài hạn.

DDD Thực Dụng: Không Phải All-or-Nothing

Thực tế của các team áp dụng DDD thành công là partial boundaries — DDD chỉ cho Core Domain, không phải toàn bộ hệ thống.

  • Core Domain: logic cốt lõi tạo ra competitive advantage — đây là nơi invest DDD đầy đủ
  • Supporting Domain: cần thiết nhưng không đặc biệt — dùng simple design hoặc outsource
  • Generic Domain: common functionality — dùng off-the-shelf solution (Stripe, SendGrid…)

Lộ trình thực tế: EventStorming (tuần 1–2) → xác định Bounded Context và Core Domain (tuần 3) → xây Ubiquitous Language (tuần 4) → implement Core Domain với tactical patterns. Theo DDD Reference của Eric Evans, đầu tư 10–20% thời gian cho design upfront thường tiết kiệm 3–5x ở giai đoạn maintenance.

Kết Luận: Lộ Trình Đúng Khi Áp Dụng DDD

DDD không phải là bộ pattern bạn copy-paste vào project. Đó là framework tư duy để align developer với domain expert, để code phản ánh business thay vì chỉ store data.

  • ✅ Team có Ubiquitous Language — developer và business dùng cùng một từ cho cùng một concept
  • ✅ Bounded Context được xác định trước khi code
  • ✅ Business rules nằm trong Domain Object, không phải Application Service
  • ✅ Aggregate được thiết kế theo consistency boundary, không phải theo convenience
  • ✅ Repository là interface trong Domain layer, implementation trong Infrastructure
  • ✅ Application Service mỏng — chỉ orchestrate, không chứa business decisions

Nếu team bạn đang gặp vấn đề “không biết đặt logic ở đâu” — đó là dấu hiệu thiếu Strategic DDD (Bounded Context chưa rõ ràng), không phải thiếu Tactical pattern.

Bước tiếp theo: trước khi đặt tên class, hãy tổ chức một buổi EventStorming để khám phá domain cùng domain expert. Sau khi domain rõ ràng, hãy xác định service boundary đúng cách trước khi implement bất kỳ tactical pattern nào. Strategic trước, Tactical sau.

Tài liệu tham khảo: Domain-Driven Design — Eric Evans (O’Reilly)Anemic Domain Model — Martin Fowler.

Bài liên quan: Khám phá Domain với EventStorming | Quy trình phân tích thiết kế hệ thống phần mềm


Comments

Gửi phản hồi

Khám phá thêm từ Hiểu Code

Đăng ký ngay để tiếp tục đọc và truy cập kho lưu trữ đầy đủ.

Tiếp tục đọc