ข้าม​ไป​ยัง​เนื้อหา

Commands: Application Service สำหรับ​ประมวล​ผล Business Use Case

ประโยชน์​ของ DDDDomain-Driven Design (DDD)แนวทาง​พัฒนา​ซอฟต์แวร์​ที่​เน้น​สร้าง domain model ซึ่ง​เข้าใจ​กฎ​และ​กระบวนการ​ของ​ธุรกิจ​อย่าง​ลึกซึ้ง แล้ว​เขียน​ลง​ใน code จริง บัญญัติ​โดย Eric Evans (2003) แบ่ง​เป็น Strategic และ Tactical DesignStrategic Design หลาย​อย่าง​เกิด​จาก​การ​ใช้ Ubiquitous LanguageUbiquitous Languageภาษา​กลาง​ที่​ทุก​คนใน​ทีม (ทั้ง​นัก​พัฒนา​และ​ผู้เชี่ยวชาญ​ธุรกิจ) ใช้​ร่วม​กัน อิง​กับ domain model โดยตรง คำ​เดียวกัน​ต้อง​หมาย​ถึง​สิ่ง​เดียวกัน และ​สะท้อน​ลง​ใน codeStrategic Design อย่าง​มี​วินัย — ทั้ง​ใน​การ​สนทนา​และ​ใน code แต่​ความ​ท้าทาย​ข้อ​ใหญ่​คือ จะ รักษา​ความ​ชัดเจน​ของ​แนวคิด​ทาง​ธุรกิจ​ใน code ได้​อย่างไร ใน​เมื่อ​โลก​จริง​เต็ม​ไป​ด้วย​เรื่อง​ทาง​เทคนิค​อย่าง thread, socket, การ​เชื่อม​ต่อ​ฐาน​ข้อมูล ลอง​นึก​ภาพ​ตอน​คุณ​นั่ง​ทำ knowledge-crunching กับ domain expert แล้ว​ต้อง​อธิบาย domain model ให้​เขา​ฟัง — คุณ​คง​ไม่​อยาก​ให้​บทสนทนา​เต็ม​ไป​ด้วย​เรื่อง connection pool หรือ retry policy

หน้าที่​หนึ่ง​ของ Application ServiceApplication Serviceบริการ​ที่ “ประสาน​งาน” use case — จัดการ transaction, ความ​ปลอดภัย, การ​บันทึก, แปลง DTO — แต่​ไม่มี​กฎ​ธุรกิจ​อยู่​ใน​ตัว​มัน​เองTactical Design จึง​เป็นการ ขีด​เส้น​แบ่ง ระหว่าง​แนวคิด​ทาง​ธุรกิจ​ที่ “บริสุทธิ์” กับ​เรื่อง​ทาง​เทคนิค​ที่​หลีก​เลี่ยง​ไม่​ได้ บท​นี้​จะ​พา​คุณ​ดู​ว่า application service ทำ​อะไร​บ้าง ต่าง​จาก Domain ServiceDomain Serviceบริการ​ที่​ถือ “ตรรกะ​ธุรกิจ” ซึ่ง​ไม่​เข้า​กับ Entity หรือ Value Object ตัว​ใด​ตัว​หนึ่ง​โดย​ธรรมชาติ เช่น การ​โอน​เงิน​ที่​เกี่ยวข้อง​กับ​สอง​บัญชี — ไร้​สถานะ (stateless)Tactical Design อย่างไร และ​มี design pattern อะไร​บ้าง​ที่​ช่วย​ให้ service layer ไม่​ขยาย​ตัว​จน​เกิน​ควบคุม

รูปที่ 25-1: ตำแหน่งของ Application Service — นั่งอยู่ในชั้น Application Layer เหนือ Domain Layer ทำหน้าที่รับ user input จากชั้น UI ประสานงานกับ domain model และสื่อสารข้าม Bounded Context ผ่าน HTTP/REST/Message Queue

ใน​เชิง​ตรรกะ application service layer วางตัว​อยู่ เหนือ domain และ พึ่งพา domain ดังนั้น​ความ​รับผิดชอบ​สำคัญ​ของ​มัน​คือ ประสาน​งาน​กับ domain เพื่อ​ทำ business use case ให้​ครบ​ทั้ง​กระบวนการ ระหว่าง​ทาง​มัน​ต้อง​แปลง input/output เพื่อ​ปกป้อง​โครงสร้าง​ภายใน​ของ domain และ​มัก​ต้อง​คุย​กับ Bounded ContextBounded Contextขอบเขต​ที่​ชัดเจน (มัก​เป็น​ระบบ​ย่อย​หรือ​ทีม​หนึ่ง) ที่ model หนึ่ง​ใช้ได้​และ​มี​ความหมาย​แน่นอน ภายใน​ขอบเขต​นี้ Ubiquitous Language สอดคล้อง​กัน 100%Strategic Design อื่น​ผ่าน REST, messaging และอื่นๆ ตาม​ที่​เรา​เรียน​ไป​ใน Part II


ถ้า​อยาก​ได้ model ที่​ขับ​เน้น​แนวคิด​ทาง​ธุรกิจ​และ​กัน​มัน​ออก​จาก​รายละเอียด​ทาง​เทคนิค คุณ​ต้อง​เข้าใจความ​ต่าง​ระหว่าง application logic กับ domain logic ให้​ชัด ส่วน​ใหญ่​แล้ว​ไม่​ยาก แม้​จะ​มี​พื้นที่​สี​เทา​อยู่​บ้าง

ให้​คิด​ง่ายๆ ว่า application service มีหน้าที่​หลัก​สอง​อย่าง:

  1. จัดการ​เรื่อง infrastructure — เปิด/ปิด transaction, ส่ง​อีเมล, เขียน log และ​งาน​ทาง​เทคนิค​ทำนอง​นี้
  2. ประสาน​งาน​กับ domain เพื่อ​ทำ business use case ให้​สำเร็จ​ทั้ง​กระบวนการ

ส่วน​ที่ เพิ่ม​คุณค่า​ทางธุรกิจจริงๆ คือ​ข้อ 2 ส่วน​ข้อ 1 มี​ไว้​เพื่อ “รองรับ” ให้​ข้อ 2 ทำงาน​ได้​เท่านั้น

กฎ​เหล็ก​ข้อ​เดียว​ที่​ต้อง​จำ

ห้าม​มี​กฎ​ธุรกิจ (business rule) อยู่​ใน application service — application service แค่ “สั่ง” domain ให้​ทำงาน แล้ว​จัดการ​เรื่อง​เทคนิครอบๆ เท่านั้น ทันที​ที่​คุณ​เริ่ม​เขียน if ที่​ตัดสิน​ใจ​เชิง​ธุรกิจ​ใน service นั่น​คือ​สัญญาณ​ว่า​มี​แนวคิด​ทาง domain รั่ว​ออก​มา​แล้ว

มา​ดู​ตัวอย่าง​ที่​คุ้น​เคย​กัน เรา​จะ​ใช้ use case “แนะนำ​เพื่อน (Recommend-a-Friend)” บน​ร้าน e-commerce: ลูกค้า​เก่า​ที่​ชวน​เพื่อน​มาสมัครสำเร็จ​จะ​ได้​เครดิต 50 บาท เพื่อน​ที่​สมัคร​ก็ได้ 50 บาท และ​ผู้​แนะนำ​จะ​ถูก​อัปเกรด​เป็น​สมาชิก​ระดับ​ทอง

รูปที่ 25-2: Use case แนะนำเพื่อน — app ต้องสร้างบัญชีให้เพื่อน ตั้งยอดเริ่มต้น ให้เครดิตผู้แนะนำ แล้วส่งอีเมลยืนยันให้ทั้งสองฝ่าย ทุกขั้นตอนนี้คือสิ่งที่ application service ต้องประสานให้ครบ


เพื่อ​จะ​เรียก​ใช้ domain model คุณ​ต้อง​ทำงาน​ทาง​เทคนิค​หลาย​อย่าง​ก่อน ถ้า​แยก code พวก​นี้​ออก​ได้​ดี domain model จะ​สะอาด​และ​ดูแล​ง่าย​ขึ้น​มาก มา​ดู​ที​ละ​เรื่อง

การ validate ระดับ app คือ​การ​ตรวจ รูปแบบ​ทาง​เทคนิค เช่น ชนิด​ข้อมูล​ถูก​ไหม ความ​ยาว​เกิน​ไหม รูปแบบ​อีเมล​ถูก​ไหม — สิ่ง​เหล่า​นี้ ไม่ใช่​กฎ​ธุรกิจ ที่ domain expert สนใจ แต่​ก็​ทำให้​ระบบ​พัง​ได้ จึง​ควร​ตรวจ​ที่ application service แทนที่​จะ​ไป​ปะปน​อยู่​ใน domain

RecommendAFriendService.ts
class RecommendAFriendService {
recommendAFriend(referrerId: string, friend: NewAccount): void {
// validation ทางเทคนิค ทำที่ระดับ application
this.validate(friend);
// ... ประสานงานกับ domain ต่อ
}
private validate(account: NewAccount): void {
if (!account.email.includes("@"))
throw new ValidationFailure("อีเมลไม่ถูกต้อง");
if (account.email.length >= 50)
throw new ValidationFailure("อีเมลต้องสั้นกว่า 50 ตัวอักษร");
if (!account.nickname?.trim())
throw new ValidationFailure("ต้องระบุชื่อเล่น");
}
}

ทุก​เงื่อนไข​ใน validate() ตรวจ​รายละเอียด​ทาง​เทคนิค​ล้วนๆ ไม่มี​อัน​ไหน​เป็น​กฎ​ธุรกิจ และ​ไม่มี​อัน​ไหน​ควร​อยู่​ใน domain

ใน use case ทั่วไป​มัก​มี​หลาย​การกระทำ​ที่​ต้อง สำเร็จ​หรือ​ล้มเหลว​พร้อม​กัน ภายใน transaction เดียว การ​จัดการ transaction ที่ application service ทำให้​คุณ​ควบคุม​ได้​ว่าการ​กระทำ​ใด​บ้าง​ที่​อยู่​ใน​ขอบเขต transaction เดียวกัน

รูปที่ 25-3: ขอบเขต transaction ของ use case แนะนำเพื่อน — การสร้างบัญชี ตั้งยอด และให้เครดิต อยู่ในขอบเขตเดียวกัน (ถ้าอันใดล้มเหลวต้อง roll back ทั้งหมด) ส่วนการส่งอีเมลอยู่ “นอก” ขอบเขต เพราะส่งอีเมลแล้วเรียกคืนไม่ได้

อย่า​เอา side-effect ที่​ย้อน​กลับ​ไม่​ได้​ไว้​ใน transaction

สังเกต​จาก​รูป​ที่ 25-3 ว่าการ ส่ง​อีเมล ถูกวาง​ไว้ “นอก” ขอบเขต transaction โดย​ตั้งใจ เพราะ​ถ้า transaction roll back แต่​อีเมล​ถูก​ส่ง​ไป​แล้ว คุณ​เรียกคืน​ไม่​ได้ การกระทำ​ที่​มี side-effect ถาวร​และ​ย้อน​กลับ​ไม่​ได้ (ส่ง​อีเมล, ยิง webhook, เรียก API ภายนอก) ควร​เกิด หลัง commit สำเร็จ​เสมอ

ไม่ใช่​ทุก​การ​คุย​กับ domain จะ​สำเร็จ — บาง​ครั้ง domain จะ​โยน exception หรือ​คืน error code ออก​มา (กรณี domain validation ไม่​ผ่าน แม้ application validation จะ​ผ่าน​แล้ว) หน้าที่​ของ application service คือ ดัก​จับ error เหล่า​นี้​แล้ว​แปล​ให้​อยู่​ใน​รูป​ที่​เหมาะ​กับ​โลก​ภายนอก เพื่อ​ไม่​ให้​ผู้​เรียก (จะ​เป็น​คน​หรือ​ระบบ​อื่น) ผูก​ติด​กับ​โครงสร้าง error ภายใน​ของ domain

RecommendAFriendService.ts
recommendAFriend(referrerId: string, friend: NewAccount): void {
this.validate(friend);
try {
this.runInTransaction(() => {
// ... คุยกับ domain หลายครั้ง
});
} catch (e: unknown) {
// แปลง domain exception ให้เป็น ApplicationError ที่เสถียร
if (e instanceof ReferralRejectedDueToOutstandingBalance) {
throw new ApplicationError(
"ขออภัย ไม่สามารถทำรายการแนะนำเพื่อนได้ เนื่องจากบัญชีผู้แนะนำมียอดค้างชำระ"
);
}
throw e;
}
}

การ​มี convention กลาง​อย่าง ApplicationError ทำให้​คุณ​จัดการ error ได้​สม่ำเสมอ เช่น ใน web framework คุณ​สร้าง error filter หนึ่ง​ตัว​ที่​ดัก​เฉพาะ ApplicationError แล้ว​แสดง​ข้อความ​ให้​ผู้​ใช้​เห็น​ได้​อย่าง​ปลอดภัย ส่วน exception ชนิด​อื่น (ที่​ยัง​ไม่​แน่ใจ​ว่า​ปลอดภัย​พอ​จะ​เปิดเผย​หรือ​ไม่) จะ​ตอบ​กลับ​ด้วย error ทั่วไป​แทน​เพื่อ​ความ​ปลอดภัย

🛒 ทำไม error translation ถึง​สำคัญ​ใน e-commerce

สมมติ domain โยน ReferralRejectedDueToOutstandingBalance ออก​มา ถ้า​คุณ​ปล่อย​ให้ frontend หรือ partner API จับ exception ตัว​นี้ตรงๆ วัน​หนึ่ง​ที่​คุณ refactor ชื่อ exception ภายใน domain ผู้​เรียก​ทุก​คน​ก็​พัง​ตาม​ไป​ด้วย การ​แปล​ให้​เป็น ApplicationError ที่​เสถียร​คือ​การ​สร้าง “สัญญา” ที่​ทน​ต่อ​การ​เปลี่ยนแปลง​ภายใน — ผู้​เรียก​ผูก​กับ interface ที่​นิ่ง ไม่ใช่​กับ​ความ​ผันผวน​ของ domain

ข้อมูล​อย่าง response time, จำนวน error และ​ตัว​นับ​ต่างๆ ช่วย​ให้​คุณ​เห็น​ว่า​ระบบ​ทำงาน​เป็น​อย่างไร​และ​ตรวจ​จับ​ปัญหา​ได้​ตั้งแต่​เนิ่นๆ แต่​ถ้า​เขียน​ปะปน​เข้าไป​ใน domain logic จะ​ทำให้​แนวคิด​สำคัญ​ทาง​ธุรกิจ​ถูก​บดบัง คุณ​จึง​ควร​พึ่ง application service ให้​บันทึก​ข้อมูล​พวก​นี้​แทน — logger.debug(), logger.error(), การ​นับ metric — ทั้งหมด​ดึง​ขึ้น​มา​ไว้​ที่ application service แทนที่​จะ​ปล่อย​ให้​ปน​ใน domain

อีก​เรื่อง infrastructure ที่​เลี่ยง​ยาก​คือ​ความ​ปลอดภัย — authentication (ผู้​ใช้​เป็น​ใคร) และ authorization (ผู้​ใช้​มี​สิทธิ์​ทำ​สิ่ง​นี้​ไหม) ทั้ง​คู่ควร​ถูก​ตรวจ​ที่ application service ก่อน​จะ​แตะ domain

AdminRecommendAFriendService.ts
class AdminRecommendAFriendService {
recommendAFriend(referrerId: string, friendId: string): void {
if (!this.auth.isLoggedIn())
throw new AuthenticationError();
if (!this.auth.isCurrentUserAdmin())
throw new AuthorizationError();
// ผ่านด่านความปลอดภัยแล้วค่อยใช้ referral policy ใน domain
}
}
authorization ไม่ใช่​แค่​ของ​ตกแต่ง

หนังสือ​ยก​ตัวอย่าง​จาก​เหตุการณ์​จริง​ว่า เมื่อ​ระบบ​พนัน (gambling) ออนไลน์​ไม่มี authorization ผู้​ใช้​ทั่วไป​สามารถ “เดา URL” ของ admin endpoint เพื่อ​เติม​เครดิต​ฟรี​ให้​ตัวเอง​ได้​ไม่​จำกัด — และ​มัน​เกิด​ขึ้น​จริง​มา​แล้ว การ​ตรวจ​สิทธิ์​ที่ application service จึง​เป็น​ด่าน​ป้องกัน​ที่​ขาด​ไม่​ได้​สำหรับ app ที่​มี​ผู้​ใช้​หลาย​ระดับ​สิทธิ์

เหตุการณ์​ที่​เกิด​ใน context หนึ่ง​อาจ trigger การ​ทำงาน​ใน context อื่น เป็น​หน้าที่​ของ application service ที่​จะ ส่ง​ต่อ event ข้าม context — ผ่าน message bus, atom feed, REST หรือ RPC เป้าหมาย​เหมือน​เดิม​คือ แยก​แนวคิด domain ออก​จาก​เรื่อง infrastructure เช่น หลัง referral สำเร็จ application service จะ publish ข้อความ CustomerRegisteredViaReferralPolicy ขึ้น bus ให้ context อื่น (เช่น การ​ตลาด หรือ การ​แจ้ง​เตือน) มา​รับ​ไป​ทำงาน​ต่อ

🚢 Application Service ใน​ระบบ​ขนส่ง​สินค้า

ใน Cargo Shipping เมื่อ​พนักงาน​ท่าเรือ​บันทึก Handling EventHandling Eventการ​บันทึก​การ​จัดการ​สินค้า​จริง (LOAD, UNLOAD, RECEIVE, CLAIM) เป็น Aggregate “แยก​ต่างหาก” จาก Cargo เพราะ​มี​ปริมาณ​มาก​และ​ต้อง​ประมวล​ผล​แบบ asyncTactical Design (เช่น LOAD สินค้า​ขึ้น​เรือ) HandlingEventService จะ: (1) validate ว่า Tracking IDTracking IDรหัส​ติดตาม​ที่​ไม่​ซ้ำ​ของ Cargo ใน​ตัวอย่าง​การ​ขนส่ง ใช้​เป็น identity ของ Cargo ตั้งแต่​ตอน​จอง​จน​ส่ง​มอบTactical Design มี​อยู่​จริง (2) เปิด transaction (3) เรียก Factory สร้าง handling event แล้ว​บันทึก​ผ่าน RepositoryRepositoryตัว​ที่​ทำให้​เรา​เข้าถึง Aggregate ราวกับ​เป็น collection ใน​หน่วย​ความ​จำ ซ่อน​รายละเอียด​ฐาน​ข้อมูล มี1 repository ต่อ1 Aggregate RootTactical Design (4) commit แล้ว​จึง publish Integration EventIntegration Eventevent ที่​สื่อสาร “ข้าม” Bounded Context หรือ​ข้าม microservice ผ่าน event bus — ต่าง​จาก Domain Event ที่​ทำงาน​ภายใน context เดียว (in-process)Architecture ให้ context “ติดตาม​สถานะ” ไป​อัปเดต — ทุก​ขั้น​นี้​คือ​งาน infrastructure ล้วน ส่วน​การ​ตัดสิน​ว่า event ทำให้ ItineraryItineraryValue Object ที่​เป็น “แผนการ​เดินทาง​จริง” ของ Cargo ประกอบ​ด้วย​ลำดับ​ของ Leg (ช่วง​การ​ขนส่ง​แต่ละ​ช่วง)Tactical Design ผิด​แผน​หรือ​ไม่ เป็น​กฎ​ธุรกิจ​ที่​อยู่​ใน domain


ส่วน​ที่ DDD practitioner หลาย​คน​สับสน​คือ code ประสาน​งาน (coordination) — เพราะ​มัน​ดูเหมือน​จะ​ขัด​กับ​หลัก “ห้าม​มี​กฎ​ธุรกิจ​ใน application service” ลอง​ดู use case แนะนำ​เพื่อน: หา​ผู้​แนะนำ → สร้าง​บัญชี​เพื่อน → ใช้ referral policy บาง​คน​อาจ​เถียง​ว่า​ลำดับ​นี้​ควร​อยู่​ใน domain

วิธี​ตัดสิน​คือ​ถาม​ตัวเอง​ว่า “ขั้นตอน​นี้​ต้อง​เกิด​ด้วย​กัน​เสมอ​ไหม? แยก​ออก​จาก​กัน​ไม่​ได้​จริง​หรือ?”

  • ถ้า ต้อง​เกิด​ด้วย​กัน​เสมอ แยก​ไม่​ได้ → นั่น​คือ domain policy ควร​ห่อ​ไว้​ใน Domain ServiceDomain Serviceบริการ​ที่​ถือ “ตรรกะ​ธุรกิจ” ซึ่ง​ไม่​เข้า​กับ Entity หรือ Value Object ตัว​ใด​ตัว​หนึ่ง​โดย​ธรรมชาติ เช่น การ​โอน​เงิน​ที่​เกี่ยวข้อง​กับ​สอง​บัญชี — ไร้​สถานะ (stateless)Tactical Design
  • ถ้า สลับ​สับเปลี่ยน/รวม​กัน​ได้​หลาย​แบบ → น่า​จะ​เป็น application logic

ใน​ตัวอย่าง​นี้ ตาม​ปกติ referral policy จะ​ใช้​กับ​ลูกค้า​เก่า (ผู้​แนะนำ) คู่​กับ​ลูกค้า​ใหม่ (เพื่อน) เสมอ — ฟัง​ดูเหมือน​เป็น domain แต่​บาง​ครั้ง​ธุรกิจ​ก็​อยาก​ใช้ policy กับ​สอง​บัญชี​ที่​มี​อยู่​แล้ว (เช่น กรณี​เกิด error ตอน​สมัคร) ความ​ยืดหยุ่น​ตรง​นี้​เอง​ที่​บอกว่า​มัน​เป็น application logic

สัญญาณ​ว่า​คุณ​ไม่​ปล่อย​ให้ domain รั่ว

ทุก​ครั้ง​ที่ application service คุย​กับ domain การ​เรียก​นั้น​ควร expressive — เป็น API ระดับ​สูง​ที่​อ่าน​แล้ว​เข้าใจ​เจตนา เช่น referralPolicy.apply(referrer, friend) ถ้า​ตรรกะ​ข้าง​ใน apply() (ให้​เครดิต 50 บาท​ทั้ง​สอง​ฝั่ง + อัปเกรด​เป็น​สมาชิก​ระดับ​ทอง) ถูก​แกะ​ออก​มากระจาย​เขียน​ใน application service แทน นั่น​คือ​คือ​สัญญาณ​ชัด​ว่า​มี​แนวคิด domain รั่ว​ออก​มา​แล้ว


code แบบ “script OO ธรรมดา” อย่าง​ที่​เห็น​ข้าง​ต้น​มัก​เป็น​ทาง​เลือก​ที่​ง่าย​และ​ดี​ที่สุด แต่​ใน​บาง​สถานการณ์​มัน​ก็​ก่อ​ปัญหา​เรื่อง​การ​ดูแล​รักษา​หรือ coupling ที่​ไม่​พึง​ประสงค์ ชุมชน DDD จึง​พัฒนา pattern หลาย​ตัว​ขึ้น​มา​รับมือ

ปัญหา​ที่​พบ​บ่อย​คือ application service ขยาย​ตัว​จน​มี​หลาย​ความ​รับผิดชอบ​ปะปน​กัน เช่น RecommendAFriendService ที่​มี​ทั้ง recommendAFriend(), recommendAFriendInDifferentCountry(), reverseFriendReferral() ฯลฯ แต่ละ method ใช้ dependency คนละ​ชุด มี cohesion ต่ำ (พบ​บ่อย​มาก​ใน​ระบบ​ที่​ใช้ CQRSCQRSCommand Query Responsibility Segregation — แยก model สำหรับ “เขียน/แก้ไข” (command) ออก​จาก model สำหรับ “อ่าน” (query) เหมาะ​กับ domain ซับซ้อน แต่​เพิ่ม​ความ​ซับซ้อน ใช้​อย่าง​ระมัดระวังArchitecture)

Command ProcessorCommand Processorรูปแบบ​ที่​แยก application service ที่​บวม​ออก​เป็น1 command (อ็อบเจ็กต์แสดง​เจตนา) + 1 processor ต่อ1 use case แล้ว​ต่อ​สาย (chain) processor ทั่วไป (logging, transaction) ห่อ processor ภายใน​เป็น pipeline เพื่อ​แยก infrastructure ออก​จาก​การ​ประสาน​งาน domainArchitecture แก้​ปัญหา​นี้​ด้วย​การ​แยก 1 command + 1 processor ต่อ1 use case:

RecommendAFriend command + processor
// command = อ็อบเจ็กต์ที่ "แสดงเจตนา" และพกข้อมูลครบที่จำเป็น
class RecommendAFriend {
constructor(public referrerId: string, public friend: NewAccount) {}
}
// interface กลางที่ทุก processor implement เพื่อนำมา "ต่อสาย" กันได้
interface CommandProcessor<T> {
process(command: T): void;
}

พลัง​ที่แท้​จริง​ปรากฏ​ตอน​เรา ต่อ​สาย (chain) processor เข้า​ด้วย​กัน​เป็น pipeline โดย​ให้ generic processor (logging, transaction) ห่อ (decorate) processor ที่​อยู่​ข้าง​ใน — แต่ละ​ชั้น​สามารถ​ทำงาน​ทั้ง​ก่อน​และ​หลัง​ตัว​ที่​มัน​ห่อ​ไว้​ได้:

Pipeline ของ Command Processor
class TransactionProcessor<T> implements CommandProcessor<T> {
constructor(private next: CommandProcessor<T>) {}
process(command: T): void {
// เปิด transaction ก่อน
try {
this.next.process(command); // เรียกตัวที่ถูกห่อ (ซึ่งอาจห่อตัวอื่นต่อ)
// commit
} catch {
// roll back
}
}
}
// ต่อสาย: transaction → logging → use case จริง
const pipeline = new TransactionProcessor(
new LoggingProcessor(new RecommendAFriendProcessor())
);

จุด​สำคัญ​คือ processor แต่ละ​ตัว​รับ processor อีก​ตัว​เข้า​มา​ทาง constructor เมื่อ​มัน​ประมวล​ผล มัน​จะ​เรียกตัว​ที่​ถูก​ห่อ​ต่อ​ไป​เรื่อยๆ — เกิด​เป็น pipeline ที่​ต่อ​ยาว​ได้​ไม่​จำกัด ช่วย​แยก infrastructure (validation, transaction, logging) ออก​จาก​การ​ประสาน​งาน domain ได้​อย่าง​สะอาด จะ​ต่อ​สาย​เอง​หรือ​ใช้ dependency injection ก็ได้

เมื่อ domain logic เป็น event-based โดย​ธรรมชาติ (โดย​เฉพาะ​ตอน​คุณ​ส่ง command เข้า domain แต่​ไม่​ได้​ค่า​คืน​กลับ​มา​ทันที) ลอง​พิจารณา pattern publish/subscribe — application service จะ subscribe event ของ domain model เช่น policy มี event ReferralAccepted กับ ReferralRejected พอ application service เรียก policy.apply(command) แล้ว​ก็​รอ​ฟัง event ว่า​ผลลัพธ์​เป็น​อย่างไร จาก​นั้น​ค่อย​จัดการ​ต่อ (ส่ง​อีเมล​ยืนยัน​หรือ​ปฏิเสธ)

กับดัก​ของ publish/subscribe กับ transaction และ thread

pattern นี้​จัดการ transaction ยากกว่าที่​คิด เพราะ event handler ไม่มี reference ไป​ยัง transaction object โดยตรง ทาง​แก้​คือ​เก็บ transaction ไว้​เป็น instance field แต่​ก็​ต้อง​สร้าง service instance ใหม่​ต่อ1 transaction เพื่อ​เลี่ยง​ปัญหา multithreading และ​ถ้า​คุณ apply แบบ async ใน​อีก thread 1 transaction scope ที่​ห่อ​อยู่​อาจ​หลุด scope ไป​แล้ว — จึง​ต้อง​ระวัง​เป็น​พิเศษ

ก่อน​จะ​กระโดด​ไป pattern ที่​ซับซ้อน ลอง​พิจารณา​ความ​เรียบ​ง่าย​ของ Request/Reply ก่อน​เสมอ pattern นี้​ทำ​ตาม​แนวคิด OMIOMO (One Model In, One Model Out) — รับ DTO เข้าไป​หนึ่ง​ตัว คืน DTO ออก​มา​หนึ่ง​ตัว:

RecommendAFriendService.ts (Request/Reply)
class RecommendAFriendService {
recommendAFriend(request: RecommendAFriendRequest): RecommendAFriendResponse {
try {
const command = new RecommendAFriend(request.referrerId, request.friend);
this.policy.apply(command);
// คืนสถานะผ่าน response object แทนที่จะโยน exception
return { status: "Success" };
} catch (e) {
if (e instanceof ReferralRejectedDueToOutstandingBalance)
return { status: "ReferralRejected" };
throw e;
}
}
}

convention ทั่วไป​คือ​ใส่ suffix Request/Response ให้ DTO และ ให้ response แบก​สถานะ​ของ use case แทน​การ​โยน exception ทั้ง request และ response ต้อง​เป็น DTO ธรรมดา (ไม่มี domain object ปน) และ​อยู่​ใน​ชั้น application service เท่านั้น Request/Reply เป็น pattern เชิง procedural ที่​เรียบ​ง่าย — ถ้า​คุณ​ยัง​ไม่​ได้​ต้องการ​ประโยชน์​ของ pattern ที่​ซับซ้อน​กว่า​นี้ ให้​ยึด​ความ​เรียบ​ง่าย​ไว้​ก่อน​จนกว่า​มัน​จะ​เริ่ม​สร้าง​ปัญหา

ภาษา​สมัย​ใหม่​ให้​คุณ​เขียน code แบบ single-thread แต่​ทำงาน​แบบ asynchronous ได้​ด้วย async/await ข้อดี​คือ non-blocking มี​ประสิทธิภาพ​และ​สเกล​ได้​ดี​กว่า ข้อ​เสีย​คือ method แบบ async ต้อง​คืน​ชนิด Promise<T> / Task<T> ซึ่ง “ทำให้ domain เปื้อน” ไป​เล็กน้อย

async application service
async recommendAFriend(referrerId: string, friend: NewAccount): Promise<void> {
// await ทำให้ runtime ไม่ block thread รอผลลัพธ์
const referrer = await this.directory.find(referrerId);
const newAcct = await this.directory.create(friend);
this.policy.apply(referrer, newAcct); // การตัดสินใจเชิงธุรกิจยังอยู่ใน domain
}

ข้อดี​ของ​การ await ที่ application service (ไม่ใช่​ใน domain) คือ noise ของ async ถูก​จำกัด​ไว้​ที่​ชั้น application ส่วน domain logic ยัง​สะอาด นี่​คือ​การ​ประนีประนอม​ที่​ยอมรับ​ได้​ใน​งาน​จริง — แต่​ต้อง​ชั่ง​น้ำหนัก​ระหว่าง​ข้อดี (ประหยัด resource) กับ​ข้อ​เสีย (code รก​ขึ้น) ให้​ดี


การ test ที่​ระดับ application service ครอบคลุม vertical slice ของ feature ได้​กว้าง (system test, acceptance test, integration test, functional test) เป้าหมาย​คือ​ทดสอบ​ให้​เหมือน​สภาพ​แวดล้อม​จริง​มาก​ที่สุด มี​สอง​หลักการ​สำคัญ

1. ใช้​ภาษา​ของ domain ใน​ชื่อ test — test เป็น​โอกาส​ดี​ที่​จะ​แสดง​แนวคิด​ทาง​ธุรกิจ​ด้วย Ubiquitous Language ลอง​นั่ง​กับ domain expert เขียน test ตาม acceptance criteria แบบ BDD ชื่อ test ควร​อ่าน​เป็น​ภาษา​ธุรกิจ:

recommend-a-friend.test.ts
describe("เมื่อผู้ใช้สมัครผ่านการแนะนำจากเพื่อน", () => {
it("ผู้แนะนำได้รับเครดิต 50 บาทเข้าบัญชี", () => { /* ... */ });
it("เพื่อนได้บัญชีใหม่พร้อมยอดเริ่มต้น 50 บาท", () => { /* ... */ });
it("ผู้แนะนำถูกอัปเกรดเป็นสมาชิกระดับทอง", () => { /* ... */ });
it("ทั้งสองฝ่ายได้รับอีเมลแจ้งเตือน", () => { /* ... */ });
});

2. ทดสอบ functionality ให้​มาก​ที่สุด — เลี่ยง​การ​ใช้ mock/stub อย่าง​พร่ำเพรื่อ ให้​ใช้ implementation จริง​เท่า​ที่​ทำได้ เช่น สร้าง CustomerDirectory ตัว​จริง​คู่​กับ in-memory database (เร็ว​และ​ตั้ง​ง่าย) เพื่อ​ยืนยัน​ว่า repository กับ application service ทำงาน​ร่วม​กัน​ได้​จริง ส่วน component ที่​ทดสอบ​ยาก/ช้า เช่น ตัว​ส่ง​อีเมล ค่อย stub แล้ว​ตรวจ​ว่า​ถูก​เรียก​ด้วย argument ที่​ถูกต้อง — mock/stub เป็น ทาง​เลือก​สุดท้าย เมื่อ​ทดสอบ​ของ​จริง​ไม่​ได้​เท่านั้น

หัวใจ​ของ​บท​นี้

Application ServiceApplication Serviceบริการ​ที่ “ประสาน​งาน” use case — จัดการ transaction, ความ​ปลอดภัย, การ​บันทึก, แปลง DTO — แต่​ไม่มี​กฎ​ธุรกิจ​อยู่​ใน​ตัว​มัน​เองTactical Design = ด่าน​ที่ แยก​เรื่อง​เทคนิค​ออก​จาก domain logic มัน​รับผิดชอบ transaction, การ​เชื่อม​ต่อ​ฐาน​ข้อมูล, อีเมล, security และ logging พร้อม​ทั้ง ประสาน​งาน​กับ domain เพื่อ​ทำ use case ให้​ครบ​ทั้ง​กระบวนการ โดย​เรียก​ผ่าน API ระดับ​สูง​ที่ expressive เสมอ และ​ต้อง ปกป้อง​โครงสร้าง​ภายใน​ของ domain ด้วย​การ​มอบ interface ที่​เสถียร​ให้​โลก​ภายนอก​ผูก​ติด — แต่ ห้าม​มี​กฎ​ธุรกิจ (business rule) อยู่​ใน​ตัว​มัน​เอง​เด็ดขาด


🔗 อ้างอิง​เพิ่มเติม​ใน DevIQ

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง DevIQ:

  • CQRS — เมื่อ application service บวม​เพราะ​ปน​หลาย use case (อ่าน/เขียน) เข้า​ด้วย​กัน CQRS คือ​แนวทาง​แยก command กับ query ออก​จาก​กัน ซึ่ง​เป็น​สาเหตุ​หลัก​ที่​ทำให้​ต้อง​ใช้ pattern อย่าง Command Processor
  • Domain Events — พื้นฐาน​ของ pattern Publish/Subscribe ใน​บท​นี้ ที่ application service subscribe เหตุการณ์​อย่าง ReferralAccepted/ReferralRejected จาก domain แล้ว​ค่อย​ประสาน​งาน​ต่อ
  • REPR (Request-Endpoint-Response) — แนวคิด​เดียว​กับ Request/Reply ใน​บท​นี้ ที่​ยึด​หลัก “1 endpoint รับ request DTO เดียว คืน response DTO เดียว” (OMIOMO)

เช็กความเข้าใจ — บทที่ 25

ข้อ 1 / 4

หน้าที่หลักสองอย่างของ Application Service คืออะไร?