clean architecture
Tung Bui Thanh (TungDaDev)10 mins read

# vấn đề clean architecture giải quyết
Bạn gặp codebase kiểu này bao giờ chưa:
- Đổi database từ PostgreSQL sang MongoDB là phải viết lại 60% code?
- Viết unit test cho business logic mà phải mock 15 dependencies?
- Service class dài 2000 dòng vì trộn chung business rules, HTTP calls, database queries, validation?
- Không test offline được vì code gọi thẳng vào external APIs?
Gốc rễ là business logic dính chặt (coupling) với chi tiết hạ tầng. Service biết mình đang dùng PostgreSQL. Controller biết format response. Use case biết cách gọi REST API. Hạ tầng đổi một cái là business logic phải sửa theo.
Clean Architecture (Robert C. Martin, 2012) xử lý chuyện này bằng Dependency Rule: dependency trong source code chỉ được trỏ vào trong. Lớp bên trong (business rules) hoàn toàn KHÔNG biết lớp bên ngoài (framework, database, UI) có tồn tại.
# the dependency rule: luật duy nhất

Sơ đồ gốc có 4 layer, đi từ trong ra ngoài (chính Martin nói số vòng không bắt buộc là 4, chỉ có Dependency Rule là bắt buộc):
| Layer | Chứa gì | Ví dụ |
|---|---|---|
| Entities | Business object cốt lõi + rules | Order, Money, CreditScore |
| Use Cases | Business rules riêng của ứng dụng | CreateOrderUseCase, ApproveLoanUseCase |
| Interface Adapters | Chuyển đổi format data giữa layers | Controllers, Presenters, Gateways |
| Frameworks & Drivers | Tool, thư viện, hạ tầng bên ngoài | Spring Boot, PostgreSQL, RabbitMQ |
Điểm mấu chốt: Entities không import class Use Case. Use Case không import class Controller. Controller không mang thứ gì riêng của Spring nhét vào Use Case. Interface (port) nằm ở lớp trong, implementation (adapter) nằm ở lớp ngoài.
# ports & adapters (hexagonal architecture)
Clean Architecture và Hexagonal Architecture (Alistair Cockburn) thật ra cùng một ý tưởng, chỉ khác cách vẽ. Hexagonal dùng hình ảnh "ports" (interface do lớp trong định nghĩa) và "adapters" (implementation do lớp ngoài cung cấp).

Inbound Ports (driving): các interface Use Case. Ai muốn "dùng" application thì phải đi qua cửa này. Outbound Ports (driven): các interface Repository/Gateway. Application cần gì từ bên ngoài thì khai báo ở đây. Adapters: implementation cụ thể (REST controller, JPA repository, SMTP email sender).
# implementation trong spring boot
# package structure
com.example.order/
├── domain/ # ← INNER: Zero Spring dependencies
│ ├── model/
│ │ ├── Order.java # Entity (POJO, business rules bên trong)
│ │ ├── OrderItem.java
│ │ ├── Money.java # Value Object
│ │ └── OrderStatus.java
│ ├── port/
│ │ ├── in/ # Inbound Ports (Use Case interfaces)
│ │ │ ├── CreateOrderUseCase.java
│ │ │ ├── GetOrderUseCase.java
│ │ │ └── CancelOrderUseCase.java
│ │ └── out/ # Outbound Ports (driven interfaces)
│ │ ├── OrderRepository.java # Persistence port
│ │ ├── PaymentGateway.java # External service port
│ │ ├── InventoryGateway.java
│ │ └── NotificationPort.java
│ └── service/ # Use Case implementations
│ ├── CreateOrderService.java
│ ├── GetOrderService.java
│ └── CancelOrderService.java
│
├── adapter/ # ← OUTER: Spring, JPA, HTTP dependencies OK
│ ├── in/ # Inbound Adapters (driving)
│ │ ├── web/
│ │ │ ├── OrderController.java # REST adapter
│ │ │ ├── CreateOrderRequest.java # Web-specific DTO
│ │ │ └── OrderResponse.java
│ │ └── messaging/
│ │ └── OrderEventListener.java # RabbitMQ adapter
│ └── out/ # Outbound Adapters (driven)
│ ├── persistence/
│ │ ├── OrderJpaEntity.java # JPA entity (≠ domain Order)
│ │ ├── OrderJpaRepository.java # Spring Data interface
│ │ └── OrderPersistenceAdapter.java # Implements domain's OrderRepository
│ ├── payment/
│ │ └── StripePaymentAdapter.java # Implements PaymentGateway
│ └── notification/
│ └── SmtpNotificationAdapter.java
│
└── config/ # Spring wiring
└── OrderBeanConfig.java
# domain layer: không dính framework nào
// Domain Entity — pure Java, NO Spring annotations, NO JPA annotations
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderItem> items;
private OrderStatus status;
private Money totalAmount;
private LocalDateTime createdAt;
// Business rules LIVE HERE
public void confirm() {
if (this.status != OrderStatus.PENDING) {
throw new IllegalOrderStateException(
"Cannot confirm order in status: " + this.status);
}
if (this.items.isEmpty()) {
throw new EmptyOrderException("Order must have at least 1 item");
}
this.status = OrderStatus.CONFIRMED;
}
public void cancel(String reason) {
if (this.status == OrderStatus.SHIPPED) {
throw new IllegalOrderStateException("Cannot cancel shipped order");
}
this.status = OrderStatus.CANCELLED;
}
public Money calculateTotal() {
// Không dùng Money.ZERO (VND) làm giá trị khởi đầu: order bằng USD sẽ ném CurrencyMismatchException
return items.stream()
.map(item -> item.getPrice().multiply(item.getQuantity()))
.reduce(Money::add)
.orElseThrow(() -> new EmptyOrderException("Order must have at least 1 item"));
}
// No setters — state changes only through business methods
}
// Value Object — immutable, equality by value
public record Money(BigDecimal amount, String currency) {
public static final Money ZERO = new Money(BigDecimal.ZERO, "VND");
public Money {
if (amount.compareTo(BigDecimal.ZERO) < 0)
throw new IllegalArgumentException("Amount cannot be negative");
}
public Money add(Money other) {
if (!this.currency.equals(other.currency))
throw new CurrencyMismatchException(this.currency, other.currency);
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(int quantity) {
return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
}
}# ports: các interface domain cần
// Inbound Port: Use Case interface
public interface CreateOrderUseCase {
OrderId execute(CreateOrderCommand command);
}
// Command object (input to use case)
public record CreateOrderCommand(
CustomerId customerId,
List<OrderItemCommand> items,
PaymentMethod paymentMethod
) {}
// Outbound Port: persistence
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(OrderId id);
List<Order> findByCustomerId(CustomerId customerId);
}
// Outbound Port: external service
public interface PaymentGateway {
PaymentResult charge(OrderId orderId, Money amount, PaymentMethod method);
void refund(OrderId orderId);
}
public interface NotificationPort {
void sendOrderConfirmation(CustomerId customerId, OrderId orderId);
}# use case implementation
// Use Case: orchestrate domain objects + ports
// NO Spring annotations here (optional: can add @Service for convenience)
public class CreateOrderService implements CreateOrderUseCase {
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
private final InventoryGateway inventoryGateway;
private final NotificationPort notificationPort;
public CreateOrderService(OrderRepository orderRepository,
PaymentGateway paymentGateway,
InventoryGateway inventoryGateway,
NotificationPort notificationPort) {
this.orderRepository = orderRepository;
this.paymentGateway = paymentGateway;
this.inventoryGateway = inventoryGateway;
this.notificationPort = notificationPort;
}
@Override
public OrderId execute(CreateOrderCommand command) {
// 1. Create domain object (business rules validated in constructor)
Order order = Order.create(command.customerId(), command.items());
// 2. Check inventory (through port — doesn't know HOW it's checked)
inventoryGateway.checkAvailability(order.getItems());
// 3. Charge payment (through port — doesn't know it's Stripe/VnPay)
PaymentResult payment = paymentGateway.charge(
order.getId(), order.calculateTotal(), command.paymentMethod());
if (!payment.isSuccess()) {
throw new PaymentFailedException(payment.getErrorMessage());
}
// 4. Confirm order (domain business rule)
order.confirm();
// 5. Persist (through port — doesn't know it's PostgreSQL/MongoDB)
orderRepository.save(order);
// 6. Notify (through port — doesn't know it's email/push/SMS)
notificationPort.sendOrderConfirmation(command.customerId(), order.getId());
return order.getId();
}
}Code trên giữ cho gọn nên bỏ qua một chuyện rất đời: thứ tự các bước khi có tiền thật. Nếu paymentGateway.charge thành công mà orderRepository.save lỗi (DB timeout chẳng hạn) thì khách đã bị trừ tiền nhưng hệ thống không có đơn. Cách hay làm là lưu order ở trạng thái PENDING trước, gọi thanh toán với idempotency key là orderId (gọi lại không bị trừ hai lần), rồi mới cập nhật CONFIRMED; còn gửi notification thì đẩy qua event sau khi commit. Clean Architecture không giải bài toán này hộ bạn, nhưng nhờ các port tách bạch nên thay đổi thứ tự hay bọc transaction ở use case làm khá nhẹ nhàng.
# adapter layer: implementation gắn với framework
// Inbound Adapter: REST Controller
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderController {
private final CreateOrderUseCase createOrderUseCase; // Depend on PORT, not implementation
private final GetOrderUseCase getOrderUseCase;
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public OrderResponse create(@Valid @RequestBody CreateOrderRequest request) {
// Convert web DTO → domain command
CreateOrderCommand command = new CreateOrderCommand(
new CustomerId(request.getCustomerId()),
request.getItems().stream().map(this::toItemCommand).toList(),
request.getPaymentMethod()
);
// Call use case (doesn't know about HTTP, JSON, Spring)
OrderId orderId = createOrderUseCase.execute(command);
// Convert domain result → web response
return new OrderResponse(orderId.value(), "Order created successfully");
}
}
// Outbound Adapter: JPA persistence
@Component
@RequiredArgsConstructor
public class OrderPersistenceAdapter implements OrderRepository {
private final OrderJpaRepository jpaRepository; // Spring Data
private final OrderMapper mapper; // Domain ↔ JPA entity
@Override
public Order save(Order order) {
OrderJpaEntity entity = mapper.toJpaEntity(order);
OrderJpaEntity saved = jpaRepository.save(entity);
return mapper.toDomain(saved);
}
@Override
public Optional<Order> findById(OrderId id) {
return jpaRepository.findById(id.value())
.map(mapper::toDomain);
}
@Override
public List<Order> findByCustomerId(CustomerId customerId) {
return jpaRepository.findByCustomerId(customerId.value()).stream()
.map(mapper::toDomain)
.toList();
}
}
// Outbound Adapter: Payment gateway (Stripe)
@Component
@RequiredArgsConstructor
public class StripePaymentAdapter implements PaymentGateway {
private final StripeClient stripeClient;
@Override
public PaymentResult charge(OrderId orderId, Money amount, PaymentMethod method) {
// Stripe tính amount theo đơn vị nhỏ nhất của tiền tệ: USD nhân 100 (cents),
// nhưng VND là zero-decimal currency, gửi nguyên số đồng, không nhân 100
int decimals = amount.currency().equals("VND") ? 0 : 2;
StripeChargeRequest stripeRequest = StripeChargeRequest.builder()
.amount(amount.amount().movePointRight(decimals).longValueExact())
.currency(amount.currency().toLowerCase())
.idempotencyKey(orderId.value().toString())
.build();
StripeChargeResponse response = stripeClient.createCharge(stripeRequest);
return new PaymentResult(response.isSucceeded(), response.getErrorMessage());
}
}# wiring: spring config
@Configuration
public class OrderBeanConfig {
@Bean
public CreateOrderUseCase createOrderUseCase(
OrderRepository orderRepository,
PaymentGateway paymentGateway,
InventoryGateway inventoryGateway,
NotificationPort notificationPort) {
return new CreateOrderService(
orderRepository, paymentGateway, inventoryGateway, notificationPort);
}
}# testing benefits: chỗ được đền đáp
Clean Architecture phát huy rõ nhất lúc viết test: test domain logic mà không cần tí hạ tầng nào.
// Unit test Use Case — mock ports, test business logic
class CreateOrderServiceTest {
private final OrderRepository orderRepository = mock(OrderRepository.class);
private final PaymentGateway paymentGateway = mock(PaymentGateway.class);
private final InventoryGateway inventoryGateway = mock(InventoryGateway.class);
private final NotificationPort notificationPort = mock(NotificationPort.class);
private final CreateOrderUseCase useCase = new CreateOrderService(
orderRepository, paymentGateway, inventoryGateway, notificationPort);
@Test
void createOrder_success() {
when(paymentGateway.charge(any(), any(), any()))
.thenReturn(PaymentResult.success());
when(orderRepository.save(any())).thenAnswer(i -> i.getArgument(0));
OrderId result = useCase.execute(validCommand());
assertThat(result).isNotNull();
verify(orderRepository).save(argThat(order ->
order.getStatus() == OrderStatus.CONFIRMED));
verify(notificationPort).sendOrderConfirmation(any(), any());
}
@Test
void createOrder_paymentFailed_throwsException() {
when(paymentGateway.charge(any(), any(), any()))
.thenReturn(PaymentResult.failed("Insufficient funds"));
assertThatThrownBy(() -> useCase.execute(validCommand()))
.isInstanceOf(PaymentFailedException.class);
verify(orderRepository, never()).save(any()); // Order NOT persisted
}
}
// Domain entity unit test — ZERO mocking
class OrderTest {
@Test
void confirm_pendingOrder_changesStatusToConfirmed() {
Order order = Order.create(customerId, List.of(item1, item2));
order.confirm();
assertThat(order.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
}
@Test
void confirm_emptyOrder_throwsException() {
Order order = Order.create(customerId, List.of());
assertThatThrownBy(order::confirm)
.isInstanceOf(EmptyOrderException.class);
}
@Test
void cancel_shippedOrder_throwsException() {
Order order = createShippedOrder();
assertThatThrownBy(() -> order.cancel("Changed mind"))
.isInstanceOf(IllegalOrderStateException.class);
}
}# pragmatic clean architecture: đừng over-engineer
Clean Architecture là hướng dẫn, không phải tôn giáo. Ngoài đời thì:
Khi nào nên làm FULL Clean Architecture:
- Core domain phức tạp (fintech, bảo hiểm, y tế)
- Nhiều kênh đầu vào (REST, gRPC, messaging, scheduled job)
- Có khả năng phải thay hạ tầng (đổi DB, đổi payment provider)
- Team lớn, cần ranh giới rõ ràng
Khi nào làm BẢN RÚT GỌN (3-layer là đủ):
- App CRUD đơn giản
- Prototype, MVP
- Team nhỏ (1-3 dev)
- Ít business logic (API chủ yếu chuyển tiếp dữ liệu)
Mấy chỗ hay được nới ra:
- Domain entity CÓ THỂ dùng JPA annotations (đánh đổi cho thực tế)
- Use Case CÓ THỂ là class
@Service(cho tiện với Spring) - Bỏ lớp mapper nếu domain model ≈ persistence model
- Gộp inbound port với use case (interface + impl chung một class)
Mục tiêu không phải là "Clean Architecture thuần khiết", mà là business logic test được mà không cần hạ tầng. Làm được điều đó với ít layer hơn thì cũng là thành công rồi.
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.