Prerequisite 3: Clean Architecture + Hexagonal Architecture — แปลง Tactical Design เป็น Code Architecture
Prerequisite 3: Clean Architecture + Hexagonal Architecture
แปลง Tactical Design เป็น Code Architecture
เชื่อมกับ Prerequisite ที่ผ่านมา
Prerequisite 1 — DDD Strategic Design
- แบ่งระบบ ERP ออกเป็น Bounded Context ที่ชัดเจน
- แต่ละ Context มี Ubiquitous Language ของตัวเอง
- Sales Context มี
SalesProductและSalesOrderที่ไม่ใช่ entity เดียวกับ Procurement หรือ Cost Accounting
Prerequisite 2 — DDD Tactical Design
- ออกแบบ Domain Model ภายใน Context ด้วย Entity, Value Object, Aggregate
- Repository ต้องเป็น interface ใน Domain Layer
- Application Service คือตัวประสาน Use Case ทั้งหมด
- Invariant ต้องถูก enforce ใกล้ข้อมูลมากที่สุด
ตอนนี้คำถามที่เหลือคือ — “building blocks พวกนี้ควรอยู่ที่ไหนในโครงสร้างโค้ด และใครควรรู้จักใคร?”
Repository interface ควรอยู่ใน folder ไหน? PlaceSalesOrderUseCase ควรรู้จัก Prisma หรือเปล่า? ถ้าเปลี่ยนจาก Fastify เป็น Hono ต้องแก้โค้ดกี่ไฟล์?
นั่นคือสิ่งที่ Clean Architecture และ Hexagonal Architecture มาตอบ
Pain Point — “ปัญหาที่ทุกคนในทีมเคยเจอ”
ก่อนรู้จัก Solution ลองดูปัญหาจริงๆ ที่เกิดขึ้นในทีม Dev ไม่ใช่ทฤษฎี
Dev: “เปลี่ยน DB นิดเดียว แก้โค้ด 10 ไฟล์”
ทีม Sales Management Context ตัดสินใจย้ายจาก PostgreSQL ไป Aurora ด้วยเหตุผลเรื่อง cost วันแรกที่เปิด codebase เจอว่า PrismaClient ถูก import อยู่ใน placeSalesOrder function โดยตรง ใน validateCreditLimit มีการเรียก prisma.customer.findUnique ฝังอยู่ และใน calculateDiscount มีการ query ราคาจาก DB อยู่กลาง function
สุดท้ายต้องแก้ 11 ไฟล์ และยังไม่แน่ใจว่าพังที่อื่นอีกไหม
EM: “ทุกครั้งที่มี requirement ใหม่ ต้อง refactor ก่อน”
Business ต้องการเพิ่มฟีเจอร์ promotion code สำหรับ Sales Context BA ส่ง requirement มาชัดเจน แต่ Dev บอกว่าต้อง refactor ส่วน discount calculation ก่อนสัก 2 สัปดาห์ถึงจะเพิ่มได้
EM ถามว่า “discount calculation ไม่ใช่ business logic ที่มีอยู่แล้วเหรอ?” Dev อธิบายว่า “มีอยู่ แต่มันผูกกับ query ราคาจาก DB อยู่ เพิ่มตรงนี้ไม่ได้โดยไม่แตะส่วนอื่น”
BA: “ไม่รู้ว่า ‘ทำไม่ได้’ เพราะ business rule หรือเพราะ technical”
BA ถาม Dev ว่า “ถ้าลูกค้ากลุ่ม Enterprise ขอ discount พิเศษเป็น case-by-case ได้ไหม?” Dev บอกว่า “ทำไม่ได้ครับ” แต่ไม่ได้อธิบายว่าเพราะอะไร
BA ไม่รู้ว่า “ทำไม่ได้” หมายถึง Business Rule ของบริษัทห้ามทำ หรือหมายถึงระบบปัจจุบันออกแบบมาไม่รองรับ และถ้าอย่างหลัง ต้องใช้เวลากี่สัปดาห์
ปัญหาทั้งสามข้อนี้มีชื่อเรียกเดียวกันว่า Coupling — Domain Logic ผูกติดกับ Technical Detail จนแยกไม่ออก
และมีสองคนที่นั่งคิดวิธีแก้มันในช่วงเวลาต่างกัน คนละมุม แต่เป้าหมายเดียวกัน
ที่มา — “สองคนเจ็บปวดแบบเดียวกัน คิดทางออกคนละแบบ”
Alistair Cockburn กับ Hexagonal Architecture (2005)
Cockburn เป็นนักพัฒนาที่เจอปัญหาเดิมซ้ำๆ ในทุก project ที่เขาทำ — ทุกครั้งที่อยากเขียน automated test ก็ต้องมี database จริง ทุกครั้งที่อยากสาธิต use case ให้ stakeholder ดู ก็ต้องรัน server จริง ทุกครั้งที่เปลี่ยน UI ก็ต้องแก้ business logic ไปพร้อมกัน
คำถามที่เขาถามตัวเองคือ — “ถ้า core ของระบบไม่รู้จักโลกภายนอกเลย มันจะทำงานได้ทุกที่ทุกเวลา เสียบเข้ากับ DB จริงก็ได้ เสียบเข้ากับ mock ก็ได้ เสียบเข้ากับ UI ก็ได้ หรือรันผ่าน command line ก็ยังได้”
เขาเรียก idea นี้ว่า Ports and Adapters Architecture และเปิดเผยในปี 2005 ภายใต้ชื่อที่ตามมาทีหลังว่า Hexagonal Architecture
Analogy ที่เขาชอบใช้คือ ปลั๊กไฟ — ของที่ใช้ไฟฟ้าไม่ควรรู้ว่าไฟมาจากโรงไฟฟ้าไหน สายส่งแบบไหน หรือกระแสสลับหรือตรง มันรู้แค่ว่า “ฉันต้องการ interface มาตรฐาน” แล้วทุกอย่างก็ทำงานได้
Goal ของเขาชัดเจน — แยก “หัวใจของระบบ” ออกจาก “โลกภายนอก” ให้ได้อย่างสมบูรณ์
Robert C. Martin กับ Clean Architecture (2012)
Uncle Bob สังเกตว่า Architecture หลายแบบก่อนหน้า รวมถึง Hexagonal ก็ตาม แก้ปัญหา coupling ได้บางส่วน แต่ยังขาดกฎที่ชัดเจนพอสำหรับระบบที่ซับซ้อนขึ้น โดยเฉพาะเรื่อง “ใครขึ้นกับใคร ใน hierarchy ที่มีหลาย layer”
คำถามที่เขาถามตัวเองคือ — “ถ้ามีกฎข้อเดียวที่บอกว่า dependency ต้องชี้เข้าใน (inward) เสมอ ระบบจะยืดหยุ่นโดยอัตโนมัติ เพราะ layer นอกสุดเปลี่ยนได้โดยไม่กระทบ layer ในสุดเลย”
เขาเปิดเผยแนวคิดนี้ในปี 2012 ในชื่อ Clean Architecture พร้อม diagram วงกลมซ้อนกัน 4 ชั้น ที่กฎเดียวคือ dependency arrow ต้องชี้เข้าในเสมอ
Analogy ที่ช่วยให้เห็นภาพคือ ชั้นในอาคาร — ชั้นบนสุด (ผนังกั้นห้อง ตกแต่งภายใน) รื้อและสร้างใหม่ได้โดยไม่กระทบโครงสร้างชั้นล่าง แต่โครงสร้างชั้นล่าง (เสา คาน ฐานราก) ถ้าเปลี่ยนจะกระทบทุกอย่าง
Goal ของเขาคือ วาง hierarchy ที่ชัดเจน ให้ทุก layer เปลี่ยนได้อย่างอิสระตามลำดับความสำคัญ
สองคน ปัญหาเดียวกัน ทางออกต่างมุม
Uncle Bob อ้างถึง Cockburn โดยตรงในงานของเขา — Clean Architecture ได้รับแรงบันดาลใจจาก Hexagonal และ Architecture อื่นๆ อีกหลายตัวพร้อมกัน
ในทางปฏิบัติ ทั้งสองแก้ปัญหาเดียวกัน แต่มองคนละแง่
| Hexagonal Architecture | Clean Architecture | |
|---|---|---|
| คำถามที่ตอบ | ”แยก Core ออกจากโลกภายนอกยังไง?" | "ใครขึ้นกับใครใน hierarchy ของ layer?” |
| มุมมองหลัก | Inside vs Outside | วงกลมซ้อนกัน ชี้เข้าใน |
| concept หลัก | Port และ Adapter | Dependency Rule |
| เน้นที่ | วิธีแยก | ลำดับความสำคัญของ layer |
ความต่างอยู่ที่มุมมอง ไม่ใช่เป้าหมาย และในทางปฏิบัติ ใช้ทั้งคู่ร่วมกันได้
Hexagonal Architecture — “ระบบที่เสียบปลั๊กได้”
Analogy ปลั๊กไฟ
ลองนึกถึงโน้ตบุ๊กที่เสียบได้ทั้งปลั๊กไทย ปลั๊กยุโรป และ USB-C — โน้ตบุ๊กไม่รู้ว่าไฟมาจากไหน มันแค่ต้องการไฟฟ้าในมาตรฐานที่ตัวเองรับได้ ส่วนหัวแปลงปลั๊กคือตัวที่จัดการให้มันเข้ากัน
Hexagonal Architecture ออกแบบระบบแบบเดียวกัน
Inside = หัวใจของระบบ (Domain Logic)
Outside = โลกภายนอก (DB, HTTP API, UI, Message Queue)
Port = รูปปลั๊ก — interface ที่กำหนดว่าจะคุยกันแบบไหน
Adapter = หัวแปลง — implementation ที่ทำให้ "ของภายนอก" คุยกับ Core ได้
[ PostgreSQL ] ←→ PrismaAdapter ←→ OrderRepository (Port) ←→ [ Domain Logic ]
[ HTTP API ] ←→ FastifyAdapter ←→ OrderController (Port) ←→ [ Domain Logic ]
[ Unit Test ] ←→ MockAdapter ←→ OrderRepository (Port) ←→ [ Domain Logic ]
Domain Logic ไม่รู้ว่าข้อมูลมาจาก PostgreSQL หรือ mock สิ่งที่มันรู้มีแค่ว่า “ฉันต้องการ OrderRepository ที่มี method findById และ save” ส่วนว่าใครจะมา implement นั้น — ไม่ใช่เรื่องของ Domain
เชื่อมกับ DDD Tactical Design
ใน Prerequisite 2 เราออกแบบ SalesOrderRepository ให้เป็น interface ใน Core โดยไม่รู้จัก Prisma เลย — นั่นคือ Port ในภาษาของ Hexagonal
// Port — อยู่ใน Core ไม่รู้จัก DB เลย
interface SalesOrderRepository {
findById(id: SalesOrderId): Promise<SalesOrder | null>
save(order: SalesOrder): Promise<void>
}
// Secondary Adapter (Driven) — implement Port
// Core เป็นคน drive ออกมาหา DB
class PrismaSalesOrderRepository implements SalesOrderRepository {
constructor(private readonly prisma: PrismaClient) {}
async findById(id: SalesOrderId): Promise<SalesOrder | null> {
const row = await this.prisma.salesOrder.findUnique({ where: { id } })
return row ? toDomainModel(row) : null
}
async save(order: SalesOrder): Promise<void> {
await this.prisma.salesOrder.upsert({ ... })
}
}
Port คือ TypeScript interface ที่นิยามใน Core Adapter คือ implementation ที่อยู่นอก Core โดยแบ่งเป็น 2 ประเภท
- Primary Adapter (Driving) — drive เข้ามาหา Core เช่น HTTP Handler, CLI, Test
- Secondary Adapter (Driven) — ถูก Core drive ออกไป เช่น PrismaRepo, SnsPublisher, MockRepo
มุมของแต่ละบทบาท:
Dev — Port คือ TypeScript interface ที่นิยามใน Core Adapter คือ implementation ที่อยู่นอก Core เปลี่ยน Secondary Adapter ได้โดยไม่แตะ Core เลย ไม่ว่าจะเปลี่ยนจาก Prisma เป็น Drizzle หรือจาก PostgreSQL เป็น MongoDB
EM — เปลี่ยน vendor หรือ database ได้โดยไม่กระทบ Domain Logic ที่ทีมสร้างมา ลด risk และลด cost ของการตัดสินใจเรื่อง tool ในอนาคต เพราะตัดสินใจผิดก็แก้ได้โดยไม่ต้อง rewrite ทั้งระบบ
Clean Architecture — หน้าที่และลำดับความสำคัญของแต่ละ Layer
Analogy ชั้นในอาคาร
ลองนึกถึงตึกสำนักงาน — ผนังกั้นห้อง ตกแต่งภายใน และเฟอร์นิเจอร์ (ชั้นบนสุด) เปลี่ยนได้ทุกปีตามสไตล์ที่ต้องการ แต่โครงสร้างเหล็ก คาน และฐานรากของตึก (ชั้นล่างสุด) ถ้าเปลี่ยนจะกระทบทุกชั้นพร้อมกัน
Clean Architecture ออกแบบ Software ให้มีลำดับชั้นแบบเดียวกัน
Uncle Bob เรียก layer ที่สองจากในว่า “Use Cases” — บทความนี้ใช้คำว่า “Application” เพราะตรงกับชื่อ folder และ package จริงในโค้ดมากกว่า และยังครอบคลุมทั้ง Use Case และ EventConsumerHandler ที่อยู่ใน layer เดียวกัน
กฎเหล็กข้อเดียว: Dependency Rule
“dependency ชี้เข้าในเสมอ”
ชั้นนอกรู้จักชั้นใน แต่ชั้นในไม่รู้จักชั้นนอก Framework รู้จัก Use Case แต่ Use Case ไม่รู้จัก Framework Use Case รู้จัก Domain แต่ Domain ไม่รู้จัก Use Case
ถ้า import ข้ามทิศทาง — architecture พัง
// ✅ ถูกต้อง — Use Case รู้จัก Domain Entity
import type { SalesOrder } from "@/domain/sales-order"
// ✅ ถูกต้อง — Infrastructure รู้จัก Domain Interface
import type { SalesOrderRepository } from "@/domain/repository"
// ❌ ผิด — Domain รู้จัก Prisma
import { PrismaClient } from "@prisma/client" // ← ห้ามอยู่ใน Domain Layer
// ❌ ผิด — Domain รู้จัก Express/Fastify
import type { FastifyRequest } from "fastify" // ← ห้ามอยู่ใน Domain Layer
เชื่อมกับ DDD Tactical Design
Building blocks ที่เรารู้จักจาก Prerequisite 2 วางได้ตามนี้
| Clean Architecture Layer | DDD Building Blocks |
|---|---|
| Entities (Domain) | Entity, Value Object, Domain Service, Repository interface, Domain Event type |
| Application | Use Case, EventConsumerHandler |
| Interface Adapters | ไม่มี DDD Building Block — เป็น mapper ระหว่าง layer (HTTP Handler, Repository impl.) |
| Frameworks & Drivers | ไม่มี DDD Building Block — เป็น third-party tools (Fastify, Prisma, SNS, SQS) |
Interface Adapters — ทำไมถึงไม่มี project boundary ของตัวเอง
Uncle Bob วาด Interface Adapters เป็น layer แยกต่างหาก แต่ในทางปฏิบัติ mapper logic ของมันต้อง depend on Framework หรือ Driver อยู่แล้ว จึงไม่มีเหตุผลให้แยกออกมาเป็น package ของตัวเอง

Interface Adapters แบ่งเป็น 2 ฝั่งตามทิศทางของ flow
ฝั่ง Framework (Presentation) — รับ request จาก Framework แล้ว map เป็น Use Case input
Fastify request → PlaceSalesOrderInput
mapper ต้องรู้จัก FastifyRequest อยู่แล้ว จึงอยู่กับ Framework เสมอ
ฝั่ง Driver (Infrastructure) — รับ Persistence Input หรือ External Service Input แล้ว map ไปเป็น Third-party format ของ Persistence หรือ External Service ในทั้งสองทิศทาง
SaveOrderInput → Prisma row (Persistence)
OrderPlacedEvent → SNS payload (External Service)
mapper ต้องรู้จัก Prisma หรือ SNS SDK อยู่แล้ว จึงอยู่กับ Driver เสมอ
ในทางปฏิบัติ นิยมรวม Interface Adapters เข้ากับ Framework และ Driver ตามฝั่งของมัน

เพราะ mapper แต่ละฝั่งรวมอยู่กับ Framework หรือ Driver อยู่แล้ว ดังนั้นคนส่วนมากนิยมลด 4 layer ของ Uncle Bob เหลือ 3 layer โดยรวม Interface Adapters เข้ากับ Framework และ Driver ตามฝั่งของมัน
| Uncle Bob | ทางปฏิบัติ |
|---|---|
| Framework (Presentation) + Interface Adapters ฝั่ง Presentation | apps/ หรือ infrastructure-http |
| Driver (Infrastructure) + Interface Adapters ฝั่ง Infrastructure | infrastructure-prisma / infrastructure-mongo |
มุมของแต่ละบทบาท:
Dev — Entities และ Application Layer ไม่ import อะไรจาก framework เลย ทดสอบได้ทันทีโดยไม่ต้อง setup database หรือ HTTP server ใดๆ แค่ pass mock dependencies เข้าไปตรงๆ
EM — เปลี่ยน framework หรือ database ได้โดยแก้แค่ชั้นนอกสุด Domain Logic ที่ทีมใช้เวลาสร้างและทดสอบมาไม่ถูกแตะเลย ลด risk ของการตัดสินใจ technical ผิดพลาด
ตารางเปรียบเทียบ — ต่างกันอย่างไร ใช้เมื่อไร เลือกอย่างไร
| มิติ | Hexagonal Architecture | Clean Architecture |
|---|---|---|
| มุมมองหลัก | Inside vs Outside | Layer hierarchy |
| คำถามที่ตอบ | ”แยก Core ออกจากโลกภายนอกยังไง?" | "ใครขึ้นกับใคร และ dependency ไหลทิศทางไหน?“ |
| concept หลัก | Port และ Adapter | Dependency Rule: ชี้เข้าในเสมอ |
| จุดแข็ง | swap implementation ได้ง่าย ทดสอบง่าย | hierarchy ชัดเจน บอกได้ว่าของแต่ละชิ้นควรอยู่ที่ไหน |
| จุดอ่อน | ไม่กำหนด hierarchy ภายใน Core ว่าใครขึ้นกับใคร | ไม่มี concept Port/Adapter ทำให้ swap implementation ได้ยากกว่า |
| Learning Curve | ต่ำ — concept เรียบง่าย | ปานกลาง — ต้องเข้าใจ hierarchy และ dependency |
เลือกอย่างไร — Decision Guide
ใช้ Hexagonal อย่างเดียว เมื่อ ระบบมีขนาดเล็ก layer ไม่ซับซ้อน เป้าหมายหลักคือแยก business logic ออกจาก DB และ HTTP ให้ test ได้ ไม่ได้วางแผนโตเป็น Monolith ขนาดใหญ่หรือแยก Microservice ในอนาคต
ใช้ Clean Architecture อย่างเดียว เมื่อ ต้องการ hierarchy ที่ชัดเจนและ Dependency Rule เป็นหลัก แต่ในทางปฏิบัติ Clean Architecture มักมี interface กั้นระหว่าง layer อยู่แล้ว ซึ่งก็คือ Port/Adapter โดยธรรมชาติ ทำให้เส้นแบ่งระหว่าง “ใช้อย่างเดียว” กับ “ใช้ร่วมกัน” ในทางปฏิบัติแทบไม่มี
ใช้ทั้งคู่ร่วมกัน เมื่อ ต้องการทั้ง hierarchy ที่ชัดเจน (Clean Architecture) และความสามารถ swap implementation ได้ง่าย (Hexagonal) ซึ่งเป็นกรณีที่พบบ่อยที่สุดในระบบขนาดกลางถึงใหญ่ที่มีแผนโต
ในทางปฏิบัติ ทั้งสองไม่ได้แข่งกันแต่เสริมกัน Hexagonal ตอบว่า “แยกยังไง” Clean Architecture ตอบว่า “จัด hierarchy ข้างในยังไง”
ใช้ทั้งคู่ร่วมกัน — ได้อะไรเพิ่ม
Hexagonal บอกว่า “แยก Core ออกจาก Adapter ยังไง” Clean Architecture บอกว่า “จัด hierarchy ข้างใน Core ยังไง” เมื่อใช้ร่วมกัน ได้คำตอบครบทั้งสองด้าน และ DDD ให้ภาษาที่ใช้ตั้งชื่อสิ่งต่างๆ ได้ตรงกับ Business จริงๆ
ทำไม Hexagonal อย่างเดียวไม่พอ
Hexagonal แก้ปัญหา Coupling ได้ดีมาก แต่มีสองคำถามที่ตอบไม่ได้
คำถามที่ 1 — Core ข้างในควรจัดยังไง?
Hexagonal บอกแค่ว่า “ทุกอย่างใน Core” แต่ไม่ได้บอกว่าข้างใน Core ใครขึ้นกับใคร
Core (Hexagonal มองรวมกันหมด)
├── Entity
├── Use Case ← Entity รู้จัก Use Case ได้ไหม?
└── Domain Service หรือ Use Case รู้จัก Entity ได้?
Hexagonal ไม่มีคำตอบ
ถ้าไม่มีกฎ Dev ก็อาจเขียนให้ Entity รู้จัก Use Case ได้โดยไม่มีอะไรป้องกัน เพราะ compiler ไม่โวย — ทั้งสองอยู่ใน Core เหมือนกัน
// ✅ compiler ผ่าน — ไม่มี circular
// ❌ แต่ dependency ไหลผิดทิศทาง
// domain/sales-order.ts
import { PlaceSalesOrderUseCase } from "../application/place-order"
class SalesOrder {
place(useCase: PlaceSalesOrderUseCase): void { ... }
// Entity ชั้นในสุดกลับรู้จัก Use Case ชั้นนอก
}
ผลที่ตามมาคือ Entity ที่มี Business Logic เช่น Invariant หรือ Business Rule ซึ่งปกติ test ได้ง่ายมากเพราะเป็น pure function กลับ test ได้ยากขึ้นโดยไม่จำเป็น เพราะต้อง mock Use Case ก่อนทั้งที่ Business Logic ไม่ได้เกี่ยวกับ Use Case เลย และถ้าเปลี่ยน Use Case ทีไร Entity พังตามทุกครั้ง ทั้งที่ Business Rule ไม่ได้เปลี่ยน
คำถามที่ 2 — Port ควรอยู่ที่ไหนใน Core?
Hexagonal บอกว่า Port เป็นส่วนหนึ่งของ Core แต่ไม่ได้บอกว่าควรอยู่กับ Entity หรืออยู่กับ Use Case
// ❓ แบบ A — Port อยู่กับ Entity?
core/
├── sales-order.ts ← Entity
├── sales-order-repo.ts ← Port อยู่ติดกับ Entity
└── place-order.ts ← Use Case
// ❓ แบบ B — Port อยู่กับ Use Case?
core/
├── domain/
│ └── sales-order.ts ← Entity
└── application/
├── place-order.ts ← Use Case
└── sales-order-repo.ts ← Port อยู่กับ Use Case แทน
ทั้งสองแบบอยู่ใน Core เหมือนกัน Hexagonal บอกไม่ได้ว่าแบบไหนถูก
Clean Architecture เข้ามาตอบทั้งสองคำถามด้วย Dependency Rule
Entities / Domain (ในสุด) ← ไม่รู้จักใคร
↑
Application ← รู้จัก Domain ได้ ไม่รู้จัก Infrastructure
↑
Infrastructure ← implement Port ที่ Domain กำหนด
คำถามที่ 1 — Entity ไม่รู้จัก Use Case เพราะ dependency ชี้เข้าใน Use Case รู้จัก Entity ได้ ไม่ใช่กลับกัน
คำถามที่ 2 — Repository Interface ควรอยู่ใน Domain Layer เพราะ Domain เป็นเจ้าของ contract ของตัวเอง Domain กำหนดว่าต้องการข้อมูลแบบไหน ไม่ใช่ Application บอก Domain ถ้าวาง Repository Interface ไว้ใน Application Layer แล้ว Domain Service ต้องการใช้จะเกิด Domain → Application dependency ซึ่งผิด Dependency Rule
// ✅ Repository Interface อยู่ใน Domain Layer
// Domain เป็นเจ้าของ contract — กำหนดว่าต้องการข้อมูลแบบไหน
domain/
├── sales-order.ts ← Entity + Business Rules
└── repositories/
└── sales-order-repo.ts ← Repository Interface อยู่ที่นี่
// ✅ Use Case อยู่ใน Application Layer
// depend Domain Interface ไม่ depend Infrastructure โดยตรง
application/
└── place-sales-order/
└── place-sales-order.ts ← Use Case รับ ISalesOrderRepository จาก Domain
// ✅ Infrastructure implement Repository Interface จาก Domain
infrastructure/
└── prisma-sales-order.repository.ts ← implement ISalesOrderRepository
Use Case Input/Output Port ใน Backend Service (REST API)
Use Case Output Port ออกแบบมาสำหรับระบบที่มี UI เช่น MVC app ที่ต้องแยก ViewModel ออกจาก Domain Model ใน Backend Service ที่ไม่มี UI — Use Case แค่ return
Result<T, Error>กลับมา HTTP Handler รับแล้วแปลงเป็น HTTP response เอง ไม่จำเป็นต้องมี Output Port// Backend Service — Use Case return Result ตรงๆ ไม่ต้องมี Output Port const placeSalesOrder = async ( deps: Deps, input: PlaceSalesOrderInput ): Promise<Result<SalesOrder, PlaceSalesOrderError>> => { ... } // HTTP Handler รับ Result แล้วจัดการ HTTP response เอง const handler = async (req, reply) => { const result = await placeSalesOrder(deps, req.body) if (result.isErr()) return reply.status(422).send({ error: result.error }) return reply.status(201).send(result.value) }
เมื่อใช้ทั้งคู่ร่วมกัน Hexagonal บอกว่า “แยก Core ออกจาก Adapter ยังไง” Clean Architecture บอกว่า “จัด hierarchy ข้างใน Core ยังไง” ได้คำตอบครบทั้งสองด้าน
จาก Tactical Design Building Blocks สู่ Layer จริง
ใน Prerequisite 2 เราออกแบบ Building Blocks ทั้งหมดโดยไม่พูดถึง folder structure เลย — รู้แค่ว่า Repository ต้องเป็น interface และ Application Service เป็นตัวประสาน Use Case แต่ยังไม่รู้ว่าแต่ละอันควรอยู่ใน folder ไหน และเหตุผลว่าทำไม
สิ่งเดียวกัน แต่ละ Architecture มองต่างมุม ได้คำตอบต่างกัน แต่ไม่ขัดกัน
Hexagonal Architecture มอง Building Blocks ยังไง
| Tactical Design Building Block | Core หรือ Adapter | ประเภท |
|---|---|---|
| Entity, Value Object, Aggregate | Core (Inside) | — |
| Domain Service | Core (Inside) | — |
| Repository interface | Core (Inside) | Driven Port |
| Domain Event type | Core (Inside) | — |
| Application Service (Use Case) | Core (Inside) | — |
| Application Service (EventConsumerHandler) | Core (Inside) | — |
| HTTP Handler / Controller | Outside | Primary Adapter (Driving) |
| Repository implementation | Outside | Secondary Adapter (Driven) |
ทำไม HTTP Handler ถึงไม่มี Driving Port
Primary Adapter บางตัวมี Driving Port เช่น
EventConsumerClient interfaceที่ประกาศไว้ในshared-kernelเพื่อให้ Outside implement และ drive event เข้ามาแต่
HTTP Handlerไม่มี Port เพราะ Core ไม่รู้จักและไม่ต้องรู้จักว่ามีใคร drive เข้ามาทาง HTTP HTTP Handler เป็นคน import Application Service โดยตรง รู้ input/output type ของ Use Case เอง เปลี่ยน Framework ได้โดยแค่เขียน Handler ใหม่ Core ไม่กระทบเลย
EventPublisher, EventConsumerClient interface เป็น shared-kernel ระดับ Global ที่ใช้ข้าม Bounded Context ไม่ได้เป็น DDD Tactical Design Building Block ของ BC ใด ดูรายละเอียดได้ที่ note “shared-kernel” ใน Project Structure section
Clean Architecture มอง Building Blocks ยังไง
| Tactical Design Building Block | Layer | เหตุผล |
|---|---|---|
| Entity, Value Object | Domain | กฎและโครงสร้างข้อมูลของ Business — ไม่รู้จัก DB หรือ Framework เลย |
| Aggregate | Domain | Consistency boundary — enforce Invariant ก่อน save |
| Domain Service | Domain | Pure logic ที่ไม่มี I/O — test ได้โดยไม่ต้อง mock อะไร |
| Repository interface | Domain | Domain กำหนด contract เอง ไม่รอให้ Infrastructure กำหนดให้ |
| Domain Event type | Domain | Domain Event เป็น Business fact ของ Domain Model (DDD) — Clean Architecture วางไว้ใน Domain Layer เพราะเป็น concept ของ Business ไม่รู้จักใครเลย |
| Application Service (Use Case) | Application | รู้จัก Repository แต่ไม่รู้จัก Framework หรือ HTTP |
| Application Service (EventConsumerHandler) | Application | transform DomainEvent → UseCaseInput → call Use Case |
| Repository implementation | Infrastructure | implement Repository interface ด้วย Prisma, MongoDB, etc. |
| HTTP Handler / Controller | Infrastructure | Primary Adapter — แปลง HTTP request → Use Case input |
EventPublisher, EventConsumerClient interface เป็น shared-kernel ระดับ Global ที่ใช้ข้าม Bounded Context ไม่ได้เป็น DDD Tactical Design Building Block ของ BC ใด ดูรายละเอียดได้ที่ note “shared-kernel” ใน Project Structure section
Domain Event — Building Block เดียวที่ span ทุก Layer
Domain Event type เป็น Building Block ของ Domain Model (DDD) ที่ Clean Architecture วางไว้ใน Domain Layer แต่ flow ของมันข้ามทุก layer ทำให้เห็น Dependency Rule ของ Clean Architecture ชัดที่สุด
Domain Layer:
type SalesOrderPlaced = { ← นิยาม event type
readonly type: "SalesOrderPlaced"
readonly orderId: SalesOrderId
readonly totalAmount: Money
readonly placedAt: Date
}
interface EventPublisherClient { ← Port — ไม่รู้จัก SNS หรือ RabbitMQ เลย
publish(event: DomainEvent): Promise<void>
}
Application Layer:
// Application Service publish หลัง save สำเร็จ
await deps.orderRepo.save(confirmedOrder)
await deps.eventPublisher.publish({ ← ผ่าน Port เสมอ
type: "SalesOrderPlaced",
orderId: confirmedOrder.id,
totalAmount: confirmedOrder.totalAmount,
placedAt: new Date(),
})
Infrastructure Layer — Adapters (เปลี่ยนได้โดยไม่แตะ Domain เลย):
├── InMemoryEventPublisherClient implements EventPublisherClient ← Monolith / dev / test
├── RabbitMQEventPublisherClient implements EventPublisherClient ← Microservice on-premise
└── SnsEventPublisherClient implements EventPublisherClient ← AWS Cloud
SNS = publish event ออก (fan-out ไปหลาย subscriber)
SQS = subscribe รับ event เข้า (queue กันหาย)
ตอนรัน Monolith → inject InMemoryEventPublisherClient
ตอนแยก Microservice บน AWS → inject SnsEventPublisherClient
แก้แค่ Composition Root บรรทัดเดียว Domain Logic ไม่แตะเลย
กฎสำคัญ: Domain Service vs Application Service (แนวคิดจาก DDD)
นี่คือจุดที่ทีมส่วนใหญ่เริ่มสับสนหลังจากรู้จัก Clean Architecture แล้ว เพราะทั้งสองอยู่ใน Domain/Application Layer เหมือนกัน แต่มีบทบาทต่างกันโดยสิ้นเชิง
กฎง่ายๆ สำหรับแยกสองอย่างนี้
Domain Service = รับ Data เข้า → คืน Data ออก
ไม่มี Repository หรือ ServiceClient
ไม่มี async/await
Application Svc = รับ Repository หรือ ServiceClient เข้า
orchestrate flow ทั้งหมด
มี async/await
ดูเหมือนง่าย แต่ข้อผิดพลาดที่เจอบ่อยที่สุดคือการส่ง Repository เข้า Domain Service
Domain Service ห้ามรับ Repository หรือ ServiceClient
แม้ Repository จะเป็นแค่ interface ไม่ใช่ Prisma โดยตรง แต่การที่ function รับ Repository เข้ามาหมายความว่ามันรู้ว่ามี I/O อยู่เบื้องหลัง — มันไม่ใช่ Domain Service อีกต่อไป แต่กลายเป็น Application Service ทันที
// ❌ ดูเหมือน Domain Service แต่ไม่ใช่
// เพราะรับ Repository เข้ามา = รู้ว่ามี I/O (Side Effect)
const validateCreditLimit = async (
repo: CustomerRepository, // ← I/O อยู่ที่นี่
order: SalesOrder
): Promise<Result<void, string>> => {
const customer = await repo.findById(order.customerId) // ← side effect
if (order.totalAmount.amount > customer.creditLimit.amount)
return Err("ยอดสั่งซื้อเกิน credit limit")
return Ok(undefined)
}
// ✅ Domain Service ที่ถูกต้อง — รับ Data เข้า คืน Data ออก ไม่มี I/O เลย
const validateCreditLimit = (
customer: Customer, // ← caller ดึงมาให้แล้ว ไม่ใช่ repo
order: SalesOrder
): Result<void, string> => {
if (order.totalAmount.amount > customer.creditLimit.amount)
return Err("ยอดสั่งซื้อเกิน credit limit")
return Ok(undefined)
}
หน้าที่ของ Application Service คือดึงข้อมูลมาก่อน แล้วค่อยส่งให้ Domain Service ทำงาน
// Application Service เป็นคนประสาน — ดึงข้อมูลแล้วส่งให้ Domain Service
const placeSalesOrder = async (deps: Deps, input: Input) => {
const customer = await deps.customerRepo.findById(input.customerId) // ← I/O อยู่ที่นี่
if (!customer) return Err({ code: "CUSTOMER_NOT_FOUND" })
// ส่ง data ที่ดึงมาแล้วเข้า Domain Service — ไม่ส่ง repo
const creditResult = validateCreditLimit(customer, input.order) // ← pure
if (creditResult.isErr()) return Err({ code: "CREDIT_LIMIT_EXCEEDED" })
// ...
}
Domain Service ต้องอยู่ภายใน BC เดียวกันเท่านั้น
✅ Domain Service ภายใน Sales BC
validateCreditLimit(customer, order)
→ ทั้ง Customer และ SalesOrder อยู่ใน Sales BC
❌ ข้าม BC — ไม่ใช่ Domain Service อีกต่อไป
checkStockAvailability(order, stockLevel)
→ SalesOrder อยู่ใน Sales BC
→ StockLevel อยู่ใน Stock BC
ถ้า logic ต้องรู้จัก model ของ BC อื่น ให้ใช้ Application Service orchestrate ผ่าน ServiceClient แทน
”No Side Effect” มาจากสามมุมที่เห็นตรงกัน
ทำไม Domain Service ต้องเป็น pure function? เพราะสามแนวคิดที่เราเรียนมาทั้งหมดเห็นพ้องกันในจุดนี้ แต่มาจากคนละมุม
DDD → Domain Logic ไม่ควรผูกกับ Infrastructure
(Prerequisite 2: Repository เป็นแค่ interface ใน Domain)
Architecture → Domain Logic ไม่รู้จัก I/O
(Hexagonal: Core ไม่รู้จัก Adapter)
(Clean Arch: inner layer ไม่ import outer layer)
Functional → Domain Logic เป็น pure function ไม่มี side effect
ทั้งสามเห็นตรงกันว่า: Domain Logic ควรไม่มี side effect
แต่มาคนละมุม
DDD + Architecture = กฎที่ต้องทำ
Functional = วิธีที่แนะนำในการ implement
ผลลัพธ์คือสิ่งเดียวกัน — Domain Service ที่ test ได้โดยไม่ต้อง mock อะไรเลย ไม่ว่าจะ approach แบบไหน
สิ่งที่ “ห้ามเปลี่ยน” vs “เปลี่ยนได้เสมอ”
นี่คือประเด็นที่ EM ควรเข้าใจให้ชัดที่สุด เพราะเกี่ยวโดยตรงกับการตัดสินใจเรื่อง vendor, tool และ technical direction
ห้ามเปลี่ยน (ถ้าไม่จำเป็น)
สิ่งที่อยู่ใน Domain Layer และ Application Layer คือสิ่งที่ทีมลงทุนสร้างและทดสอบมามากที่สุด
- Domain Logic ทั้งหมด
← DDD— Business Rule, Invariant, Value Object ที่ผ่านการ validate แล้ว ถ้าเปลี่ยน Business ได้รับผลกระทบโดยตรง - Use Case ที่สะท้อนความต้องการจริงของ Business
← DDD + Clean Architecture—PlaceSalesOrder,ConfirmSalesOrder,ValidateCreditLimitชื่อเหล่านี้มาจาก Ubiquitous Language ไม่ควรเปลี่ยนเพราะ technical reason - Repository interface
← Hexagonal + Clean Architecture— interface อยู่ใน Domain ถ้าเปลี่ยนหมายความว่า contract ของ Domain เปลี่ยน กระทบทุก Adapter ที่ implement
เปลี่ยนได้เสมอ
สิ่งที่อยู่ชั้นนอก เป็น Adapter ที่เปลี่ยนแทนได้โดยไม่กระทบ Domain
- Framework
← Hexagonal (Primary Adapter)— Fastify → Hono, Express → Elysia แก้แค่adapters-http/หรือinfrastructure-http/ - Database
← Hexagonal (Secondary Adapter)— PostgreSQL → MongoDB, Prisma → Drizzle แก้แค่adapters-persistence-*/หรือinfrastructure-*/ - Message Queue
← Hexagonal (Secondary/Primary Adapter)— SNS/SQS → RabbitMQ แก้แค่ messaging adapter package ใน shared-kernel - Protocol
← Clean Architecture (Outer Layer)— REST → GraphQL → tRPC เพิ่ม Adapter ใหม่ไม่แตะ Use Case
Scenario จริง
ถ้าวันหนึ่ง Sales Context ต้องย้ายจาก REST API เป็น GraphQL เพราะ Frontend team ต้องการ
ในระบบที่ออกแบบดี:
สิ่งที่ต้องแก้:
✅ เพิ่ม GraphQL Adapter ใหม่
✅ map GraphQL mutation → Use Case input
สิ่งที่ไม่ต้องแก้เลย:
✅ placeSalesOrder use case — ทำงานเหมือนเดิม
✅ validateCreditLimit domain service — ไม่รู้ว่า protocol เปลี่ยน
✅ SalesOrderRepository interface — ไม่ต้องแตะ
✅ domain types ทั้งหมด — ไม่ต้องแตะ
ในระบบที่ไม่ได้ออกแบบ:
สิ่งที่ต้องแก้:
❌ แก้ use case function เพราะผูกกับ req/res ของ Fastify
❌ แก้ validation logic เพราะผูกกับ request body format
❌ แก้ error handling เพราะ format ต่างกัน
❌ ... และยังไม่รู้ว่าพังที่ไหนอีก
ความต่างนี้ไม่ใช่แค่เรื่อง code quality มันคือความต่างระหว่าง “เพิ่มฟีเจอร์ใน 2 วัน” กับ “refactor 3 สัปดาห์ก่อนเพิ่มได้”
Project Structure — Package Layout ทั้งสอง Architecture
ก่อนดูโค้ด สิ่งสำคัญที่ต้องเห็นก่อนคือ ทั้ง Hexagonal และ Clean Architecture ใช้แบบ Multi-Package (แบบ A) เพราะเป็นวิธีเดียวที่ enforce Dependency Rule ได้จริงด้วย tool ไม่ใช่แค่ convention
แต่ละ package มี package.json ของตัวเอง ทำให้ package ชั้นในไม่มีทางรู้จัก package ชั้นนอกได้เลยในทางเทคนิค ถ้าพยายาม import จะ error ทันทีตอน build
- Hexagonal —
core/ไม่มีทางรู้จักadapters-persistence-prisma/หรือadapters-messaging-sns/ได้ เพราะไม่ได้ประกาศไว้ในdepsของcore/package.json - Clean Architecture —
domain/ไม่มีทางรู้จักinfrastructure-prisma/หรือinfrastructure-http/ได้ และapplication/ก็ไม่มีทางรู้จักinfrastructure-*/เช่นกัน
shared-kernel — package กลางสำหรับ interface ที่ใช้ข้าม Bounded Context
EventPublisherClient interfaceและEventConsumerClient interfaceไม่ได้ผูกกับ Bounded Context ใด ทุก BC ใช้ contract เดียวกัน จึงควรแยกออกมาเป็น package กลางpackages/ ├── shared-kernel/ ← @system/shared-kernel │ ├── package.json deps: {} ← ไม่มี third-party เลย │ ├── event-publisher-client.ts ← EventPublisherClient interface │ └── event-consumer-client.ts ← EventConsumerClient interface │ ├── sales/core/ หรือ sales/domain/ │ └── package.json peerDeps: { @system/shared-kernel } │ └── procurement/core/ หรือ procurement/domain/ └── package.json peerDeps: { @system/shared-kernel }Messaging Adapter packages (Hexagonal)
packages/ ├── adapters-messaging-sns/ ← @system/adapters-messaging-sns │ ├── package.json peerDeps: { @system/shared-kernel, @aws-sdk/client-sns } │ └── sns-event-publisher-client.ts ← implements EventPublisherClient │ ├── adapters-messaging-sqs/ ← @system/adapters-messaging-sqs │ ├── package.json peerDeps: { @system/shared-kernel, @aws-sdk/client-sqs } │ └── sqs-event-consumer-client.ts ← implements EventConsumerClient │ ├── adapters-messaging-rabbitmq/ ← @system/adapters-messaging-rabbitmq │ ├── package.json peerDeps: { @system/shared-kernel, amqplib } │ └── rabbitmq-event-publisher-client.ts │ └── adapters-messaging-inmemory/ ← @system/adapters-messaging-inmemory ├── package.json peerDeps: { @system/shared-kernel } └── inmemory-event-publisher-client.ts ← ใช้ตอน dev/test หรือ MonolithMessaging Infrastructure packages (Clean Architecture)
packages/ ├── infrastructure-sns/ ← @system/infrastructure-sns │ ├── package.json peerDeps: { @system/shared-kernel, @aws-sdk/client-sns } │ └── sns-event-publisher-client.ts ← implements EventPublisherClient │ ├── infrastructure-sqs/ ← @system/infrastructure-sqs │ ├── package.json peerDeps: { @system/shared-kernel, @aws-sdk/client-sqs } │ └── sqs-event-consumer-client.ts ← implements EventConsumerClient │ ├── infrastructure-rabbitmq/ ← @system/infrastructure-rabbitmq │ ├── package.json peerDeps: { @system/shared-kernel, amqplib } │ └── rabbitmq-event-publisher-client.ts │ └── infrastructure-inmemory/ ← @system/infrastructure-inmemory ├── package.json peerDeps: { @system/shared-kernel } └── inmemory-event-publisher-client.ts ← ใช้ตอน dev/test หรือ Monolithdiagram ด้านล่างแสดงแค่ BC เดียว โดยสมมติว่า
EventPublisherClientและEventConsumerClientinterface มาจากshared-kernelและ messaging adapters อยู่แยกต่างหากตามที่แสดงด้านบน
ทำไมไม่ใช้ Single Package (แบบ B)?
Single package คือ libs ทั้งหมด —
domain/,application/,infrastructure-*/— รวมอยู่ในpackage.jsonเดียว ปัญหาคือไม่มีอะไรป้องกันdomain/ให้ import จากinfrastructure-prisma/หรือinfrastructure-http/ได้เลย ต้องพึ่งแค่ convention และ linting ซึ่งไม่เพียงพอสำหรับระบบที่โต นอกจากนี้ถ้าinfrastructure-*/ทุกตัวอยู่ใน package เดียวกัน dependency ของทุก infrastructure จะถูกติดตั้งพร้อมกันหมด แม้แต่adapters-messaging-sns/หรือadapters-messaging-rabbitmq/ที่อาจใช้แค่ตัวเดียว
Pure Hexagonal Architecture — Package Layout
จัดตาม Inside (Core) vs Outside (Adapters) แต่ละ Adapter package ติดตั้งเฉพาะ third-party ที่ตัวเองต้องการ
sales-management/
│
├── packages/
│ │
│ ├── core/ ← @sales/core (Inside the Hexagon)
│ │ ├── package.json peerDeps: { @system/shared-kernel }
│ │ ├── domain/ ← Entity, Value Object, Aggregate
│ │ │ ├── sales-order/
│ │ │ └── value-objects/
│ │ │ └── money.ts
│ │ ├── application/
│ │ │ ├── use-cases/
│ │ │ │ └── place-sales-order/
│ │ │ └── event-consumer-handlers/ ← transform DomainEvent → UseCaseInput
│ │ │ └── on-order-shipped/
│ │ └── ports/
│ │ └── driven/ ← Driven Ports ที่ Core เรียกออกไป
│ │ ├── sales-order.repository.ts
│ │ └── customer.repository.ts
│ │
│ ├── adapters-http/ ← @sales/adapters-http (Primary Adapter)
│ │ ├── package.json deps: { @sales/core }
│ │ │ peerDeps: { fastify, zod }
│ │ └── place-sales-order.handler.ts
│ │
│ ├── adapters-persistence-prisma/ ← @sales/adapters-persistence-prisma
│ │ ├── package.json deps: { @sales/core }
│ │ │ peerDeps: { @prisma/client }
│ │ ├── prisma-sales-order.repository.ts
│ │ └── prisma-customer.repository.ts
│ │
│ └── adapters-persistence-mongo/ ← @sales/adapters-persistence-mongo
│ ├── package.json deps: { @sales/core }
│ │ peerDeps: { mongodb }
│ └── mongo-sales-order.repository.ts
│
└── apps/ ← Composition Roots (ติดตั้ง third-party จริงๆ ที่นี่)
│
├── api/ ← HTTP Server
│ └── package.json deps: { @sales/core, adapters-http,
│ adapters-persistence-prisma,
│ @system/adapters-messaging-sns,
│ @system/shared-kernel,
│ fastify, zod, @prisma/client,
│ @aws-sdk/client-sns }
│
├── consumer/ ← Consumer — consume message จาก SQS
│ └── package.json deps: { @sales/core,
│ @system/adapters-messaging-sqs,
│ adapters-persistence-prisma,
│ @system/shared-kernel,
│ @aws-sdk/client-sqs,
│ @prisma/client }
│ ← ไม่มี fastify เลย
│
├── scheduler/ ← Scheduler — cron job, periodic task
│ └── package.json deps: { @sales/core,
│ adapters-persistence-prisma,
│ @prisma/client }
│
└── cli/ ← CLI — admin tool, migration, seeding
└── package.json deps: { @sales/core,
adapters-persistence-prisma,
@prisma/client }
สังเกต:
packages/ทำหน้าที่เป็น libs ใช้peerDependenciesสำหรับ third-party เพื่อให้apps/เป็นคนติดตั้งจริงๆ ทำให้แต่ละ app ติดตั้งเฉพาะที่ตัวเองต้องการapps/consumer/ไม่มีfastifyเลยแม้จะ dependadapters-http/
ทำไม local packages ใช้
dependenciesแต่ third-party ใช้peerDependencieslocal packages เช่น
@sales/coreใช้dependenciesเพราะ workspace manager (npm/yarn/pnpm workspaces) symlink ให้อัตโนมัติ ไม่มีการ install ซ้ำ ไม่ว่าจะมีกี่ package depend@sales/coreก็ใช้ไฟล์เดียวกันทั้งหมดthird-party เช่น
fastify,@prisma/clientใช้peerDependenciesเพราะให้apps/เป็นคนเลือกว่าจะติดตั้ง version ไหน และป้องกันการ bundle library ซ้ำในกรณีที่หลาย adapter depend third-party เดียวกัน
Clean Architecture — Package Layout
จัดตาม Dependency Rule ของ Clean Architecture — Interface Adapters ไม่มี package boundary ของตัวเอง mapper อยู่กับ Framework หรือ Driver ที่มัน depend on เสมอ ตามที่อธิบายไว้ใน section ข้างต้น
sales-management/
│
├── packages/
│ │
│ ├── domain/ ← @sales/domain (Entities Layer — ชั้นในสุด)
│ │ ├── package.json deps: {} ← ไม่มี third-party เลย
│ │ ├── sales-order/ ← Entity, Value Object, Aggregate
│ │ │ ├── sales-order.type.ts
│ │ │ ├── sales-order.ts
│ │ │ └── index.ts
│ │ ├── value-objects/
│ │ │ └── money.ts
│ │ ├── services/ ← Domain Service: pure functions
│ │ │ └── validate-credit-limit.ts
│ │ └── repositories/ ← Repository interfaces
│ │ ├── sales-order.repository.ts
│ │ └── customer.repository.ts
│ │
│ ├── application/ ← @sales/application (Application Layer)
│ │ ├── package.json deps: { @sales/domain } ← local package
│ │ │ peerDeps: { @system/shared-kernel }
│ │ ├── use-cases/
│ │ │ └── place-sales-order/
│ │ │ ├── place-sales-order.type.ts
│ │ │ ├── place-sales-order.ts
│ │ │ └── index.ts
│ │ └── event-consumer-handlers/ ← transform DomainEvent → UseCaseInput
│ │ └── on-order-shipped/
│ │ ├── on-order-shipped.type.ts
│ │ ├── on-order-shipped.ts
│ │ └── index.ts
│ │
│ ├── infrastructure-prisma/ ← @sales/infrastructure-prisma
│ │ ├── package.json deps: { @sales/domain }
│ │ │ peerDeps: { @prisma/client }
│ │ ├── prisma-sales-order.repository.ts
│ │ └── prisma-customer.repository.ts
│ │
│ ├── infrastructure-mongo/ ← @sales/infrastructure-mongo
│ │ ├── package.json deps: { @sales/domain }
│ │ │ peerDeps: { mongodb }
│ │ └── mongo-sales-order.repository.ts
│ │
│ └── infrastructure-http/ ← @sales/infrastructure-http
│ ├── package.json deps: { @sales/domain, @sales/application }
│ │ peerDeps: { fastify, zod }
│ └── place-sales-order.handler.ts
│
└── apps/ ← Composition Roots (ติดตั้ง third-party จริงๆ ที่นี่)
│
├── api/ ← HTTP Server
│ └── package.json deps: { @sales/application,
│ infrastructure-http,
│ infrastructure-prisma,
│ @system/infrastructure-sns,
│ @system/shared-kernel,
│ fastify, zod, @prisma/client,
│ @aws-sdk/client-sns }
│
├── consumer/ ← Consumer — consume message จาก SQS
│ └── package.json deps: { @sales/application,
│ @system/infrastructure-sqs,
│ infrastructure-prisma,
│ @system/shared-kernel,
│ @aws-sdk/client-sqs,
│ @prisma/client }
│ ← ไม่มี fastify เลย
│
├── scheduler/ ← Scheduler — cron job, periodic task
│ └── package.json deps: { @sales/application,
│ infrastructure-prisma,
│ @prisma/client }
│
└── cli/ ← CLI — admin tool, migration, seeding
└── package.json deps: { @sales/application,
infrastructure-prisma,
@prisma/client }
ทำไม Repository Interface ถึงอยู่ใน
domain/ไม่ใช่application/Domain เป็นเจ้าของ contract ของตัวเอง — Domain กำหนดว่าต้องการข้อมูลแบบไหน ไม่ใช่ Application บอก Domain ถ้าวาง Repository Interface ไว้ใน Application แล้ว Domain Service ต้องการใช้ Repository จะเกิด Domain → Application dependency ซึ่งผิด Dependency Rule ชั้นในสุดต้องไม่รู้จักชั้นนอก
สรุปกฎสำคัญของ Package Layout
กฎ third-party ของแต่ละ Project Boundary
| Project Boundary | Hexagonal | Clean Architecture | เพราะ |
|---|---|---|---|
core/ / domain/ | ❌ ห้ามทุกกรณี | ❌ ห้ามทุกกรณี | ต้องอิสระจาก Technology ทั้งหมด Business Rule ไม่ควรขึ้นกับ library ใดๆ |
core/ (application part) / application/ | ❌ ห้ามทุกกรณี | ⚠️ ได้บางตัว เช่น neverthrow | Clean Architecture แยก layer ชัดกว่า Hexagonal จึง strict กว่าในส่วนนี้ |
adapters-*/ / infrastructure-*/ | ✅ peerDeps | ✅ peerDeps | Adapter รู้จัก Technology ให้ apps/ เป็นคนติดตั้งจริง |
apps/ | ✅ deps | ✅ deps | Composition Root ติดตั้ง third-party ทุกตัวที่ใช้จริง |
Input Validation vs Domain Validation — อยู่คนละ Layer
มี Validation 2 ประเภทที่ต่างกันโดยสิ้นเชิง
Domain Validation (Invariant) — อยู่ใน domain/ หรือ core/
เขียนด้วย pure TypeScript + Result<T, E> ไม่ต้องการ library ใดๆ
// domain/ หรือ core/ — pure TypeScript ไม่มี Zod
const createSalesOrder = (input: ...): Result<SalesOrder, "EMPTY_ITEMS"> => {
if (input.items.length === 0) return Err("EMPTY_ITEMS")
return Ok({ ... })
}
Input Validation (DTO Validation) — อยู่ใน infrastructure-http/ หรือ adapters-http/
ตรวจว่า HTTP request body ถูกรูปแบบไหม ก่อนส่ง typed input เข้า Use Case
library เช่น zod เหมาะที่นี่ เพราะเป็น Framework-specific concern
// infrastructure-http/ หรือ adapters-http/ — Zod อยู่ที่นี่
const PlaceSalesOrderSchema = z.object({
customerId: z.string().uuid(),
items: z.array(z.object({ ... })).min(1),
})
const handler = async (req, reply) => {
const parsed = PlaceSalesOrderSchema.safeParse(req.body)
if (!parsed.success)
return reply.status(400).send({ error: parsed.error })
// ส่ง typed input เข้า Use Case — Use Case ไม่รู้จัก Zod เลย
const result = await placeSalesOrder(deps, parsed.data)
...
}
infrastructure-http แยก package vs วางใน apps/api โดยตรง
HTTP Handler และ Mapper ที่แปลง HTTP Request → Use Case Input เป็น Framework-specific code ล้วนๆ ไม่สามารถ reuse ข้าม Framework ได้ จึงมีสองทางเลือก
แบบที่นิยมกว่า — วาง Handler ไว้ใน apps/api เลย
เหมาะกับกรณีทั่วไปที่แต่ละ app ใช้ handler ของตัวเอง ไม่มีการ share ข้าม app ลด complexity ของ package structure ลงได้
apps/api/
├── routes/
│ └── place-sales-order.handler.ts ← อยู่ที่นี่เลย
└── main.ts
แบบที่แยก infrastructure-http — เมื่อต้องการ share handler ข้าม app
เหมาะเมื่อมี app หลายตัวที่ใช้ handler เดิมร่วมกัน เช่น apps/api-v1 และ apps/api-v2
apps/api-v1/ ← ใช้ handler จาก infrastructure-http
apps/api-v2/ ← ใช้ handler เดิม + เพิ่มใหม่
ตัวอย่าง Implementation
บทความนี้ focus ที่ concept และ Package Layout ของทั้งสอง Architecture การดู implementation จริงๆ แยกไว้เป็น Bonus articles ให้เลือกตาม language และ style ที่ทีมใช้
| Bonus | ภาษา | Style | เหมาะกับ |
|---|---|---|---|
| Bonus 1 | TypeScript | OOP | ทีมที่มาจาก Java/C# หรือคุ้นเคยกับ class-based pattern |
| Bonus 2 | TypeScript | Functional | ทีมที่เริ่มใหม่หรือต้องการ testability สูงสุด — style เดียวกับ Series Scale-Ready |
| Bonus 3 | C# | OOP | ทีม .NET ที่ต้องการนำ DDD + Clean + Hexagonal ไปใช้ |
| Bonus 4 | C# | Functional | ทีม .NET ที่ต้องการ immutability และ Result type |
บทความถัดไป
ตอนนี้ Foundation ครบแล้วทั้งสาม Prerequisite
- Prerequisite 1 — รู้จัก Domain, Bounded Context, Ubiquitous Language และ Context Mapping
- Prerequisite 2 — รู้จัก Building Blocks ของ DDD: Entity, Value Object, Aggregate, Repository, Domain Service, Application Service และ Domain Event
- Prerequisite 3 — รู้ว่า Building Blocks แต่ละตัววางอยู่ใน layer ไหน ทำไม Port ต้องอยู่ใน Domain และทำไม Infrastructure ถึงเปลี่ยนได้โดยไม่แตะ Domain Logic
สิ่งที่ยังไม่รู้คือ — ในระบบจริงที่ต้องเริ่มเป็น Monolith แล้วโตเป็น Microservice ได้ตามต้องการ โครงสร้าง project ควรเป็นอย่างไร? Port และ Adapter กระจายอยู่ใน package ไหน? และใครควร depend ใคร ในระดับ package ไม่ใช่แค่ layer?
นั่นคือจุดที่ Hexagonal Architecture และ Clean Architecture จะแสดงออกมาจริงๆ ในรูปของ share-core / share-data / share-service / app-{name} บน Nx Monorepo ที่ enforce Dependency Rule ผ่าน exports field ไม่ใช่แค่ convention และนั่นคือสิ่งที่ Series Scale-Ready Architecture จะเริ่มต้นใน Part 1
→ Scale-Ready Architecture Part 1: หลักการออกแบบระบบที่ไม่ต้องเลือกระหว่างความเร็วกับความยืดหยุ่น
Glossary — คำศัพท์ที่ใช้ในบทความนี้
| คำ | ความหมาย |
|---|---|
| Building Block | ”ชิ้นส่วนพื้นฐาน” ที่นำมาประกอบกันเพื่อออกแบบ Domain Model ตาม DDD Tactical Design ได้แก่ Entity, Value Object, Aggregate, Repository, Domain Service, Application Service และ Domain Event |
| Port | interface ที่ Core ประกาศไว้เพื่อสื่อสารกับโลกภายนอก — Core รู้จัก Port แต่ไม่รู้จัก Adapter |
| Adapter | implementation ที่อยู่นอก Core ทำหน้าที่แปลงระหว่าง Core กับ Technology จริง เช่น PrismaRepository, SnsEventPublisher |
| Driving Port | Port ที่ Outside ใช้เรียกเข้ามาหา Core เช่น EventConsumerClient interface |
| Driven Port | Port ที่ Core ใช้เรียกออกไปหา Outside เช่น Repository interface, EventPublisherClient interface |
| Dependency Rule | กฎของ Clean Architecture ว่า dependency ต้องชี้เข้าในเสมอ — ชั้นนอกรู้จักชั้นใน แต่ชั้นในไม่รู้จักชั้นนอก |
| Composition Root | จุดเดียวในระบบที่ wire dependencies ทั้งหมดเข้าด้วยกัน — ใน Series นี้คือ apps/ |
| Orchestration | การประสาน flow ของ Use Case (Business Operation) หนึ่งๆ — ดึงข้อมูลจาก Repository, เรียก Domain Service, คุยกับ Service อื่นผ่าน Client, แล้ว save ผลลัพธ์กลับ หน้าที่หลักของ Application Service |
| shared-kernel | package กลางที่เก็บ contract ที่ใช้ข้าม Bounded Context เช่น EventPublisherClient และ EventConsumerClient interface |
FAQ
Q: Hexagonal กับ Clean Architecture แก้ปัญหาเดียวกัน แล้วทำไมถึงต้องมีสองอัน?
เพราะมองปัญหาคนละแง่
- Hexagonal ถามว่า “ทำยังไงให้ Core ไม่รู้จักโลกภายนอก?” แล้วตอบด้วย Port และ Adapter
- Clean Architecture ถามว่า “ถ้ามีหลาย layer ใครขึ้นกับใคร?” แล้วตอบด้วย Dependency Rule
ในทางปฏิบัติ ทั้งสองอธิบาย structure เดียวกันจากคนละมุมและใช้ร่วมกันได้สมบูรณ์ เหมือนการอธิบายอาคารว่า “มีภายในและภายนอก” กับ “มีชั้นล่างและชั้นบน” — ทั้งสองถูก ทั้งสองอธิบายอาคารเดียวกัน
Q: ถ้าใช้ Clean Architecture แล้ว ยังต้องใช้ Hexagonal อีกไหม?
ไม่จำเป็นต้องรู้สึกว่า “ใช้” ทั้งสอง เพราะถ้า follow Clean Architecture อย่างเคร่งครัด ก็ได้ Hexagonal structure มาฟรีอยู่แล้ว
- Dependency Rule บังคับให้ interface อยู่ชั้นใน → ตรงกับ Port concept ของ Hexagonal
- infrastructure-http คือ Primary Adapter (Driving) ในภาษา Hexagonal
- infrastructure-prisma คือ Secondary Adapter (Driven) ในภาษา Hexagonal
Hexagonal มีประโยชน์ตรงที่ให้ภาษาที่ชัดขึ้นในการอธิบายทิศทางของ Adapter ว่าใคร drive ใคร
Q: Port กับ Interface ใน TypeScript คือสิ่งเดียวกันไหม?
ในทางปฏิบัติ ใช่ครับ
- Port = TypeScript
interfaceที่นิยามไว้ใน Domain Layer - Adapter =
classหรือfunctionที่implementsinterface นั้นใน Infrastructure
แต่ Port ในแง่ conceptual นั้นกว้างกว่าเล็กน้อย — มันหมายถึง “สัญญาว่าจะคุยกันแบบไหน” ไม่ใช่แค่ syntax ของ TypeScript
Q: Interface Adapters ใน Uncle Bob กับ infrastructure-* ใน codebase คือสิ่งเดียวกันไหม?
ไม่ตรงกันทั้งหมดครับ Uncle Bob วาด Interface Adapters เป็น layer แยก แต่ในทางปฏิบัติ mapper logic ของมันต้อง depend on Framework หรือ Driver อยู่แล้ว จึงไม่มีเหตุผลให้แยกออกมาเป็น package ของตัวเอง
mapper แต่ละฝั่งจึงอยู่กับ package ที่มัน depend on
| Uncle Bob | ทางปฏิบัติ |
|---|---|
| Framework (Presentation) + Interface Adapters ฝั่ง Presentation | apps/ หรือ infrastructure-http |
| Driver (Infrastructure) + Interface Adapters ฝั่ง Infrastructure | infrastructure-prisma / infrastructure-mongo |
Q: shared-kernel คืออะไร และต่างจาก domain/ ยังไง?
domain/core/ → Business Logic ของ BC นั้นๆ
เช่น SalesOrder, Customer, กฎของ Sales
shared-kernel → contract ที่ใช้ข้าม BC (ไม่มี Business Logic)
เช่น EventPublisherClient, EventConsumerClient interface
domain/ เป็นเจ้าของ Business Logic ของ BC ตัวเอง ส่วน shared-kernel เป็น package กลางที่ทุก BC ใช้ร่วมกัน ไม่ผูกกับ BC ใดเลย
Q: ทำไม third-party ใน packages/ ถึงใช้ peerDependencies แทน dependencies?
peerDependencies บอกว่า “ฉันต้องการ library นี้ แต่ให้ apps/ เป็นคนติดตั้งเอง” ทำให้
- ทุก package ใช้ version เดียวกัน ไม่มี bundle ซ้ำ
apps/เป็น single source of truth ของทุก dependency จริงๆ
adapters-http/ peerDeps: { fastify, zod } ← แจ้งว่าต้องการ
apps/api/ deps: { fastify, zod } ← ติดตั้งจริงที่นี่
Q: ทีมเล็ก 3-5 คน จำเป็นต้องใช้ Architecture พวกนี้ไหม?
ความสามารถในการ test โดยไม่ต้องมี DB จริงมีประโยชน์ตั้งแต่วันแรก ไม่ว่าทีมจะมีกี่คน
- Prototype ที่ไม่แน่ใจว่าจะ production — ไม่ต้องยุ่ง
- Product จริงที่มีแผนโต — ลงทุนแยก Repository interface ตั้งแต่ต้นถูกกว่า refactor ทีหลังมาก concept นี้ไม่ได้เพิ่ม complexity มาก แต่ให้ประโยชน์ชัดเจนตั้งแต่เรื่อง test
Q: ข้อผิดพลาดที่พบบ่อยที่สุดเมื่อเริ่ม implement คืออะไร?
ข้อแรก — วาง Repository interface ผิดที่
หลายทีมวาง SalesOrderRepository ไว้ใน Infrastructure folder แทนที่จะอยู่ใน Domain ทำให้ Use Case ต้อง import จาก Infrastructure — dependency ชี้ออก ผิด Dependency Rule Domain ควรเป็น owner ของ interface เสมอ
ข้อสอง — ทำ Composition Root กระจัดกระจาย
หลายทีม inject dependency ใน handler โดยตรง บางทีก็ inject ใน middleware บางทีก็ inject ใน function ที่เรียกกัน ทำให้หา root ของ dependency graph ไม่เจอ กฎง่ายๆ คือ wire ทุกอย่างที่ composition-root.ts หรือ app.ts ที่เดียว ที่อื่น receive ผ่าน parameter เท่านั้น