Givepo
บล็อก

Clean Architecture และหลักการ SOLID: เขียนซอฟต์แวร์ที่ดูแลง่ายในระยะยาว

ทำไมโค้ดเบสจึงช้าลงเมื่อโตขึ้น และ Clean Architecture กฎการพึ่งพา และหลักการ SOLID ช่วยให้ตรรกะทางธุรกิจทดสอบง่ายและแก้ไขง่ายได้อย่างไร พร้อมตัวอย่าง TypeScript ที่สมบูรณ์

ทีมงาน Givepo
อ่าน 7 นาที
Illustration of concentric architecture layers with arrows pointing inward to the core, beside a SOLID principles checklistวิศวกรรมซอฟต์แวร์

ซอฟต์แวร์ส่วนใหญ่ไม่ได้ล้มเหลวเพราะสร้างไม่ได้ แต่ล้มเหลวเพราะหลังผ่านไปหนึ่งหรือสองปี การเปลี่ยนแปลงแต่ละครั้งใช้เวลานานกว่าครั้งก่อน การแก้เล็กๆ น้อยๆ ทำให้ฟีเจอร์ที่ไม่เกี่ยวข้องพัง และไม่มีใครกล้าแตะส่วนแกนกลาง Clean Architecture และหลักการ SOLID คือชุดแนวคิดที่ป้องกันเรื่องนี้ ด้วยการแยกส่วนต่างๆ ของระบบที่เปลี่ยนแปลงด้วยเหตุผลต่างกันออกจากกัน

ทำไมโค้ดเบสจึงเสื่อมสภาพ

การเสื่อมสภาพเกือบทั้งหมดมาจากการผูกติดกัน (coupling) คือกฎทางธุรกิจรู้จัก HTTP request แถวข้อมูลในฐานข้อมูล และคอมโพเนนต์ UI เมื่อตรรกะการคิดราคาอยู่ใน controller ที่ทั้งแปลง JSON และเขียน SQL คุณจะทดสอบการคิดราคาไม่ได้หากไม่มีเว็บเซิร์ฟเวอร์และฐานข้อมูล เปลี่ยนฐานข้อมูลไม่ได้โดยไม่กระทบการคิดราคา และนำการคิดราคาไปใช้ซ้ำใน background job ไม่ได้เลย

สถาปัตยกรรมที่ดีคือการขีดเส้นแบ่งขอบเขต เพื่อให้โค้ดที่มีค่าที่สุด ซึ่งก็คือกฎทางธุรกิจของคุณ พึ่งพาสิ่งอื่นให้น้อยที่สุด

กฎการพึ่งพา (Dependency Rule)

Clean Architecture ตามที่ Robert C. Martin อธิบายไว้ จัดโค้ดเป็นชั้นซ้อนกันเป็นวง:

  1. Entities อยู่ตรงกลาง: อ็อบเจกต์และกฎทางธุรกิจหลัก เช่น คำสั่งซื้อและวิธีคำนวณยอดรวม
  2. Use cases ล้อมรอบชั้นแรก: ขั้นตอนการทำงานเฉพาะของแอปพลิเคชัน เช่น การสั่งซื้อหรือการคืนเงิน
  3. Interface adapters: controller, presenter และ repository ที่แปลงข้อมูลระหว่าง use case กับโลกภายนอก
  4. Frameworks และ drivers อยู่ชั้นนอกสุด: เว็บเฟรมเวิร์ก ฐานข้อมูล UI และบริการภายนอก

กฎข้อเดียวที่ทำให้ทุกอย่างได้ผลคือ การพึ่งพาในซอร์สโค้ดต้องชี้เข้าด้านในเท่านั้น ชั้นในไม่รู้อะไรเกี่ยวกับชั้นนอกเลย use case ไม่เคย import client ของฐานข้อมูล แต่จะประกาศ interface ที่อธิบายสิ่งที่ตนต้องการ เรียกว่า port แล้วให้ชั้นนอกเป็นผู้ implement เรียกว่า adapter นี่คือเหตุผลที่แนวทางนี้ถูกเรียกอีกชื่อว่า ports and adapters หรือ hexagonal architecture

โครงสร้างโฟลเดอร์ที่ทำให้มองเห็นชั้นต่างๆ อาจมีหน้าตาแบบนี้:

Text
src/
  domain/            entities and value objects; no framework imports
    order.ts
  application/       use cases and the ports they need
    ports.ts
    place-order.ts
  infrastructure/    adapters: database, email, payment provider
    postgres-order-repository.ts
    email-notifier.ts
  interface/         HTTP handlers, CLI commands, UI
    http/orders-controller.ts
  main.ts            composition root: wires adapters into use cases

ตัวอย่างที่สมบูรณ์ด้วย TypeScript

เริ่มจาก entity ซึ่งมีแต่กฎทางธุรกิจล้วนๆ และไม่ import อะไรจากเฟรมเวิร์กเลย:

TypeScript
// domain/order.ts
export type OrderLine = { sku: string; quantity: number; unitPriceCents: number }

export class Order {
  private constructor(
    readonly id: string,
    readonly customerId: string,
    readonly lines: readonly OrderLine[]
  ) {}

  static create(id: string, customerId: string, lines: OrderLine[]): Order {
    if (lines.length === 0) throw new Error('An order needs at least one line')
    if (lines.some((line) => line.quantity <= 0)) throw new Error('Quantities must be positive')
    return new Order(id, customerId, lines)
  }

  get totalCents(): number {
    return this.lines.reduce((sum, line) => sum + line.quantity * line.unitPriceCents, 0)
  }
}

Use case อธิบายขั้นตอนการทำงานและประกาศ port ที่ตนพึ่งพา สังเกตว่า interface เป็นของชั้นแอปพลิเคชัน ไม่ใช่ของโค้ดฐานข้อมูล:

TypeScript
// application/ports.ts
import type { Order } from '../domain/order'

export interface OrderRepository {
  save(order: Order): Promise<void>
}

export interface Notifier {
  orderPlaced(order: Order): Promise<void>
}

// application/place-order.ts
import { Order, type OrderLine } from '../domain/order'
import type { Notifier, OrderRepository } from './ports'

export class PlaceOrder {
  constructor(
    private readonly orders: OrderRepository,
    private readonly notifier: Notifier
  ) {}

  async execute(input: { customerId: string; lines: OrderLine[] }) {
    const order = Order.create(crypto.randomUUID(), input.customerId, input.lines)
    await this.orders.save(order)
    await this.notifier.orderPlaced(order)
    return { orderId: order.id, totalCents: order.totalCents }
  }
}

Adapter ในชั้นนอก implement port ด้วยเทคโนโลยีจริง และ composition root เชื่อมทุกอย่างเข้าด้วยกันตอนเริ่มระบบ:

TypeScript
// infrastructure/postgres-order-repository.ts
import type { Pool } from 'pg'
import type { OrderRepository } from '../application/ports'
import type { Order } from '../domain/order'

export class PostgresOrderRepository implements OrderRepository {
  constructor(private readonly db: Pool) {}

  async save(order: Order) {
    await this.db.query('INSERT INTO orders (id, customer_id, total_cents) VALUES ($1, $2, $3)', [order.id, order.customerId, order.totalCents])
  }
}

// main.ts
const placeOrder = new PlaceOrder(new PostgresOrderRepository(pool), new EmailNotifier(mailer))

ผลตอบแทนเห็นชัดตอนทดสอบ use case ทดสอบได้ในไม่กี่มิลลิวินาทีด้วยของปลอมง่ายๆ ในหน่วยความจำ ไม่ต้องมีฐานข้อมูล ไม่ต้องมีเมลเซิร์ฟเวอร์ และไม่ต้องใช้ไลบรารี mock:

TypeScript
import { expect, test } from 'vitest'
import { PlaceOrder } from './place-order'
import type { Order } from '../domain/order'

test('places an order and notifies the customer', async () => {
  const saved: Order[] = []
  const notified: Order[] = []
  const placeOrder = new PlaceOrder({ save: async (order) => void saved.push(order) }, { orderPlaced: async (order) => void notified.push(order) })

  const result = await placeOrder.execute({
    customerId: 'customer-1',
    lines: [{ sku: 'TS-1042', quantity: 2, unitPriceCents: 1500 }],
  })

  expect(result.totalCents).toBe(3000)
  expect(saved).toHaveLength(1)
  expect(notified).toHaveLength(1)
})

หลักการ SOLID ทีละข้อ

SOLID คือหลักการออกแบบเชิงวัตถุห้าข้อที่สนับสนุนเป้าหมายเดียวกันในระดับคลาสและโมดูล

S: หลักความรับผิดชอบเดียว (Single Responsibility)

โมดูลหนึ่งควรมีเหตุผลในการเปลี่ยนแปลงเพียงข้อเดียว หมายความว่ามันควรตอบสนองคนเพียงกลุ่มเดียวหรือเรื่องเดียว คลาสใบแจ้งหนี้ที่ทั้งคำนวณภาษี สร้าง PDF และส่งอีเมล จะต้องเปลี่ยนทุกครั้งที่ฝ่ายการเงิน ฝ่ายออกแบบ หรือฝ่าย IT เปลี่ยนใจ ควรแยกเป็นตัวคำนวณ ตัวสร้างเอกสาร และตัวส่ง

O: หลักเปิด/ปิด (Open/Closed)

โค้ดควรเปิดให้ขยาย แต่ปิดไม่ให้แก้ไข คือเพิ่มพฤติกรรมด้วยการเพิ่มโค้ดใหม่ ไม่ใช่แก้โค้ดที่ทดสอบแล้ว เทคนิคที่ใช้กันทั่วไปคือ strategy pattern:

TypeScript
interface DiscountRule {
  appliesTo(order: Order): boolean
  amountCents(order: Order): number
}

const rules: DiscountRule[] = [new LoyaltyDiscount(), new BulkOrderDiscount()]

export const discountCents = (order: Order) => rules.filter((rule) => rule.appliesTo(order)).reduce((sum, rule) => sum + rule.amountCents(order), 0)

// A new promotion is a new class in the list, not another branch in a growing if/else

L: หลักการแทนที่ของ Liskov

ทุก implementation ของ interface ต้องใช้แทนได้ในทุกที่ที่ต้องการ interface นั้นโดยไม่ทำให้ผู้เรียกประหลาดใจ ReadOnlyOrderRepository ที่ implement OrderRepository แต่โยนข้อผิดพลาดเมื่อเรียก save() คือการละเมิดหลักนี้ ผู้เรียกที่พึ่งพาสัญญานั้นจะพังตอนรัน หากสิ่งใดไม่สามารถทำตามสัญญาได้ครบ ก็ต้องใช้ interface อื่นที่เล็กกว่า

I: หลักการแยก Interface

client ไม่ควรต้องพึ่งพาเมธอดที่ตนไม่ได้ใช้ เลือกใช้ interface เล็กๆ หลายตัวที่โฟกัสชัดเจน เช่น OrderReader และ OrderWriter แทน interface ใหญ่ตัวเดียวที่มียี่สิบเมธอด interface เล็กๆ implement ง่ายกว่า ทำของปลอมในเทสต์ง่ายกว่า และแก้ไขง่ายกว่า

D: หลักการกลับทิศการพึ่งพา (Dependency Inversion)

นโยบายระดับสูงควรพึ่งพา abstraction ไม่ใช่รายละเอียดที่เป็นรูปธรรม use case PlaceOrder ข้างต้นพึ่งพา interface OrderRepository ไม่ใช่ PostgreSQL ฐานข้อมูลจึงเปลี่ยนได้โดยที่ตรรกะทางธุรกิจไม่รับรู้ หลักการนี้คือเครื่องยนต์ของกฎการพึ่งพา

Design Pattern ที่ควรรู้จัก

  • Repository: ซ่อนวิธีจัดเก็บ entity ไว้หลัง interface ที่ใช้งานคล้ายคอลเลกชัน
  • Strategy: สลับอัลกอริทึมที่ใช้แทนกันได้ เช่น กฎการคิดราคา ค่าจัดส่ง หรือส่วนลด หลัง interface เดียว
  • Adapter: ห่อ API ของบุคคลที่สามด้วย interface ที่แอปพลิเคชันของคุณกำหนดเอง
  • Factory: รวมการสร้างและตรวจสอบอ็อบเจกต์ที่ซับซ้อนไว้ที่เดียว อย่างที่ Order.create ทำ
  • Observer และ domain event: ให้ส่วนอื่นของระบบตอบสนองต่อ "มีคำสั่งซื้อใหม่" โดยที่คำสั่งซื้อไม่ต้องรู้ว่าใครฟังอยู่
  • Decorator: เพิ่มการแคช การบันทึก log หรือการลองใหม่รอบ implementation ที่มีอยู่โดยไม่ต้องแก้ไขมัน

เมื่อไรไม่ควรออกแบบเกินจำเป็น

Clean Architecture เป็นเครื่องมือ ไม่ใช่ศาสนา แอป CRUD เล็กๆ ที่มีตรรกะทางธุรกิจน้อยไม่จำเป็นต้องมีสี่ชั้นและ interface อีกนับสิบ พิธีรีตองเหล่านั้นจะมีต้นทุนมากกว่าสิ่งที่ช่วยประหยัดได้ ใช้แนวคิดเหล่านี้ให้เหมาะกับความซับซ้อนและอายุการใช้งานที่คาดไว้ของโค้ด:

  • แยกกฎทางธุรกิจออกจาก controller และคอมโพเนนต์ UI เสมอ แม้ในโปรเจกต์เล็ก เพราะแทบไม่มีต้นทุน
  • นำ port และ adapter มาใช้ในจุดที่มีเหตุผลจริง เช่น บริการภายนอกที่อาจเปลี่ยน ตรรกะที่ต้องทดสอบได้เร็ว หรือมีหลายช่องทางเข้า อย่าง API, job และ CLI
  • อย่าสร้าง interface ที่มี implementation เพียงตัวเดียวและไม่มีของปลอมสำหรับเทสต์ ให้เพิ่มเมื่อความต้องการที่สองปรากฏขึ้น (you aren’t gonna need it หรือ YAGNI)
  • ค่อยๆ refactor เข้าหาสถาปัตยกรรมนี้ทีละโมดูล โดยดูจากจุดที่การเปลี่ยนแปลงสร้างความเจ็บปวดจริง

ประเด็นสำคัญ

  • ความง่ายในการดูแลมาจากการจัดการการพึ่งพา ให้กฎทางธุรกิจเป็นอิสระจากเฟรมเวิร์กและโครงสร้างพื้นฐาน
  • การพึ่งพาชี้เข้าด้านใน use case ประกาศ port และชั้นนอกเป็นผู้จัดหา adapter
  • SOLID นำแนวคิดเดียวกันมาใช้กับคลาสและโมดูล ได้แก่ ความรับผิดชอบเดียว ขยายแทนการแก้ไข สัญญาที่ตรงไปตรงมา interface ขนาดเล็ก และการพึ่งพา abstraction
  • ปรับระดับของสถาปัตยกรรมให้เหมาะกับความซับซ้อนของโจทย์

สรุป

ท้ายที่สุดแล้ว Clean Architecture คือการปกป้องโค้ดที่มีค่าที่สุดของคุณ ซึ่งก็คือกฎที่ทำให้ธุรกิจดำเนินไปได้ จากความผันผวนของเฟรมเวิร์ก ฐานข้อมูล และช่องทางการส่งมอบ ให้การพึ่งพาชี้เข้าด้านใน ให้โมดูลโฟกัสชัดเจน และทดสอบตรรกะทางธุรกิจได้โดยไม่ต้องพึ่งโครงสร้างพื้นฐาน แล้วโค้ดเบสของคุณจะยังแก้ไขได้รวดเร็วในปีที่ห้าเช่นเดียวกับปีแรก

อ่านเพิ่มเติม

  • #Architecture
  • #Clean Architecture
  • #Design Patterns
  • #SOLID
  • #TypeScript
เขียนโดย
ทีมงาน Givepo

Givepo คือบริษัทไอทีที่พัฒนาเว็บไซต์ แอปมือถือ และซอฟต์แวร์เฉพาะทาง เพื่อผลักดันธุรกิจของคุณให้ก้าวไปข้างหน้า

แชร์บทความนี้

ปรึกษาฟรี 30 นาที

พูดคุยเรื่องไอเดีย ขอบเขตงาน และงบประมาณกับทีมของเรา

นัดหมายปรึกษา

บทความอื่นๆ

บล็อก
แบ็กเอนด์และ API
·อ่าน 7 นาที

อธิบายสถาปัตยกรรมแบ็กเอนด์: REST API, Microservices และฐานข้อมูลที่ขยายได้

วิธีออกแบบ REST API ที่นักพัฒนาชอบใช้ เมื่อไรที่ Microservices คุ้มค่ากับต้นทุน วิธีออกแบบฐานข้อมูลที่รับโหลดหนักได้ และรูปแบบที่ทำให้ระบบแบ็กเอนด์เร็วและเสถียร

ดูรายละเอียด
พัฒนาเว็บ
·อ่าน 6 นาที

พัฒนาเว็บยุคใหม่ด้วย Next.js, React และ TypeScript: แนวทางปฏิบัติที่ดีที่สุด

Server Components กลยุทธ์การเรนเดอร์ ข้อมูลที่ปลอดภัยด้วยไทป์ และ Core Web Vitals คู่มือปฏิบัติจริงสำหรับสร้างเว็บแอปที่เร็วและดูแลง่าย ด้วยชุดเทคโนโลยีที่ทีมส่วนใหญ่เลือกใช้ในวันนี้

ดูรายละเอียด
AI และแมชชีนเลิร์นนิง
·อ่าน 8 นาที

AI และ Machine Learning ในการใช้งานจริง: สร้างคุณค่าทางธุรกิจได้ที่ไหนบ้าง

มองให้ทะลุกระแส เข้าใจว่า Machine Learning และ Generative AI ทำงานอย่างไรจริงๆ สร้างคุณค่าที่วัดผลได้ในงานแบบไหนบ้างในวันนี้ และจะทำโปรเจกต์ AI แรกให้ใช้งานได้จริงอย่างไร แทนที่จะจบแค่เดโม

ดูรายละเอียด