Prerequisite 3: Clean Architecture + Hexagonal Architecture — แปลง Tactical Design เป็น Code Architecture

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 จนแยกไม่ออก

Coupling Problem — Domain Logic ผูกกับ Infrastructure vs แยกออกจากกัน

และมีสองคนที่นั่งคิดวิธีแก้มันในช่วงเวลาต่างกัน คนละมุม แต่เป้าหมายเดียวกัน


ที่มา — “สองคนเจ็บปวดแบบเดียวกัน คิดทางออกคนละแบบ”

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 ArchitectureClean Architecture
คำถามที่ตอบ”แยก Core ออกจากโลกภายนอกยังไง?""ใครขึ้นกับใครใน hierarchy ของ layer?”
มุมมองหลักInside vs Outsideวงกลมซ้อนกัน ชี้เข้าใน
concept หลักPort และ AdapterDependency 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

Hexagonal Architecture — Core, Port, Primary Adapter และ Secondary Adapter

มุมของแต่ละบทบาท:

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 เดียวกัน

Clean Architecture — 4 ชั้น dependency ชี้เข้าในเสมอ

กฎเหล็กข้อเดียว: 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 LayerDDD Building Blocks
Entities (Domain)Entity, Value Object, Domain Service, Repository interface, Domain Event type
ApplicationUse 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 ของตัวเอง

Uncle Bob — Clean Architecture original diagram

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 ตามฝั่งของมัน

Clean Architecture — interpretation ที่นิยมใช้ ลด 4 layer เหลือ 3

เพราะ mapper แต่ละฝั่งรวมอยู่กับ Framework หรือ Driver อยู่แล้ว ดังนั้นคนส่วนมากนิยมลด 4 layer ของ Uncle Bob เหลือ 3 layer โดยรวม Interface Adapters เข้ากับ Framework และ Driver ตามฝั่งของมัน

Uncle Bobทางปฏิบัติ
Framework (Presentation) + Interface Adapters ฝั่ง Presentationapps/ หรือ infrastructure-http
Driver (Infrastructure) + Interface Adapters ฝั่ง Infrastructureinfrastructure-prisma / infrastructure-mongo

มุมของแต่ละบทบาท:

Dev — Entities และ Application Layer ไม่ import อะไรจาก framework เลย ทดสอบได้ทันทีโดยไม่ต้อง setup database หรือ HTTP server ใดๆ แค่ pass mock dependencies เข้าไปตรงๆ

EM — เปลี่ยน framework หรือ database ได้โดยแก้แค่ชั้นนอกสุด Domain Logic ที่ทีมใช้เวลาสร้างและทดสอบมาไม่ถูกแตะเลย ลด risk ของการตัดสินใจ technical ผิดพลาด


ตารางเปรียบเทียบ — ต่างกันอย่างไร ใช้เมื่อไร เลือกอย่างไร

มิติHexagonal ArchitectureClean Architecture
มุมมองหลักInside vs OutsideLayer hierarchy
คำถามที่ตอบ”แยก Core ออกจากโลกภายนอกยังไง?""ใครขึ้นกับใคร และ dependency ไหลทิศทางไหน?“
concept หลักPort และ AdapterDependency 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 ยังไง” ได้คำตอบครบทั้งสองด้าน

Hexagonal + Clean Architecture — ใช้ทั้งคู่ร่วมกัน


จาก 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 BlockCore หรือ Adapterประเภท
Entity, Value Object, AggregateCore (Inside)
Domain ServiceCore (Inside)
Repository interfaceCore (Inside)Driven Port
Domain Event typeCore (Inside)
Application Service (Use Case)Core (Inside)
Application Service (EventConsumerHandler)Core (Inside)
HTTP Handler / ControllerOutsidePrimary Adapter (Driving)
Repository implementationOutsideSecondary 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 BlockLayerเหตุผล
Entity, Value ObjectDomainกฎและโครงสร้างข้อมูลของ Business — ไม่รู้จัก DB หรือ Framework เลย
AggregateDomainConsistency boundary — enforce Invariant ก่อน save
Domain ServiceDomainPure logic ที่ไม่มี I/O — test ได้โดยไม่ต้อง mock อะไร
Repository interfaceDomainDomain กำหนด contract เอง ไม่รอให้ Infrastructure กำหนดให้
Domain Event typeDomainDomain Event เป็น Business fact ของ Domain Model (DDD) — Clean Architecture วางไว้ใน Domain Layer เพราะเป็น concept ของ Business ไม่รู้จักใครเลย
Application Service (Use Case)Applicationรู้จัก Repository แต่ไม่รู้จัก Framework หรือ HTTP
Application Service (EventConsumerHandler)Applicationtransform DomainEvent → UseCaseInput → call Use Case
Repository implementationInfrastructureimplement Repository interface ด้วย Prisma, MongoDB, etc.
HTTP Handler / ControllerInfrastructurePrimary 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 Event — flow ข้าม Domain, Application, Infrastructure Layer

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 ArchitecturePlaceSalesOrder, 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

  • Hexagonalcore/ ไม่มีทางรู้จัก adapters-persistence-prisma/ หรือ adapters-messaging-sns/ ได้ เพราะไม่ได้ประกาศไว้ใน deps ของ core/package.json
  • Clean Architecturedomain/ ไม่มีทางรู้จัก 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 หรือ Monolith

Messaging 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 หรือ Monolith

diagram ด้านล่างแสดงแค่ BC เดียว โดยสมมติว่า EventPublisherClient และ EventConsumerClient interface มาจาก 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 เลยแม้จะ depend adapters-http/

ทำไม local packages ใช้ dependencies แต่ third-party ใช้ peerDependencies

local 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 BoundaryHexagonalClean Architectureเพราะ
core/ / domain/❌ ห้ามทุกกรณี❌ ห้ามทุกกรณีต้องอิสระจาก Technology ทั้งหมด Business Rule ไม่ควรขึ้นกับ library ใดๆ
core/ (application part) / application/❌ ห้ามทุกกรณี⚠️ ได้บางตัว เช่น neverthrowClean Architecture แยก layer ชัดกว่า Hexagonal จึง strict กว่าในส่วนนี้
adapters-*/ / infrastructure-*/✅ peerDeps✅ peerDepsAdapter รู้จัก Technology ให้ apps/ เป็นคนติดตั้งจริง
apps/✅ deps✅ depsComposition 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 1TypeScriptOOPทีมที่มาจาก Java/C# หรือคุ้นเคยกับ class-based pattern
Bonus 2TypeScriptFunctionalทีมที่เริ่มใหม่หรือต้องการ testability สูงสุด — style เดียวกับ Series Scale-Ready
Bonus 3C#OOPทีม .NET ที่ต้องการนำ DDD + Clean + Hexagonal ไปใช้
Bonus 4C#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
Portinterface ที่ Core ประกาศไว้เพื่อสื่อสารกับโลกภายนอก — Core รู้จัก Port แต่ไม่รู้จัก Adapter
Adapterimplementation ที่อยู่นอก Core ทำหน้าที่แปลงระหว่าง Core กับ Technology จริง เช่น PrismaRepository, SnsEventPublisher
Driving PortPort ที่ Outside ใช้เรียกเข้ามาหา Core เช่น EventConsumerClient interface
Driven PortPort ที่ 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-kernelpackage กลางที่เก็บ 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 ที่ implements interface นั้นใน 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 ฝั่ง Presentationapps/ หรือ infrastructure-http
Driver (Infrastructure) + Interface Adapters ฝั่ง Infrastructureinfrastructure-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 เท่านั้น

Supawut Thomas

Supawut Thomas

Software Developer

มีประสบการณ์พัฒนา Software ระดับ Enterprise มากกว่า 10 ปี ผ่านงานจริงหลากหลายโปรเจกต์องค์กร — เชื่อว่าความรู้ที่ดีที่สุดคือความรู้ที่มาจากประสบการณ์จริง และอยากแบ่งปันสิ่งเหล่านั้นให้เพื่อน Developer ทุกคนได้นำไปพัฒนาตัวเองได้ดีขึ้นในทุกๆ วัน