Hexagonal Architecture (Ports & Adapters): Ranh Giới Sạch

Trong layered architecture, dependency chạy theo một hướng: presentation → business logic → data access. Hoạt động tốt cho đến khi bạn nhận ra rằng business logic — phần quan trọng nhất của application — lại depend vào database layer. Đổi database, core logic cũng phải đổi theo.
Hexagonal architecture (còn gọi là Ports & Adapters) đảo ngược mối quan hệ này. Business logic ngồi ở trung tâm và không depend vào gì cả. Mọi thứ bên ngoài — database, API, UI, message queue — kết nối qua các interface được định nghĩa rõ ràng gọi là port, với implementation cụ thể gọi là adapter.
Điều này có nghĩa là bạn có thể swap PostgreSQL sang MongoDB, thay REST API bằng gRPC, hoặc chuyển từ Kafka sang RabbitMQ — tất cả mà không đụng một dòng business logic nào.
Trong bài viết này, chúng ta sẽ đề cập:
✅ Problem với dependency direction truyền thống
✅ Hexagonal architecture giải thích: core, port, và adapter
✅ Driving (primary) vs driven (secondary) adapter
✅ Dependency inversion ở trung tâm pattern
✅ Cấu trúc folder và implementation thực tế
✅ Testing với mock adapter
✅ So sánh hexagonal với layered architecture
✅ Tích hợp với dependency injection (Spring, NestJS)
✅ Ví dụ thực tế: swap database và message broker
✅ Những lỗi implementation phổ biến
Problem: Business Logic Depend Vào Infrastructure
Trong một ứng dụng layered điển hình, chuỗi dependency trông như thế này:
Service layer gọi trực tiếp Repository layer. Repository dùng JPA hoặc TypeORM, bị gắn chặt vào database technology. Điều này tạo ra vấn đề:
1. Business logic depend vào infrastructure
// Service depend trực tiếp vào JPA repository
@Service
public class OrderService {
private final OrderJpaRepository orderRepository; // JPA dependency!
public Order createOrder(CreateOrderRequest request) {
// Business logic trộn lẫn với infrastructure concern
OrderEntity entity = new OrderEntity(); // JPA entity!
entity.setCustomerId(request.getCustomerId());
entity.setStatus("PENDING");
return orderRepository.save(entity); // JPA method!
}
}Muốn chuyển từ PostgreSQL sang MongoDB? Phải viết lại service layer. Muốn dùng ORM khác? Cũng vậy.
2. Testing cần database
// Khó test — cần database thật hoặc H2
@SpringBootTest
class OrderServiceTest {
@Autowired OrderService orderService; // Cần Spring context + DB
@Test
void shouldCreateOrder() {
// Test này chậm vì cần database
Order order = orderService.createOrder(request);
assertNotNull(order.getId());
}
}3. Framework lock-in
JPA annotation, Spring-specific class, và framework convention rò rỉ vào business logic. Migrate sang framework khác đồng nghĩa với viết lại core logic.
Hexagonal Architecture: Ý Tưởng Cốt Lõi
Hexagonal architecture giải quyết điều này bằng cách đảo ngược hướng dependency. Business logic (cái core) định nghĩa interface (các port) mà hệ thống bên ngoài phải implement (thông qua adapter).
Hình lục giác đại diện cho ranh giới application. Mọi thứ bên trong lục giác là business logic. Mọi thứ bên ngoài là infrastructure.
Tại Sao Lại Là Hình Lục Giác?
Alistair Cockburn (người tạo ra pattern này) chọn hình lục giác vì nó có đủ cạnh để biểu diễn các loại tương tác bên ngoài khác nhau — nhưng hình dạng chính xác không quan trọng. Insight quan trọng là không có trên hay dưới. Khác với layered architecture nơi mọi thứ chảy từ trên xuống dưới, hexagonal architecture đặt core ở trung tâm với port ở mọi phía.
Ba Phần Chính
1. Core (Business Logic)
Phần trong cùng nhất. Chứa domain entity, business rule, và use case. Không có dependency vào framework hay infrastructure bên ngoài.
// Pure domain entity — không có framework annotation
public class Order {
private final String id;
private final String customerId;
private OrderStatus status;
private final List<OrderItem> items;
private BigDecimal totalAmount;
public void confirm() {
if (status != OrderStatus.PLACED) {
throw new IllegalStateException(
"Can only confirm orders in PLACED status, current: " + status
);
}
this.status = OrderStatus.CONFIRMED;
}
public void cancel(String reason) {
if (status == OrderStatus.SHIPPED) {
throw new IllegalStateException("Cannot cancel shipped orders");
}
this.status = OrderStatus.CANCELLED;
}
public void addItem(OrderItem item) {
items.add(item);
recalculateTotal();
}
private void recalculateTotal() {
this.totalAmount = items.stream()
.map(i -> i.price().multiply(BigDecimal.valueOf(i.quantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}Chú ý: không có @Entity, không @Id, không @Column. Đây là pure domain object chỉ chứa business logic.
2. Port (Interface)
Port là interface được định nghĩa bởi core, mô tả cái gì nó cần từ thế giới bên ngoài, mà không chỉ định cách nào.
3. Adapter (Implementation)
Adapter là implementation cụ thể của port. Chúng dịch giữa ngôn ngữ của core và ngôn ngữ của hệ thống bên ngoài.
Port: Driving vs Driven
Port có hai loại dựa trên ai khởi tạo tương tác.
Driving Port (Primary / Inbound)
Driving port định nghĩa cách thế giới bên ngoài tương tác với application. Chúng là API của application — các use case mà nó expose.
// Driving port — định nghĩa application CÓ THỂ LÀM GÌ
public interface OrderService {
String placeOrder(PlaceOrderCommand command);
void confirmOrder(String orderId);
void cancelOrder(String orderId, String reason);
OrderDetails getOrderDetails(String orderId);
}Ai gọi driving port? REST controller, GraphQL resolver, CLI command, message consumer — bất kỳ thứ gì trigger application làm việc gì đó.
Driven Port (Secondary / Outbound)
Driven port định nghĩa những gì application cần từ thế giới bên ngoài. Chúng là requirement của application — infrastructure mà nó depend vào.
// Driven port — định nghĩa application CẦN GÌ
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(String id);
List<Order> findByCustomerId(String customerId);
}
public interface PaymentGateway {
PaymentResult charge(String customerId, BigDecimal amount);
PaymentResult refund(String paymentId);
}
public interface NotificationSender {
void sendOrderConfirmation(String customerId, String orderId);
void sendOrderCancellation(String customerId, String orderId);
}
public interface EventPublisher {
void publish(DomainEvent event);
}Ai implement driven port? Database adapter, HTTP client, message producer, email sender — infrastructure cụ thể.
Hướng Của Dependency
Đây là quy tắc quan trọng nhất của hexagonal architecture:
- Driving adapter depend vào core (chúng gọi core interface)
- Core không depend vào gì bên ngoài (chỉ định nghĩa interface)
- Driven adapter depend vào core (chúng implement core interface)
Tất cả mũi tên chỉ vào trong. Core không bao giờ reference bất kỳ thứ gì bên ngoài nó.
Implementation Thực Tế
Hãy xây dựng một hệ thống order hoàn chỉnh dùng hexagonal architecture với Spring Boot.
Cấu Trúc Project
src/main/java/com/example/orders/
├── domain/ # CORE — không có framework dependency
│ ├── model/
│ │ ├── Order.java # Domain entity
│ │ ├── OrderItem.java # Value object
│ │ ├── OrderStatus.java # Enum
│ │ └── DomainEvent.java # Base event
│ ├── port/
│ │ ├── in/ # Driving port (inbound)
│ │ │ └── OrderService.java # Use case interface
│ │ └── out/ # Driven port (outbound)
│ │ ├── OrderRepository.java # Persistence interface
│ │ ├── PaymentGateway.java # Payment interface
│ │ └── EventPublisher.java # Event interface
│ ├── service/
│ │ └── OrderServiceImpl.java # Use case implementation
│ └── exception/
│ ├── OrderNotFoundException.java
│ └── InsufficientInventoryException.java
│
├── adapter/ # ADAPTER — framework-specific
│ ├── in/ # Driving adapter (inbound)
│ │ ├── rest/
│ │ │ ├── OrderController.java # REST API
│ │ │ ├── OrderRequest.java # REST DTO
│ │ │ └── OrderResponse.java
│ │ └── messaging/
│ │ └── OrderEventListener.java # Kafka consumer
│ └── out/ # Driven adapter (outbound)
│ ├── persistence/
│ │ ├── OrderJpaRepository.java # Spring Data JPA
│ │ ├── OrderJpaEntity.java # JPA entity
│ │ └── OrderPersistenceAdapter.java # Adapter
│ ├── payment/
│ │ └── StripePaymentAdapter.java # Stripe implementation
│ └── messaging/
│ └── KafkaEventPublisher.java # Kafka producerCore: Domain Model + Port + Use Case
// domain/model/Order.java — Pure domain entity
public class Order {
private String id;
private String customerId;
private OrderStatus status;
private List<OrderItem> items;
private BigDecimal totalAmount;
public static Order create(String customerId, List<OrderItem> items) {
Order order = new Order();
order.id = UUID.randomUUID().toString();
order.customerId = customerId;
order.status = OrderStatus.PLACED;
order.items = new ArrayList<>(items);
order.totalAmount = items.stream()
.map(OrderItem::subtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return order;
}
public void confirm() {
if (status != OrderStatus.PLACED) {
throw new IllegalStateException("Cannot confirm order in status: " + status);
}
status = OrderStatus.CONFIRMED;
}
public void cancel(String reason) {
if (status == OrderStatus.SHIPPED || status == OrderStatus.DELIVERED) {
throw new IllegalStateException("Cannot cancel order in status: " + status);
}
status = OrderStatus.CANCELLED;
}
// Getter only — không có setter, enforce invariant qua method
public String getId() { return id; }
public String getCustomerId() { return customerId; }
public OrderStatus getStatus() { return status; }
public List<OrderItem> getItems() { return Collections.unmodifiableList(items); }
public BigDecimal getTotalAmount() { return totalAmount; }
}
// domain/model/OrderItem.java — Value object
public record OrderItem(
String productId,
String productName,
int quantity,
BigDecimal price
) {
public BigDecimal subtotal() {
return price.multiply(BigDecimal.valueOf(quantity));
}
}// domain/port/in/OrderService.java — Driving port
public interface OrderService {
String placeOrder(PlaceOrderCommand command);
void confirmOrder(String orderId);
void cancelOrder(String orderId, String reason);
OrderDetails getOrderDetails(String orderId);
}
// domain/port/in/PlaceOrderCommand.java
public record PlaceOrderCommand(
String customerId,
List<OrderItemRequest> items
) {}
// domain/port/out/OrderRepository.java — Driven port
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(String id);
List<Order> findByCustomerId(String customerId);
}
// domain/port/out/PaymentGateway.java — Driven port
public interface PaymentGateway {
PaymentResult charge(String customerId, BigDecimal amount);
}
// domain/port/out/EventPublisher.java — Driven port
public interface EventPublisher {
void publish(DomainEvent event);
}// domain/service/OrderServiceImpl.java — Use case implementation
public class OrderServiceImpl implements OrderService {
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
private final EventPublisher eventPublisher;
// Constructor injection — không @Autowired, không Spring annotation
public OrderServiceImpl(OrderRepository orderRepository,
PaymentGateway paymentGateway,
EventPublisher eventPublisher) {
this.orderRepository = orderRepository;
this.paymentGateway = paymentGateway;
this.eventPublisher = eventPublisher;
}
@Override
public String placeOrder(PlaceOrderCommand command) {
// Pure business logic — không có framework code
List<OrderItem> items = command.items().stream()
.map(r -> new OrderItem(r.productId(), r.productName(),
r.quantity(), r.price()))
.toList();
Order order = Order.create(command.customerId(), items);
// Charge payment qua port (không trực tiếp qua Stripe)
PaymentResult payment = paymentGateway.charge(
command.customerId(), order.getTotalAmount()
);
if (!payment.isSuccess()) {
throw new PaymentFailedException(payment.errorMessage());
}
order.confirm();
// Save qua port (không trực tiếp qua JPA)
orderRepository.save(order);
// Publish event qua port (không trực tiếp qua Kafka)
eventPublisher.publish(new OrderPlacedEvent(
order.getId(), order.getCustomerId(), order.getTotalAmount()
));
return order.getId();
}
@Override
public void cancelOrder(String orderId, String reason) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.cancel(reason); // Domain logic validate state transition
orderRepository.save(order);
eventPublisher.publish(new OrderCancelledEvent(orderId, reason));
}
@Override
public OrderDetails getOrderDetails(String orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
return OrderDetails.from(order);
}
}Driving Adapter: REST Controller
// adapter/in/rest/OrderController.java
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService; // Depend vào PORT, không phải impl
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public ResponseEntity<OrderResponse> placeOrder(
@Valid @RequestBody OrderRequest request) {
// Dịch REST request → domain command
PlaceOrderCommand command = new PlaceOrderCommand(
request.customerId(),
request.items().stream()
.map(i -> new OrderItemRequest(
i.productId(), i.productName(), i.quantity(), i.price()))
.toList()
);
String orderId = orderService.placeOrder(command);
return ResponseEntity.status(HttpStatus.CREATED)
.body(new OrderResponse(orderId, "Order placed successfully"));
}
@DeleteMapping("/{orderId}")
public ResponseEntity<Void> cancelOrder(
@PathVariable String orderId,
@RequestParam String reason) {
orderService.cancelOrder(orderId, reason);
return ResponseEntity.noContent().build();
}
@GetMapping("/{orderId}")
public ResponseEntity<OrderDetailsResponse> getOrder(
@PathVariable String orderId) {
OrderDetails details = orderService.getOrderDetails(orderId);
return ResponseEntity.ok(OrderDetailsResponse.from(details));
}
}Driven Adapter: Database (JPA)
// adapter/out/persistence/OrderJpaEntity.java — JPA entity (adapter concern!)
@Entity
@Table(name = "orders")
public class OrderJpaEntity {
@Id private String id;
private String customerId;
@Enumerated(EnumType.STRING) private OrderStatus status;
private BigDecimal totalAmount;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItemJpaEntity> items;
// Mapper: domain ↔ JPA entity
public static OrderJpaEntity fromDomain(Order order) {
OrderJpaEntity entity = new OrderJpaEntity();
entity.id = order.getId();
entity.customerId = order.getCustomerId();
entity.status = order.getStatus();
entity.totalAmount = order.getTotalAmount();
entity.items = order.getItems().stream()
.map(OrderItemJpaEntity::fromDomain)
.toList();
return entity;
}
public Order toDomain() {
return Order.reconstitute(id, customerId, status, totalAmount,
items.stream().map(OrderItemJpaEntity::toDomain).toList());
}
}
// adapter/out/persistence/OrderJpaRepository.java — Spring Data interface
public interface OrderJpaRepository extends JpaRepository<OrderJpaEntity, String> {
List<OrderJpaEntity> findByCustomerId(String customerId);
}
// adapter/out/persistence/OrderPersistenceAdapter.java — ADAPTER
@Component
public class OrderPersistenceAdapter implements OrderRepository {
private final OrderJpaRepository jpaRepository;
public OrderPersistenceAdapter(OrderJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Order save(Order order) {
OrderJpaEntity entity = OrderJpaEntity.fromDomain(order);
jpaRepository.save(entity);
return order;
}
@Override
public Optional<Order> findById(String id) {
return jpaRepository.findById(id)
.map(OrderJpaEntity::toDomain);
}
@Override
public List<Order> findByCustomerId(String customerId) {
return jpaRepository.findByCustomerId(customerId).stream()
.map(OrderJpaEntity::toDomain)
.toList();
}
}Driven Adapter: Payment Gateway
// adapter/out/payment/StripePaymentAdapter.java
@Component
public class StripePaymentAdapter implements PaymentGateway {
private final StripeClient stripeClient;
@Override
public PaymentResult charge(String customerId, BigDecimal amount) {
try {
StripeCharge charge = stripeClient.createCharge(
customerId, amount.longValue() * 100, "usd"
);
return PaymentResult.success(charge.getId());
} catch (StripeException e) {
return PaymentResult.failure(e.getMessage());
}
}
}Driven Adapter: Event Publisher
// adapter/out/messaging/KafkaEventPublisher.java
@Component
public class KafkaEventPublisher implements EventPublisher {
private final KafkaTemplate<String, DomainEvent> kafkaTemplate;
@Override
public void publish(DomainEvent event) {
kafkaTemplate.send("order-events", event.getAggregateId(), event);
}
}Nối Tất Cả Lại (Spring Configuration)
// Configuration — nơi duy nhất Spring biết về core
@Configuration
public class OrderConfig {
@Bean
public OrderService orderService(
OrderRepository orderRepository,
PaymentGateway paymentGateway,
EventPublisher eventPublisher) {
return new OrderServiceImpl(orderRepository, paymentGateway, eventPublisher);
}
}Dependency injection của Spring tự động tìm các @Component adapter và inject vào use case. Core không bao giờ biết nó đang chạy trong Spring.
Testing Với Mock Adapter
Lợi thế lớn nhất của hexagonal architecture: test core mà không cần infrastructure.
Unit Test Use Case
// Pure unit test — không Spring, không database, không Kafka
class OrderServiceImplTest {
private OrderRepository orderRepository;
private PaymentGateway paymentGateway;
private EventPublisher eventPublisher;
private OrderService orderService;
@BeforeEach
void setUp() {
orderRepository = mock(OrderRepository.class);
paymentGateway = mock(PaymentGateway.class);
eventPublisher = mock(EventPublisher.class);
orderService = new OrderServiceImpl(
orderRepository, paymentGateway, eventPublisher
);
}
@Test
void shouldPlaceOrderSuccessfully() {
// Given
when(paymentGateway.charge(any(), any()))
.thenReturn(PaymentResult.success("pay-123"));
when(orderRepository.save(any())).thenAnswer(i -> i.getArgument(0));
PlaceOrderCommand command = new PlaceOrderCommand(
"customer-1",
List.of(new OrderItemRequest("prod-1", "Widget", 2, new BigDecimal("10.00")))
);
// When
String orderId = orderService.placeOrder(command);
// Then
assertNotNull(orderId);
verify(paymentGateway).charge("customer-1", new BigDecimal("20.00"));
verify(orderRepository).save(any(Order.class));
verify(eventPublisher).publish(any(OrderPlacedEvent.class));
}
@Test
void shouldRejectOrderWhenPaymentFails() {
// Given
when(paymentGateway.charge(any(), any()))
.thenReturn(PaymentResult.failure("Insufficient funds"));
PlaceOrderCommand command = new PlaceOrderCommand(
"customer-1",
List.of(new OrderItemRequest("prod-1", "Widget", 1, new BigDecimal("10.00")))
);
// When/Then
assertThrows(PaymentFailedException.class,
() -> orderService.placeOrder(command));
verify(orderRepository, never()).save(any());
verify(eventPublisher, never()).publish(any());
}
@Test
void shouldNotCancelShippedOrder() {
// Given
Order shippedOrder = createShippedOrder();
when(orderRepository.findById("order-1")).thenReturn(Optional.of(shippedOrder));
// When/Then
assertThrows(IllegalStateException.class,
() -> orderService.cancelOrder("order-1", "Changed my mind"));
}
}Những test này chạy trong millisecond — không Spring context, không database, không Kafka. Chúng test pure business logic.
Integration Test Adapter
Test từng adapter độc lập với infrastructure thật (dùng test container):
// Test persistence adapter với database thật
@DataJpaTest
@Testcontainers
class OrderPersistenceAdapterTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired private OrderJpaRepository jpaRepository;
private OrderPersistenceAdapter adapter;
@BeforeEach
void setUp() {
adapter = new OrderPersistenceAdapter(jpaRepository);
}
@Test
void shouldSaveAndRetrieveOrder() {
Order order = Order.create("customer-1",
List.of(new OrderItem("prod-1", "Widget", 2, new BigDecimal("10.00"))));
adapter.save(order);
Optional<Order> retrieved = adapter.findById(order.getId());
assertTrue(retrieved.isPresent());
assertEquals("customer-1", retrieved.get().getCustomerId());
assertEquals(OrderStatus.PLACED, retrieved.get().getStatus());
}
}Swap Infrastructure: Sức Mạnh Của Hexagonal
Đây là nơi hexagonal architecture thực sự tỏa sáng. Hãy swap PostgreSQL sang MongoDB — mà không đụng một dòng business logic nào.
Bước 1: Tạo MongoDB Adapter
// adapter/out/persistence/mongo/OrderMongoDocument.java
@Document(collection = "orders")
public class OrderMongoDocument {
@Id private String id;
private String customerId;
private String status;
private BigDecimal totalAmount;
private List<OrderItemDocument> items;
public static OrderMongoDocument fromDomain(Order order) { /* ... */ }
public Order toDomain() { /* ... */ }
}
// adapter/out/persistence/mongo/OrderMongoRepository.java
public interface OrderMongoRepository extends MongoRepository<OrderMongoDocument, String> {
List<OrderMongoDocument> findByCustomerId(String customerId);
}
// adapter/out/persistence/mongo/OrderMongoAdapter.java
@Component
@Profile("mongodb") // Kích hoạt bằng Spring profile
public class OrderMongoAdapter implements OrderRepository {
private final OrderMongoRepository mongoRepository;
@Override
public Order save(Order order) {
mongoRepository.save(OrderMongoDocument.fromDomain(order));
return order;
}
@Override
public Optional<Order> findById(String id) {
return mongoRepository.findById(id).map(OrderMongoDocument::toDomain);
}
@Override
public List<Order> findByCustomerId(String customerId) {
return mongoRepository.findByCustomerId(customerId).stream()
.map(OrderMongoDocument::toDomain).toList();
}
}Bước 2: Chuyển Bằng Spring Profile
# application-mongodb.yml
spring:
data:
mongodb:
uri: mongodb://localhost:27017/orders# Chạy với PostgreSQL (mặc định)
java -jar app.jar
# Chạy với MongoDB
java -jar app.jar --spring.profiles.active=mongodbVậy thôi. Core (OrderServiceImpl, Order, tất cả business rule) hoàn toàn không thay đổi. Chỉ adapter được swap.
Tương tự, chuyển từ Kafka sang RabbitMQ:
// adapter/out/messaging/RabbitEventPublisher.java
@Component
@Profile("rabbitmq")
public class RabbitEventPublisher implements EventPublisher {
private final RabbitTemplate rabbitTemplate;
@Override
public void publish(DomainEvent event) {
rabbitTemplate.convertAndSend("order-events", event);
}
}Hexagonal vs Layered Architecture
| Khía cạnh | Layered | Hexagonal |
|---|---|---|
| Hướng dependency | Trên → dưới | Ngoài → trong |
| Business logic depend vào | Data access layer | Không gì (định nghĩa port) |
| Test core logic | Cần database hoặc mock concrete class | Mock interface (port) |
| Swap infrastructure | Cần thay đổi business layer | Chỉ đổi adapter |
| Complexity | Đơn giản hơn, ít file | Nhiều file và interface hơn |
| Phù hợp cho | App CRUD đơn giản | Business logic phức tạp, nhiều integration |
Khi nào chọn hexagonal thay vì layered:
- Business logic là phần có giá trị nhất của application
- Cần support nhiều infrastructure option
- Muốn test business logic riêng biệt
- Domain có rule phức tạp thay đổi độc lập với infrastructure
Khi nào layered là đủ:
- CRUD đơn giản với business logic mỏng
- Một database, một API, không messaging
- Team nhỏ nơi abstraction thêm tạo cognitive overhead
Những Lỗi Phổ Biến
1. Rò Rỉ Framework Code Vào Core
// ❌ SAI — Spring annotation trong core
@Service // Spring!
public class OrderServiceImpl implements OrderService {
@Autowired // Spring!
private OrderRepository orderRepository;
@Transactional // Spring!
public String placeOrder(PlaceOrderCommand command) { ... }
}// ✅ ĐÚNG — Pure core, Spring chỉ trong configuration
public class OrderServiceImpl implements OrderService {
private final OrderRepository orderRepository;
public OrderServiceImpl(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public String placeOrder(PlaceOrderCommand command) { ... }
}
// Configuration class xử lý Spring wiring
@Configuration
public class OrderConfig {
@Bean
public OrderService orderService(OrderRepository repo) {
return new OrderServiceImpl(repo);
}
}2. Domain Entity Là Database Entity
// ❌ SAI — Domain entity với JPA annotation
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue private Long id;
@Column private String customerId;
// Business logic trộn lẫn persistence concern
}// ✅ ĐÚNG — Tách riêng domain entity và JPA entity
// domain/model/Order.java — Pure domain
public class Order {
private String id;
private String customerId;
// Chỉ business logic
}
// adapter/out/persistence/OrderJpaEntity.java — Chỉ JPA
@Entity @Table(name = "orders")
public class OrderJpaEntity {
@Id private String id;
@Column private String customerId;
// Chỉ mapping logic
}3. Quá Nhiều Port Cho Operation Đơn Giản
// ❌ Over-engineering — một port cho mỗi operation
public interface OrderSaver { void save(Order order); }
public interface OrderFinder { Optional<Order> findById(String id); }
public interface OrderDeleter { void delete(String id); }
public interface OrderLister { List<Order> findAll(); }// ✅ Thực dụng — một port cho mỗi driven dependency
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(String id);
List<Order> findByCustomerId(String customerId);
void delete(String id);
}4. Adapter Chứa Business Logic
// ❌ SAI — Business logic trong adapter
@Component
public class OrderPersistenceAdapter implements OrderRepository {
@Override
public Order save(Order order) {
// Business validation trong adapter!
if (order.getTotalAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new ValidationException("Order total must be positive");
}
jpaRepository.save(OrderJpaEntity.fromDomain(order));
return order;
}
}Adapter chỉ nên dịch giữa ngôn ngữ của core và ngôn ngữ của infrastructure. Tất cả business logic thuộc về core.
Tổng Kết
Hexagonal architecture (Ports & Adapters) đặt business logic ở trung tâm application, cách ly khỏi mọi external dependency thông qua interface được định nghĩa rõ ràng.
Khái niệm cốt lõi:
- Core — domain model và use case với zero infrastructure dependency
- Port — interface được định nghĩa bởi core (cái nó cung cấp và cái nó cần)
- Adapter — implementation cụ thể kết nối port với infrastructure thật
Hai loại port:
- Driving (primary) — cách thế giới bên ngoài tương tác với app (REST, GraphQL, CLI)
- Driven (secondary) — cái app cần từ thế giới bên ngoài (database, payment, messaging)
Lợi ích chính:
- Business logic testable mà không cần infrastructure
- Swap infrastructure (database, message broker, API) mà không đổi core logic
- Domain model không phụ thuộc framework và portable
- Phân tách rõ ràng giữa "cái gì" (port) và "cách nào" (adapter)
Quy tắc chính:
- Tất cả dependency chỉ vào trong — core không bao giờ reference adapter
- Adapter depend vào core (implement core interface)
- Core chỉ depend vào port của chính nó (interface)
Khi nào nên dùng:
- Business logic phức tạp cần cách ly khỏi infrastructure
- Nhiều integration point (database, API, message broker)
- Application sống lâu dài nơi infrastructure sẽ thay đổi
- Team coi trọng testability và clean separation
Khi nào nên bỏ qua:
- Application CRUD đơn giản với business logic tối thiểu
- Prototype nhỏ nơi tốc độ quan trọng hơn structure
- Application gắn chặt vào một infrastructure duy nhất theo design
Hình lục giác đại diện cho một ranh giới: bên trong là cái application là, bên ngoài là cái nó dùng. Giữ ranh giới đó sạch sẽ, và code của bạn sẽ luôn adaptable.
Tiếp Theo Trong Software Architecture Series
Đây là bài 8 trong 12 bài của series Software Architecture Patterns:
- ✅ ARCH-1: Software Architecture Patterns Roadmap
- ✅ ARCH-2: Monolithic Architecture
- ✅ ARCH-3: Layered (N-Tier) Architecture
- ✅ ARCH-4: MVC, MVP & MVVM Patterns
- ✅ ARCH-5: Microservices Architecture
- ✅ ARCH-6: Event-Driven Architecture
- ✅ ARCH-7: CQRS & Event Sourcing
- ✅ ARCH-8: Hexagonal Architecture (bài này)
- 🔜 ARCH-9: Clean Architecture
- 🔜 ARCH-10: Domain-Driven Design (DDD)
- 🔜 ARCH-11: Serverless & Function-as-a-Service
- 🔜 ARCH-12: Choosing the Right Architecture
Bài 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.