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 ไม่ขยายตัวจนเกินควบคุม

ในเชิงตรรกะ 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
แยก Application Logic ออกจาก Domain Logic
หัวข้อที่มีชื่อว่า “แยก Application Logic ออกจาก Domain Logic”ถ้าอยากได้ model ที่ขับเน้นแนวคิดทางธุรกิจและกันมันออกจากรายละเอียดทางเทคนิค คุณต้องเข้าใจความต่างระหว่าง application logic กับ domain logic ให้ชัด ส่วนใหญ่แล้วไม่ยาก แม้จะมีพื้นที่สีเทาอยู่บ้าง
ให้คิดง่ายๆ ว่า application service มีหน้าที่หลักสองอย่าง:
- จัดการเรื่อง infrastructure — เปิด/ปิด transaction, ส่งอีเมล, เขียน log และงานทางเทคนิคทำนองนี้
- ประสานงานกับ 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 บาท และผู้แนะนำจะถูกอัปเกรดเป็นสมาชิกระดับทอง

Infrastructure: งานทางเทคนิคที่ Application Service รับไป
หัวข้อที่มีชื่อว่า “Infrastructure: งานทางเทคนิคที่ Application Service รับไป”เพื่อจะเรียกใช้ domain model คุณต้องทำงานทางเทคนิคหลายอย่างก่อน ถ้าแยก code พวกนี้ออกได้ดี domain model จะสะอาดและดูแลง่ายขึ้นมาก มาดูทีละเรื่อง
Application-Level Validation
หัวข้อที่มีชื่อว่า “Application-Level Validation”การ validate ระดับ app คือการตรวจ รูปแบบทางเทคนิค เช่น ชนิดข้อมูลถูกไหม ความยาวเกินไหม รูปแบบอีเมลถูกไหม — สิ่งเหล่านี้ ไม่ใช่กฎธุรกิจ ที่ domain expert สนใจ แต่ก็ทำให้ระบบพังได้ จึงควรตรวจที่ application service แทนที่จะไปปะปนอยู่ใน domain
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
Transactions
หัวข้อที่มีชื่อว่า “Transactions”ใน use case ทั่วไปมักมีหลายการกระทำที่ต้อง สำเร็จหรือล้มเหลวพร้อมกัน ภายใน transaction เดียว การจัดการ transaction ที่ application service ทำให้คุณควบคุมได้ว่าการกระทำใดบ้างที่อยู่ในขอบเขต transaction เดียวกัน

สังเกตจากรูปที่ 25-3 ว่าการ ส่งอีเมล ถูกวางไว้ “นอก” ขอบเขต transaction โดยตั้งใจ เพราะถ้า transaction roll back แต่อีเมลถูกส่งไปแล้ว คุณเรียกคืนไม่ได้ การกระทำที่มี side-effect ถาวรและย้อนกลับไม่ได้ (ส่งอีเมล, ยิง webhook, เรียก API ภายนอก) ควรเกิด หลัง commit สำเร็จเสมอ
Error Handling และ Error Translation
หัวข้อที่มีชื่อว่า “Error Handling และ Error Translation”ไม่ใช่ทุกการคุยกับ domain จะสำเร็จ — บางครั้ง domain จะโยน exception หรือคืน error code ออกมา (กรณี domain validation ไม่ผ่าน แม้ application validation จะผ่านแล้ว) หน้าที่ของ application service คือ ดักจับ error เหล่านี้แล้วแปลให้อยู่ในรูปที่เหมาะกับโลกภายนอก เพื่อไม่ให้ผู้เรียก (จะเป็นคนหรือระบบอื่น) ผูกติดกับโครงสร้าง error ภายในของ domain
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 ทั่วไปแทนเพื่อความปลอดภัย
สมมติ domain โยน ReferralRejectedDueToOutstandingBalance ออกมา ถ้าคุณปล่อยให้ frontend หรือ partner API จับ exception ตัวนี้ตรงๆ วันหนึ่งที่คุณ refactor ชื่อ exception ภายใน domain ผู้เรียกทุกคนก็พังตามไปด้วย การแปลให้เป็น ApplicationError ที่เสถียรคือการสร้าง “สัญญา” ที่ทนต่อการเปลี่ยนแปลงภายใน — ผู้เรียกผูกกับ interface ที่นิ่ง ไม่ใช่กับความผันผวนของ domain
Logging, Metrics และ Monitoring
หัวข้อที่มีชื่อว่า “Logging, Metrics และ Monitoring”ข้อมูลอย่าง response time, จำนวน error และตัวนับต่างๆ ช่วยให้คุณเห็นว่าระบบทำงานเป็นอย่างไรและตรวจจับปัญหาได้ตั้งแต่เนิ่นๆ แต่ถ้าเขียนปะปนเข้าไปใน domain logic จะทำให้แนวคิดสำคัญทางธุรกิจถูกบดบัง คุณจึงควรพึ่ง application service ให้บันทึกข้อมูลพวกนี้แทน — logger.debug(), logger.error(), การนับ metric — ทั้งหมดดึงขึ้นมาไว้ที่ application service แทนที่จะปล่อยให้ปนใน domain
Authentication และ Authorization
หัวข้อที่มีชื่อว่า “Authentication และ Authorization”อีกเรื่อง infrastructure ที่เลี่ยงยากคือความปลอดภัย — authentication (ผู้ใช้เป็นใคร) และ authorization (ผู้ใช้มีสิทธิ์ทำสิ่งนี้ไหม) ทั้งคู่ควรถูกตรวจที่ application service ก่อนจะแตะ domain
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 }}หนังสือยกตัวอย่างจากเหตุการณ์จริงว่า เมื่อระบบพนัน (gambling) ออนไลน์ไม่มี authorization ผู้ใช้ทั่วไปสามารถ “เดา URL” ของ admin endpoint เพื่อเติมเครดิตฟรีให้ตัวเองได้ไม่จำกัด — และมันเกิดขึ้นจริงมาแล้ว การตรวจสิทธิ์ที่ application service จึงเป็นด่านป้องกันที่ขาดไม่ได้สำหรับ app ที่มีผู้ใช้หลายระดับสิทธิ์
Communication ข้าม Bounded Context
หัวข้อที่มีชื่อว่า “Communication ข้าม Bounded Context”เหตุการณ์ที่เกิดใน context หนึ่งอาจ trigger การทำงานใน context อื่น เป็นหน้าที่ของ application service ที่จะ ส่งต่อ event ข้าม context — ผ่าน message bus, atom feed, REST หรือ RPC เป้าหมายเหมือนเดิมคือ แยกแนวคิด domain ออกจากเรื่อง infrastructure เช่น หลัง referral สำเร็จ application service จะ publish ข้อความ CustomerRegisteredViaReferralPolicy ขึ้น bus ให้ context อื่น (เช่น การตลาด หรือ การแจ้งเตือน) มารับไปทำงานต่อ
ใน 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
เส้นแบ่งสีเทา: เมื่อ Coordination กลายเป็น Domain Logic
หัวข้อที่มีชื่อว่า “เส้นแบ่งสีเทา: เมื่อ Coordination กลายเป็น Domain Logic”ส่วนที่ 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
ทุกครั้งที่ application service คุยกับ domain การเรียกนั้นควร expressive — เป็น API ระดับสูงที่อ่านแล้วเข้าใจเจตนา เช่น referralPolicy.apply(referrer, friend) ถ้าตรรกะข้างใน apply() (ให้เครดิต 50 บาททั้งสองฝั่ง + อัปเกรดเป็นสมาชิกระดับทอง) ถูกแกะออกมากระจายเขียนใน application service แทน นั่นคือคือสัญญาณชัดว่ามีแนวคิด domain รั่วออกมาแล้ว
Application Service Patterns
หัวข้อที่มีชื่อว่า “Application Service Patterns”code แบบ “script OO ธรรมดา” อย่างที่เห็นข้างต้นมักเป็นทางเลือกที่ง่ายและดีที่สุด แต่ในบางสถานการณ์มันก็ก่อปัญหาเรื่องการดูแลรักษาหรือ coupling ที่ไม่พึงประสงค์ ชุมชน DDD จึงพัฒนา pattern หลายตัวขึ้นมารับมือ
Command Processor
หัวข้อที่มีชื่อว่า “Command Processor”ปัญหาที่พบบ่อยคือ 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:
// 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 ที่อยู่ข้างใน — แต่ละชั้นสามารถทำงานทั้งก่อนและหลังตัวที่มันห่อไว้ได้:
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 ก็ได้
Publish/Subscribe
หัวข้อที่มีชื่อว่า “Publish/Subscribe”เมื่อ 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 ว่าผลลัพธ์เป็นอย่างไร จากนั้นค่อยจัดการต่อ (ส่งอีเมลยืนยันหรือปฏิเสธ)
pattern นี้จัดการ transaction ยากกว่าที่คิด เพราะ event handler ไม่มี reference ไปยัง transaction object โดยตรง ทางแก้คือเก็บ transaction ไว้เป็น instance field แต่ก็ต้องสร้าง service instance ใหม่ต่อ1 transaction เพื่อเลี่ยงปัญหา multithreading และถ้าคุณ apply แบบ async ในอีก thread 1 transaction scope ที่ห่ออยู่อาจหลุด scope ไปแล้ว — จึงต้องระวังเป็นพิเศษ
Request/Reply
หัวข้อที่มีชื่อว่า “Request/Reply”ก่อนจะกระโดดไป pattern ที่ซับซ้อน ลองพิจารณาความเรียบง่ายของ Request/Reply ก่อนเสมอ pattern นี้ทำตามแนวคิด OMIOMO (One Model In, One Model Out) — รับ DTO เข้าไปหนึ่งตัว คืน DTO ออกมาหนึ่งตัว:
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 ที่ซับซ้อนกว่านี้ ให้ยึดความเรียบง่ายไว้ก่อนจนกว่ามันจะเริ่มสร้างปัญหา
async/await
หัวข้อที่มีชื่อว่า “async/await”ภาษาสมัยใหม่ให้คุณเขียน code แบบ single-thread แต่ทำงานแบบ asynchronous ได้ด้วย async/await ข้อดีคือ non-blocking มีประสิทธิภาพและสเกลได้ดีกว่า ข้อเสียคือ method แบบ async ต้องคืนชนิด Promise<T> / Task<T> ซึ่ง “ทำให้ domain เปื้อน” ไปเล็กน้อย
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 รกขึ้น) ให้ดี
การทดสอบ Application Service
หัวข้อที่มีชื่อว่า “การทดสอบ Application Service”การ 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 ควรอ่านเป็นภาษาธุรกิจ:
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:
- 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 คืออะไร?