Hexagonal architecture ได้กลายเป็นรูปแบบสถาปัตยกรรมยอดนิยมสำหรับการแยกตรรกะทางธุรกิจ (business logic) ออกจากโครงสร้างพื้นฐาน (infrastructure) การแยกส่วนนี้ช่วยให้เราสามารถชะลอการตัดสินใจเกี่ยวกับเทคโนโลยี หรือเปลี่ยนเทคโนโลยีได้ง่ายขึ้น นอกจากนี้ยังช่วยให้สามารถทดสอบตรรกะทางธุรกิจแยกต่างหากจากระบบภายนอกได้อีกด้วย

ในบทความนี้ เราจะมาดูวิธีนำ hexagonal architecture มาใช้ใน applicationSpring Boot เราจะแยกตรรกะทางธุรกิจและโครงสร้างพื้นฐานออกเป็น module ของตัวเอง และดูว่าเราจะพัฒนาและทดสอบ module เหล่านี้แยกกันได้อย่างไร

บทความนี้เป็นบทเรียนเชิงปฏิบัติและคาดหวังว่าผู้อ่านจะมีความเข้าใจพื้นฐานเกี่ยวกับหลักการของ hexagonal architecture มาบ้างแล้ว หากต้องการข้อมูลพื้นฐานเพิ่มเติม สามารถอ่านได้ที่บทความ Hexagonal Architecture Explained

Use Cases and Business Logic

คุณสมบัติหลักของ hexagonal architecture คือมันส่งเสริมวิธีการเขียน Use Case ที่เหมาะสม Use Case ควรจะอยู่ตรงขอบ (boundary) ของ application โดยที่ไม่รับรู้ถึงเทคโนโลยีภายนอกใดๆ

เราสามารถระบุตัวละครหลัก (primary actors) ได้สองกลุ่ม ได้แก่: ลูกค้าที่สั่งออเดอร์ และบาริสต้าที่เตรียมออเดอร์ เมื่อเรารู้ว่า Ports ใน hexagonal architecture นั้นเหมาะสมอย่างยิ่งสำหรับการอธิบาย Use Case ของ application สิ่งนี้ก็นำไปสู่การสร้าง primary ports สอง port ได้แก่:OrderingCoffee และ PreparingCoffee ในอีกด้านหนึ่งของ application เราจำเป็นต้องมี secondary ports สำหรับการจัดเก็บออเดอร์และการชำระเงิน

18a37402-7fd9-4d41-bc46-c97f7f98cef8

port OrderingCoffee และ PreparingCoffee จะต้องตอบสนองความต้องการที่เรามีเกี่ยวกับการสั่งซื้อและการเตรียมกาแฟ:

public interface OrderingCoffee {
 Order placeOrder(Order order);
 Order updateOrder(UUID orderId, Order order);
 void cancelOrder(UUID orderId);
 Payment payOrder(UUID orderId, CreditCard creditCard);
 Receipt readReceipt(UUID orderId);
 Order takeOrder(UUID orderId);
}

public interface PreparingCoffee {
 Order startPreparingOrder(UUID orderId);
 Order finishPreparingOrder(UUID orderId);
}

ในทำนองเดียวกัน secondary ports ของเราอาจจะถูกเรียกว่า Orders และ Payments โดยมีหน้าที่ในการจัดเก็บและดึงข้อมูลออเดอร์และการชำระเงิน:

public interface Orders { 
 Order findOrderById(UUID orderId) throws OrderNotFound;
 Order save(Order order);
 void deleteById(UUID orderId);
}

public interface Payments {
 Payment findPaymentByOrderId(UUID orderId);
 Payment save(Payment payment);
}

เราจำเป็นต้องมี entity(entities) บางอย่างในโมเดลโดเมนของเรา เช่น LineItem ในออเดอร์จะเก็บประเภทของกาแฟ, นม และขนาดของเครื่องดื่ม นอกจากนี้เรายังต้องติดตามสถานะ (status) ของออเดอร์ด้วย

public class Order { 
 private UUID id = UUID.randomUUID(); 
 private final Location location; 
 private final List<LineItem> items; 
 private Status status = Status.PAYMENT_EXPECTED;

 // ...
}

public record LineItem(Drink drink, Milk milk, Size size, int quantity) { }

public enum Status { 
 PAYMENT_EXPECTED, 
 PAID, 
 PREPARING, 
 READY, 
 TAKEN 
}

ถัดไป เราต้องนำ Use Case มาใช้งานจริงภายใน application ของเรา เราจะสร้างคลาส CoffeeShop ที่รัน interfaceprimary portOrderingCoffee และคลาสนี้จะเรียกใช้ secondary portsOrders และ Payments ด้วย

794aee30-721e-42e8-ba91-88f3bbf6751c

การนำไปใช้งานนั้นค่อนข้างตรงไปตรงมา นี่คือตัวอย่างการชำระเงินสำหรับออเดอร์ที่ต้องใช้ port เสริมทั้งสอง port:

public class CoffeeShop implements OrderingCoffee { 
 private final Orders orders; 
 private final Payments payments;

 // ...

 @Override 
 public Payment payOrder(UUID orderId, CreditCard creditCard) { 
 var order = orders.findOrderById(orderId); 
 
 orders.save(order.markPaid()); 
 
 return payments.save(new Payment(orderId, creditCard, LocalDate.now())); 
 }
}

หน้าที่ของคลาส CoffeeShop คือการประสานงาน (orchestrate) การทำงานของ entity และพื้นที่เก็บข้อมูล (repositories) โดยรัน port หลักในฐานะ Use Case และใช้ port เสริมในฐานะ repository

ในการใช้งานของเรา เราได้ใส่ตรรกะทางธุรกิจส่วนใหญ่ไว้ใน entity โดเมน (domain entities) นี่คือตัวอย่างการทำเครื่องหมายว่า Order ได้รับการชำระเงินแล้ว:

public class Order { 
 // ...

 public Order markPaid() {
 if (status != Status.PAYMENT_EXPECTED) {
 throw new IllegalStateException("Order is already paid");
 }
 status = Status.PAID;
 return this;
 }
}

สำหรับ Use Case ที่สอง เราจะสร้างคลาส service CoffeeMachine ที่รัน interface port หลัก PreparingCoffee

2aa38fa4-965a-47b3-b584-de1eea3deb2f

เนื่องจากเราต้องการชะลอการตัดสินใจเรื่องเทคโนโลยีออกไปก่อน เราสามารถสร้าง Stub สำหรับ port เสริมที่เรามี วิธีที่ง่ายที่สุดสำหรับ repository คือการเก็บ entity ไว้ใน Map

public class InMemoryOrders implements Orders {
 private final Map<UUID, Order> entities = new HashMap<>();

 @Override
 public Order findOrderById(UUID orderId) {
 var order = entities.get(orderId);
 if (order == null) {
 throw new OrderNotFound();
 }
 return order;
 } 

 @Override
 public Order save(Order order) {
 entities.put(order.getId(), order);
 return order;
 }

 @Override
 public void deleteById(UUID orderId) {
 entities.remove(orderId);
 }
}

สิ่งนี้ช่วยให้เราสามารถรันตรรกะทางธุรกิจได้โดยไม่ต้องกังวลเกี่ยวกับรายละเอียดการจัดเก็บข้อมูลในตอนนี้ อันที่จริง เมื่อทำงานแบบวนซ้ำ (iterative) เราสามารถส่งมอบ application เวอร์ชันแรกด้วย stub ในหน่วยความจำเหล่านี้ได้เลย

ข้อสังเกตประการหนึ่งคือ เราไม่จำเป็นต้องมี interface สำหรับ port หลักก็ได้! การมี interface เหล่านี้ช่วยให้เห็นภาพบทบาทของ port หลักได้ง่ายขึ้น แต่มันไม่ใช่สิ่งจำเป็น เราต้องการ interface สำหรับ port เสริมเท่านั้นเพื่อทำ Dependency Inversion

Acceptance Tests

เรายังสามารถใช้ stub ในหน่วยความจำเหล่านี้สำหรับการทดสอบเพื่อให้ได้การทดสอบที่รวดเร็วอย่างมาก เราสามารถเริ่มจากการเขียน Acceptance Tests สำหรับ Use Case ของเรา เมื่อทำงานแบบ BDD นี่จะเป็นจุดที่เราเริ่มต้นก่อนที่จะลงมือเขียน code จริงด้วยซ้ำ

a409b88e-14f0-4603-8744-656c9f850b6d

การทดสอบเหล่านี้มอง application เป็น "กล่องดำ" (black box) และรัน Use Case ผ่าน port หลักเท่านั้น วิธีนี้ทำให้การทดสอบมีความทนทานต่อการทำ Refactoring เพราะเราสามารถแก้ไข code ภายในได้โดยไม่ต้องแตะต้องส่วนของเซตการทดสอบเลย

Unit Tests

เราอาจตัดสินใจว่ามีบางตรรกะที่ควรค่าแก่การทดสอบแบบแยกส่วน เช่น การคำนวณราคาของออเดอร์ แทนที่จะทดสอบทุกอย่างผ่าน Use Case เราอาจเขียนการทดสอบที่เจาะจงมากขึ้น

e2b014d3-abde-425d-95b0-2367ecb8eeed

นี่คือตัวอย่าง unit test ที่สร้างออเดอร์และตรวจสอบว่าราคาที่คำนวณได้ถูกต้อง:

public class OrderCostTest {

 private static Stream<Arguments> drinkCosts() {
 return Stream.of(
 arguments(1, Size.SMALL, BigDecimal.valueOf(4.0)),
 arguments(1, Size.LARGE, BigDecimal.valueOf(5.0)),
 arguments(2, Size.SMALL, BigDecimal.valueOf(8.0))
 );
 }

 @ParameterizedTest(name = "{0} drinks of size {1} cost {2}")
 @MethodSource("drinkCosts") 
 void orderCostIsBasedOnQuantityAndSize(
 int quantity, Size size, BigDecimal expectedCost) {

 var order = new Order(Location.TAKE_AWAY, List.of(
 new OrderItem(Drink.LATTE, quantity, Milk.WHOLE, size)
 ));

 assertThat(order.getCost()).isEqualTo(expectedCost);
 }
}

สังเกตว่าเราตั้งชื่อคลาสทดสอบว่า OrderCostTest แทนที่จะเป็นแค่ OrderTest เพื่อเน้นย้ำว่าเราควรทดสอบที่ พฤติกรรม (behavior) ไม่ใช่ทดสอบตามชื่อคลาสหรือเมธอด

Primary Adapters (ตัวแปลงฝั่งหลัก)

ขั้นตอนต่อมาคือการเพิ่ม Primary Adapters แม้ว่าตัว application เองจะไม่รู้ว่าใครเป็นคนเรียก Use Case แต่เราต้องจัดหาช่องทางให้โลกภายนอกสามารถสื่อสารกับ application ได้

ลองดูตัวอย่างการรัน REST endpoint สำหรับรับออเดอร์ คลาส controller ควรจะ "บาง" ที่สุดเท่าที่จะเป็นไปได้ ทำหน้าที่เพียงแปลง request ที่เข้ามาให้เป็นสิ่งที่ application เข้าใจ และเรียก port หลักของ application

@Controller 
@RequiredArgsConstructor 
public class OrderController { 
 private final OrderingCoffee orderingCoffee; 
 
 @PostMapping("/order") 
 ResponseEntity<OrderResponse> createOrder(
 @RequestBody OrderRequest request,
 UriComponentsBuilder uriComponentsBuilder) {

 var order = orderingCoffee.placeOrder(request.toDomain()); 
 var location = uriComponentsBuilder.path("/order/{id}")
 .buildAndExpand(order.getId())
 .toUri(); 
 return ResponseEntity.created(location).body(OrderResponse.fromDomain(order)); 
 }
}

ในที่นี้ เราได้ใส่ code การทำ mapping ระหว่าง domain และ response ไว้ใน objectresponse โดยตรง

public record OrderResponse(
 Location location,
 List<OrderItemResponse> items,
 BigDecimal cost
) {
 public static OrderResponse fromDomain(Order order) {
 return new OrderResponse(
 order.getLocation(),
 order.getItems().stream().map(OrderItemResponse::fromDomain).toList(),
 order.getCost()
 );
 }
}

สังเกตว่าเราเลือกใช้ OrderRequest และ OrderResponse แยกกัน เพราะโมเดลสำหรับการเขียน (write model) และการอ่าน (read model) ไม่จำเป็นต้องเหมือนกัน และอาจมีคุณสมบัติที่แตกต่างกันได้

f4f1c7eb-862d-4fcf-959c-b02ee858f297

Note: หากพูดตามหลักการแล้ว กลยุทธ์การทำ mapping แบบนี้อาจไม่สามารถป้องกันไม่ให้ตรรกะของโดเมนหลุดรอดไปยัง primary adapters ได้ทั้งหมด หากต้องการแยกส่วนอย่างสมบูรณ์ เราควรกำหนดโมเดลอย่าง PlacingOrdersCommand เพื่อใช้ร่วมกับ interface port และไม่อนุญาตให้ adapters เข้าถึง Order ได้โดยตรง แต่นั่นก็แลกมาด้วยการต้องเขียน codemapping เพิ่มขึ้นอีกมหาศาล

Configuring the Application (การตั้งค่า application)

ลำพังแค่การเตรียม primary adapters ยังไม่พอ หากเราพยายามเริ่ม application ในตอนนี้ มันจะพังเพราะหา domain beans ไม่เจอ ดังนั้นเราต้องบอกให้ Spring รู้ว่าจะเชื่อมต่อ (wire) คลาสโดเมนอย่าง CoffeeShop และ CoffeeMachine อย่างไร

วิธีปกติคือการใส่ @Service ไว้บนคลาสเหล่านั้น แต่ถ้าเราต้องการให้ application ของเราสะอาดจากการพึ่งพา framework เราทำแบบนั้นไม่ได้

แล้วเราควรทำอย่างไร? เราสามารถสร้างคลาส config และเพิ่ม bean เข้าไปเองได้ แต่ถ้าโปรเจกต์มีขนาดใหญ่ งานนี้จะน่าเบื่อมาก

วิธีที่ดีกว่าคือการสร้าง annotation ของเราเองเพื่อใช้ระบุ Use Case:

@Retention(RetentionPolicy.RUNTIME) 
@Target(ElementType.TYPE) 
@Inherited 
public @interface UseCase { 
}

จากนั้นนำไปใช้กับคลาสในโดเมน:

@UseCase 
public class CoffeeShop implements OrderingCoffee {
 // ...
}

สุดท้าย เพิ่ม config เพื่อสแกนหาคลาสที่ติด annotation นี้และสร้าง bean ให้โดยอัตโนมัติ:

@Configuration
@ComponentScan(
 basePackages = "com.arhohuttunen.coffeeshop.application",
 includeFilters = @ComponentScan.Filter(
 type = FilterType.ANNOTATION, value = UseCase.class
 )
)
public class ApplicationConfig {
}

Integration Tests for Primary Adapters (การทดสอบ Integration ของตัวแปลงฝั่งหลัก)

เพื่อทดสอบ controller เราจะฉีด application ตัวจริงเข้าไปและนำ stub ในหน่วยความจำที่เคยสร้างไว้กลับมาใช้ใหม่ วิธีนี้ทนทานต่อการทำ Refactoring มากกว่าและยังได้ทดสอบ codemapping ไปในตัวด้วย

027f2c30-9364-4d71-ba9b-e437e1334346

ในตัวอย่างนี้ เราต้องสร้าง TestConfiguration เพื่อจัดการ bean สำหรับ stub ในหน่วยความจำ:

@TestConfiguration
@Import(DomainConfig.class)
public class DomainTestConfig {
 @Bean
 Orders orders() {
 return new InMemoryOrders();
 }
 
 @Bean
 Payments payments() {
 return new InMemoryPayments();
 }
}

Secondary Adapters (ตัวแปลงฝั่งเสริม)

เมื่อเรามีจุดเข้าใช้งานระบบพร้อมแล้ว ก็ถึงเวลาที่จะต้องรัน secondary adapters ในที่นี้เราต้องการการจัดเก็บข้อมูลออเดอร์ (persistence)

5192eee2-59e7-4ed7-986f-6f5ab74629db

ในตัวอย่างนี้เราจะใช้ JPA เพื่อแสดงให้เห็นหลักการ Dependency Inversion ซึ่งตัว application จะไม่พึ่งพา adapter โดยตรง แต่จะพึ่งพา port เสริมแทน

@Component 
@RequiredArgsConstructor 
public class OrdersJpaAdapter implements Orders { 
 private final OrderJpaRepository orderJpaRepository; 
 
 @Override 
 public Order findOrderById(UUID orderId) { 
 return orderJpaRepository.findById(orderId) 
 .map(OrderEntity::toDomain) 
 .orElseThrow(); 
 } 

 // ...
}

OrdersJpaAdapter จะทำหน้าที่แปลข้อมูล (translation) ระหว่างโดเมนและ entityJPA ซึ่งช่วยให้โมเดลโดเมนของเราสะอาดจากการติด annotation ของ ORM อย่าง @Entity

Handling Transactions (การจัดการ Transaction)

Use Case คือหน่วยของงานที่เหมาะสมที่สุดในการห่อหุ้มด้วย transaction เราสามารถใช้ Aspect-Oriented Programming (AOP) เพื่อเพิ่มพฤติกรรมนี้เข้าไปโดยไม่ต้องแก้ไข code ในโดเมน

ขั้นแรก เราต้องการตัวรัน code ภายใน transaction:

public class TransactionalUseCaseExecutor {
 @Transactional
 <T> T executeInTransaction(Supplier<T> execution) {
 return execution.get();
 }
}

จากนั้นสร้าง Aspect เพื่อหาจุดที่ต้องการใส่ transaction (ซึ่งก็คือคลาสที่ติด @UseCase) และรันผ่านตัว executor ที่เราสร้างไว้:

@Aspect
@RequiredArgsConstructor
public class TransactionalUseCaseAspect {

 private final TransactionalUseCaseExecutor transactionalUseCaseExecutor;

 @Pointcut("@within(useCase)")
 void inUseCase(UseCase useCase) {
 }
 
 @Around("inUseCase(useCase)")
 Object useCase(ProceedingJoinPoint proceedingJoinPoint, UseCase useCase) {
 return transactionalUseCaseExecutor.executeInTransaction(() -> proceed(proceedingJoinPoint));
 }

 @SneakyThrows
 Object proceed(ProceedingJoinPoint proceedingJoinPoint) {
 return proceedingJoinPoint.proceed();
 }
}

Integration Tests for Secondary Adapters (การทดสอบ Integration ของตัวแปลงฝั่งเสริม)

เราทดสอบตัวแปลงฝั่งเสริมแยกต่างหากโดยใช้ฐานข้อมูลจริง (เช่น ผ่าน Testcontainers) เพื่อให้มั่นใจว่าการทำ mapping ระหว่างโดเมน entity และฐานข้อมูลทำงานได้อย่างถูกต้อง

4c4526d2-68f0-4619-a052-ac3bb968f4b9

การเข้าถึง adapter ผ่าน port เสริมช่วยลดการพึ่งพากันระหว่างการทดสอบและการรันจริง

@DataJpaTest
@ComponentScan("com.arhohuttunen.coffeeshop.adapter.out.persistence")
public class OrdersJpaAdapterTest {
 @Autowired
 private Orders orders;

 @Test
 void creatingOrderReturnsPersistedOrder() {
 var order = new Order(Location.TAKE_AWAY, List.of(
 new OrderItem(Drink.LATTE, 1, Milk.WHOLE, Size.SMALL))
 );

 var persistedOrder = orders.save(order);

 assertThat(persistedOrder.getLocation()).isEqualTo(Location.TAKE_AWAY);
 }
}

End-To-End Tests (การทดสอบตั้งแต่ต้นจนจบ)

สุดท้าย เพื่อความมั่นใจสูงสุด เราควรมีการทดสอบแบบ end-to-end หรือ broad integration tests เพื่ออุดช่องว่างที่การทดสอบระดับล่างอาจจะมองข้ามไป

09b75d32-f690-4cce-8b4c-3fd58be30485

@SpringBootTest
@AutoConfigureMockMvc
class CoffeeShopApplicationTests {
 @Autowired
 private MockMvc mockMvc;

 @Test
 void processNewOrder() throws Exception {
 var orderId = placeOrder();
 payOrder(orderId);
 prepareOrder(orderId);
 readReceipt(orderId);
 takeOrder(orderId);
 }
}

𝐒𝐔𝐌𝐌𝐀𝐑𝐈𝐙𝐈𝐍𝐆

การนำ hexagonal architecture มาใช้กับ Spring Boot ช่วยให้เราแยกส่วนของตรรกะทางธุรกิจออกจากเรื่องทางเทคนิคได้อย่างชัดเจน การใช้ ports และ adapters ทำให้ระบบมีความยืดหยุ่นสูงขึ้นและทดสอบได้ง่ายขึ้นมาก แม้ว่าจะต้องแลกมาด้วยการเขียน code เพิ่มขึ้นบ้าง (โดยเฉพาะส่วนของ mapping) แต่ผลลัพธ์ที่ได้คือสถาปัตยกรรมที่ยั่งยืนและบำรุงรักษาได้ดีในระยะยาว

References