เมื่อไร "อย่า" ใช้ Clean Architecture
บทที่แล้วปิดท้ายด้วยประโยคที่ฟังดูแปลกสำหรับคอร์สที่ใช้เวลาเจ็ดบทเต็มสอนวิธีสร้าง Clean ArchitectureClean Architectureแนวสถาปัตยกรรมของ Robert C. Martin ที่จัด code เป็นวงแหวนซ้อนกัน (Entities → Use Cases → Interface Adapters → Frameworks) ภายใต้ Dependency Rule เพื่อกัน business rules ออกจาก UI ฐานข้อมูล และ frameworkArchitecture มาตลอด — “ต้นทุนของโครงสร้าง4 project ports และ ceremony ทั้งหมดนี้คุ้มก็ต่อเมื่อ domain ซับซ้อนพอ” ฟังดูเหมือนกำลังจะถอนคำพูดตัวเองใช่ไหม ไม่ใช่เลย บทปิดคอร์สนี้ไม่ได้มาบอกว่าเจ็ดบทที่ผ่านมาผิด แต่มาเติมสิ่งที่ไดอะแกรมวงแหวนสวยๆ ไม่เคยบอกไว้ตรงๆ สักที นั่นคือ สถาปัตยกรรมทุกแบบมีต้นทุน และงานของวิศวกรที่ดีไม่ใช่แค่รู้วิธีสร้างมัน แต่รู้ด้วยว่าเมื่อไรควร “อย่า” สร้างมัน
บทนี้ไม่มี code ใหม่ใน repo kaen-food-ordering — สิ่งที่ควรเปิดดูแทนคือ2 template โอเพนซอร์สที่พูดถึงด้านล่าง (Ardalis/CleanArchitecture และ jasontaylordev/CleanArchitecture) ซึ่งทำทุกอย่างที่คอร์สนี้สอนไว้ให้พร้อมใช้งานจริงแล้ว
ต้นทุนที่ไดอะแกรมไม่เคยบอก
หัวข้อที่มีชื่อว่า “ต้นทุนที่ไดอะแกรมไม่เคยบอก”ลองนึกภาพ feature ที่เรียบง่ายที่สุดเท่าที่จะนึกออกได้: หน้าตั้งค่าร้านที่ให้เจ้าของร้านแก้ไข “ชื่อร้าน” หนึ่งช่อง ไม่มีส่วนลด ไม่มีการคำนวณ ไม่มี invariant ใดๆ ให้รักษาเลยสักข้อ — ถ้าจะทำ feature นี้ตามโครง4 project ที่วางไว้ตั้งแต่บทที่ 1 หน้าตาของงานจะเป็นแบบนี้:
FoodOrdering.Domain— เพิ่ม entityRestaurantProfileพร้อม methodRename(...)ที่ไม่มี guard clause อะไรให้เขียนจริงๆFoodOrdering.Application— เพิ่ม portIRestaurantProfileRepository, commandRenameRestaurantCommand, และ handler ที่ประสานสองอย่างนี้เข้าด้วยกันFoodOrdering.Infrastructure— implementEfRestaurantProfileRepositoryพร้อม EF Core mappingFoodOrdering.Web— endpoint ใหม่, request DTO, response DTOProgram.cs— เพิ่มบรรทัด DI registration อีกหนึ่งบรรทัด
รวมแล้วแตะราว7 file ข้าม4 project เพื่อเปลี่ยน string หนึ่งช่อง เทียบกับ Order.Place(...) ในบทที่ 2 ที่มี invariant จริงสี่ข้อให้รักษา Dependency RuleDependency Ruleกฎเดียวของ Clean Architecture: source-code dependency ต้องชี้เข้าด้านในเสมอ วงในห้ามรู้จักวงนอก — ใน code .NET แปลเป็นทิศทางของ ProjectReference ระหว่าง projectArchitecture ที่บังคับให้วงในไม่รู้จักวงนอกนั้นคุ้มค่ามากตอนมีกฎธุรกิจให้ปกป้องจากการรั่วไหลของ framework แต่ตอนไม่มีกฎอะไรให้ปกป้องเลย — ผลลัพธ์ของ ceremony ทั้งหมดนั้นก็แค่ทำให้งานที่ควรเสร็จใน file เดียวกลายเป็นงานที่ต้องกระโดดข้าม7 file
เมื่อ domain เป็นแค่ CRUD ล้วนๆ
หัวข้อที่มีชื่อว่า “เมื่อ domain เป็นแค่ CRUD ล้วนๆ”หลักที่อธิบายสัญชาตญาณข้างบนได้ตรงที่สุดชื่อ YAGNIYAGNI (You Aren't Gonna Need It)อย่าสร้างสิ่งที่ยังไม่ต้องใช้ — เตือนไม่ให้ใส่ abstraction/ชั้นเกินจำเป็นเพียงเพราะ 'เผื่อไว้'Architecture ย่อจาก “You Aren’t Gonna Need It” — อย่าสร้างสิ่งที่ยังไม่ต้องใช้ หลักนี้มักถูกพูดถึงในระดับ code (อย่าเขียน parameter ที่ยังไม่มีใครเรียกใช้) แต่มันใช้ได้กับระดับสถาปัตยกรรมด้วย: การแยกเป็น4 project ประกาศ port แล้วรอ adapter มา implement คือ “การลงทุนล่วงหน้า” อย่างหนึ่ง มันจ่ายคืนก็ต่อเมื่อมีเหตุผลจริงให้กันวงในจากวงนอก เช่น กฎธุรกิจที่ซับซ้อนและเปลี่ยนบ่อย หรือความจำเป็นต้องสลับ adapter ระหว่าง test กับของจริง — ถ้าไม่มีสองอย่างนี้เลย การลงทุนนั้นก็เป็นแค่ต้นทุนเปล่าๆ ที่ไม่เคยได้ผลตอบแทนคืนมา
// สมมติแค่ "แก้ไขชื่อร้าน" ในหน้าตั้งค่า — ไม่มี invariant ให้รักษาเลยสักข้อ
// 1) FoodOrdering.Domainpublic sealed record RestaurantProfileId(Guid Value); // record wrapper แบบเดียวกับ OrderId (บทที่ 2)
public sealed class RestaurantProfile{ public RestaurantProfileId Id { get; } public string Name { get; private set; }
public RestaurantProfile(RestaurantProfileId id, string name) { Id = id; Name = name; }
public void Rename(string name) => Name = name; // ไม่มี guard clause ให้เขียนจริง ๆ}
// 2) FoodOrdering.Application — port + command + handler เพื่อ "เปลี่ยนชื่อ" 1 fieldpublic interface IRestaurantProfileRepository{ Task<RestaurantProfile?> FindAsync(RestaurantProfileId id, CancellationToken ct); Task SaveAsync(RestaurantProfile profile, CancellationToken ct);}
public sealed record RenameRestaurantCommand(Guid Id, string Name) : IRequest;
public sealed class RenameRestaurantHandler : IRequestHandler<RenameRestaurantCommand>{ private readonly IRestaurantProfileRepository _repo; public RenameRestaurantHandler(IRestaurantProfileRepository repo) => _repo = repo;
public async Task Handle(RenameRestaurantCommand cmd, CancellationToken ct) { var profile = await _repo.FindAsync(new RestaurantProfileId(cmd.Id), ct) ?? throw new KeyNotFoundException(); profile.Rename(cmd.Name); await _repo.SaveAsync(profile, ct); }}
// 3) FoodOrdering.Infrastructure — EfRestaurantProfileRepository implement port ด้านบน (ละไว้)// 4) FoodOrdering.Web — endpoint แปลง HTTP PATCH เป็น RenameRestaurantCommand (ละไว้)// 7 file 4 project เพื่อเปลี่ยน string หนึ่งช่อง — ไม่มีบรรทัดไหนเลยที่รักษากฎธุรกิจอะไรจริง ๆสังเกตว่า code ข้างบน “ถูก” ทุกบรรทัดในแง่ที่ compile ผ่านและตามสไตล์ที่คอร์สนี้สอนมาตลอดเป๊ะ — ปัญหาไม่ได้อยู่ที่วิธีเขียน แต่อยู่ที่การเลือกใช้เครื่องมือผิดกับปัญหา นี่คือสิ่งที่ DevIQ เรียกว่า Golden Hammer — พอถนัดสถาปัตยกรรมแบบหนึ่งจนคล่อง ก็มีแนวโน้มจะหยิบมันมาใช้กับทุกปัญหาที่เจอ โดยไม่ทันหยุดถามว่า domain ตรงหน้าต้องการมันจริงไหม RestaurantProfile ไม่ใช่ Order — มันไม่มีอะไรให้ Dependency Rule ปกป้อง
ทางเลือกที่เบากว่า
หัวข้อที่มีชื่อว่า “ทางเลือกที่เบากว่า”ถ้า Clean Architecture จัด code ตาม “ชั้นเทคนิค” (Domain, Application, Infrastructure, Web) ทางเลือกที่ตรงข้ามกันคือจัด code ตาม “feature” — แต่ละ feature เป็น1 slice ที่ตัดผ่านทุกชั้นในตัวเอง ตั้งแต่รับ request จนถึงบันทึกข้อมูล อยู่ใน folder เดียวกัน แนวคิดนี้เรียกว่า Vertical Slice ArchitectureVertical Slice Architectureแนวจัด code ตาม 'feature' (slice แนวตั้งที่ตัดผ่านทุกชั้น) แทนการแบ่งตามชั้นเทคนิค — ทางเลือกที่เบากว่าเมื่อ Clean Architecture เริ่ม over-engineerArchitecture ข้อดีของมันคือ feature แต่ละอันตัดสินใจ “ความหนา” ของตัวเองได้อิสระ — slice ที่ซับซ้อนอย่าง PlaceOrder จะมี domain model, port และ handler แยกกันเหมือนเดิมก็ได้ถ้าจำเป็นจริง ในขณะที่ slice ง่ายๆ อย่าง RenameRestaurant เขียนจบใน file เดียวได้เลยโดยไม่ต้องผ่าน4 project:
# Layered (Clean Architecture) — จัดตามชั้นเทคนิค ต้องเปิดหลาย project ต่อ1 featureFoodOrdering.Domain/ Orders/Order.csFoodOrdering.Application/ Orders/PlaceOrderCommand.cs Orders/PlaceOrderHandler.cs Orders/IOrderRepository.csFoodOrdering.Infrastructure/ Orders/EfOrderRepository.csFoodOrdering.Web/ Orders/PlaceOrderEndpoint.cs
# Vertical Slice — จัดตาม feature ทุกอย่างของ feature เดียวอยู่ที่เดียวFeatures/ PlaceOrder/ PlaceOrderRequest.cs PlaceOrderEndpoint.cs PlaceOrderHandler.cs Order.cs # domain logic เท่าที่ feature นี้ต้องใช้ ยังรักษา invariant ได้เหมือนเดิม RenameRestaurant/ RenameRestaurantEndpoint.cs # feature นี้ไม่มีกฎอะไรซับซ้อน จบใน file เดียวได้จริงจะเลือกแบบไหนตอบด้วยคำถามเดียวกับที่ใช้ตัดสินตลอดคอร์สนี้ นั่นคือ domain ตรงหน้ามีกฎธุรกิจที่ซับซ้อนและเปลี่ยนบ่อยพอจะคุ้มกับ ceremony ไหม:
flowchart TD
Q1{"domain ซับซ้อน?<br/>กฎธุรกิจเปลี่ยนบ่อย?"}
Q1 -->|ใช่| CA["Clean Architecture<br/>(วงแหวน + Dependency Rule)"]
Q1 -->|ไม่ใช่| Q2{"CRUD ล้วน ๆ?<br/>แทบไม่มีกฎธุรกิจ"}
Q2 -->|ใช่| VS["Vertical Slice<br/>(จัดตาม feature)"]
Q2 -->|ไม่แน่ใจ| CA
classDef ca fill:#16a34a,stroke:#065f46,color:#f8fafc;
classDef vs fill:#f59e0b,stroke:#92400e,color:#1c1917;
class CA ca;
class VS vs;
คำบรรยายภาพ: คำถามแรกกรอง domain ที่คุ้มกับ ceremony ออกไปทาง Clean Architecture (เขียว) คำถามที่สองกรองส่วนที่เหลือ — ถ้ายังไม่แน่ใจว่ากฎจะซับซ้อนขึ้นไหมในอนาคต ให้เอียงไปทาง Clean Architecture ไว้ก่อน เพราะย้ายจาก Vertical Slice ไปหา Clean Architecture ทีหลังมักเจ็บกว่าตรงกันข้าม และในระบบจริงสองแบบนี้อยู่ร่วมกันได้ในโซลูชันเดียวกัน — ใช้ Clean Architecture กับ Order แต่ใช้ Vertical Slice กับ RestaurantProfile ก็ได้ ไม่มีกฎว่าทั้งระบบต้องเลือกแบบเดียวทั้งหมด
ทีนี้พอระบบเริ่มมีทั้ง slice ที่หนาแบบ Clean Architecture และ slice ที่บางแบบ Vertical Slice ปนกันหลาย10 feature คำถามถัดไปที่ตามมาเองคือ “แล้วจะรวมทุก feature เหล่านี้ไว้ใน deploy เมนต์เดียวกันโดยไม่ให้มันพันกันจนแยกไม่ออกได้ยังไง” นั่นคือขอบเขตของแนวคิด Modular Monolith ที่จะมีคอร์สแยกมาพูดถึงเต็มๆ ในอนาคต — วาง feature เป็น module อิสระต่อกันภายใน deploy เมนต์เดียว ก่อนจะตัดสินใจแยกเป็น microservice จริงก็ยังไม่สาย
ทางลัดมืออาชีพ: template ที่ทีมจริงใช้งานอยู่
หัวข้อที่มีชื่อว่า “ทางลัดมืออาชีพ: template ที่ทีมจริงใช้งานอยู่”ทุกอย่างที่คอร์สนี้สอนมาตลอดเจ็ดบท — 4 project, Dependency Rule, DIP, CQRS/MediatR, composition root, test ที่ไม่ต้อง mock — ไม่ใช่ของใหม่ที่คิดขึ้นเองสำหรับคอร์สนี้ มันคือ pattern ที่ชุมชน .NET ใช้งานจริงมานาน และมี template โอเพนซอร์สคุณภาพสูงสองตัวที่รวบรวมมันไว้ให้ครบแล้ว พร้อม dotnet new ได้ทันที:
- Ardalis/CleanArchitecture โดย Steve “Ardalis” Smith — template ที่เน้นความเรียบง่ายตรงตามวงแหวนของ Uncle Bob พร้อม specificationSpecificationpattern ห่อ 'กฎการคัดเลือก/เงื่อนไข' เป็น object นำมาประกอบและนำกลับมาใช้ซ้ำได้ เช่น EligiblePromotionSpec, CancellableOrderSpecTactical Design pattern และ Result pattern แทนการโยน exception เพื่อควบคุม flow
- jasontaylordev/CleanArchitecture โดย Jason Taylor — template ที่ครบเครื่องกว่า มาพร้อม EF Core, FluentValidation, ตัวอย่าง Angular/React frontend และ pipeline behavior ของ MediatR สำหรับ cross-cutting concern อย่าง logging กับ validation
ทั้ง2 repo เผยแพร่เป็น dotnet new template ที่ติดตั้งได้ตรงๆ ด้วย dotnet new install (ดูคำสั่งและ version ล่าสุดที่แน่นอนได้จาก README ของแต่ละ repo เพราะมันอัปเดตบ่อย) มองเป็น ทางลัดสำหรับงานจริง ไม่ใช่ทางลัดสำหรับการเรียนรู้ — ถ้าเพิ่งอ่านคอร์สนี้จบใหม่ๆ แนะนำให้โคลนทั้ง2 repo มาไล่อ่านโครงสร้างเทียบกับที่เพิ่งสร้างเองมาตลอดเจ็ดบท จะเห็นเลยว่าเหตุผลเบื้องหลังทุก folder คือเหตุผลเดียวกับที่อธิบายไปในคอร์สนี้ ต่างกันแค่รายละเอียดปลีกย่อยของแต่ละทีม
จากคอร์สนี้ไปคอร์สถัดไป
หัวข้อที่มีชื่อว่า “จากคอร์สนี้ไปคอร์สถัดไป”คอร์สนี้จบด้วยการสร้างโครงและพิสูจน์ว่ามัน test ได้ แต่ยังเปิดประตูค้างไว้สองบาน บานแรกคือ tactical patterns เชิงลึก — คอร์ส Patterns, Principles & Practices of DDD ที่แปลไว้แล้วครบ 26 บทได้ปูทฤษฎีของ bounded contextBounded Contextขอบเขตที่ ubiquitous language และ model ชุดหนึ่งมีความหมายคงเส้นคงวา เช่น Ordering, Payment, Dispatch ใน domain ฟู้ดเดลิเวอรีStrategic Design, aggregate, domain service และ event sourcing ไว้แล้ว แต่คอร์สนี้หยิบมาใช้แค่ส่วนที่จำเป็นสำหรับ Order เดียวใน Bounded Context เดียว คอร์สถัดไปในซีรีส์ (ยังไม่เผยแพร่) จะพาลงลึกกว่านั้น — ใช้ tactical pattern เต็มชุดกับ domain ที่มีหลาย Bounded Context คุยกันจริงๆ
บานที่สองคือ การ test แบบเต็มรูปแบบ — บทที่ 7 แตะแค่ผิวของ unit test กับ integration test เป็นทีเซอร์ ส่วนเรื่อง test fixture, contract test ระหว่าง bounded context, mutation testing และกลยุทธ์ test MediatR pipeline ทั้งเส้น เป็นเนื้อหาที่ลึกกว่าขอบเขตของคอร์สที่เน้นโครงสร้างนี้ คอร์สแยกที่ว่าด้วยการ test โดยเฉพาะ (ยังไม่เผยแพร่เช่นกัน) จะกลับมาปิดช่องว่างนั้น
ระหว่างรอสองคอร์สนั้น พื้นฐานที่มีอยู่แล้ว — The Clean Architecture, Hexagonal Architecture, และคอร์ส DDD ทั้งสองเล่ม — เพียงพอให้ลงมือสร้าง project จริงได้แล้ววันนี้
สรุปคอร์ส — 8 บทที่ผ่านมา
หัวข้อที่มีชื่อว่า “สรุปคอร์ส — 8 บทที่ผ่านมา”ย้อนกลับไปบทที่ 1 เราเริ่มจากคำถามเดียว: จะแปลงวงแหวนของ Uncle Bob ให้เป็นโซลูชัน .NET ที่ dotnet build ผ่านได้ยังไง แล้วไล่ตอบทีละชั้น — Domain ที่รักษา invariant ของ Order เอง (บทที่ 2), Application ที่ประสาน use case ผ่าน CQRS/MediatR และรับมือกับโปรโมชันที่เปลี่ยนบ่อยด้วย Strategy (บทที่ 3), Infrastructure ที่ implement port ด้วย EF Core โดยไม่ให้ domain รู้จักมันเลย (บทที่ 4), Web ที่เป็นแค่ adapter บางๆ ตาม pattern REPR (บทที่ 5), composition root จุดเดียวที่ประกอบทุกอย่างเข้าด้วยกัน (บทที่ 6) และ test จริงที่พิสูจน์ว่าการกลับทิศ dependency ทั้งหมดนั้นคุ้มค่า (บทที่ 7) วันนี้เราปิดท้ายด้วยบทเรียนที่สำคัญไม่แพ้กัน — รู้ว่าเมื่อไรควรหยุดใช้สิ่งที่เพิ่งเรียนมาทั้งหมด
ถ้านับจากคอร์ส Event Storming ที่ค้นพบ domain ฟู้ดเดลิเวอรีบนกำแพง Post-it ผ่านคอร์ส DDD ที่ปูทฤษฎี มาถึงคอร์สนี้ที่แปลงมันเป็น code จริง — เส้นทางที่เดินมาคือ จากกระดาษสู่ code ที่รันได้ ทดสอบได้ และรู้ขอบเขตของตัวเอง นั่นคือสิ่งที่วิศวกรที่ดีสร้างขึ้นจริงๆ ไม่ใช่แค่ code ที่ compile ผ่าน แต่คือการตัดสินใจที่มีเหตุผลรองรับทุกจุด รวมถึงการตัดสินใจว่าจะ “ไม่” ใช้เครื่องมือที่เพิ่งชำนาญด้วย ขอบคุณที่เดินทางมาครบทั้ง 8 บท — ไปลงมือสร้างของจริงกันต่อได้เลย
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- YAGNI — หลักเต็มว่าทำไมไม่ควรสร้างสิ่งที่ยังไม่ต้องใช้ พร้อมสัญญาณเตือนของการละเมิด
- Golden Hammer — antipattern ของการใช้เครื่องมือที่ถนัดกับทุกปัญหาโดยไม่ถามว่าเหมาะจริงไหม
- Vertical Slices — แนวคิดเต็มของการจัด feature ตัดผ่านทุกชั้น พร้อมความสัมพันธ์กับ Separation of Concerns
เช็กความเข้าใจ — บทที่ 8
ข้อ 1 / 3Clean Architecture มีแนวโน้ม 'over-engineer' มากที่สุดกับ domain แบบไหน?