HomeDesign Patterns
Part 1

ทำไมต้อง Design Pattern? ทำไมต้อง Architecture?

อ่าน ~137 นาที
ทำไมต้อง Design Pattern? ทำไมต้อง Architecture?
Table of Contents

ทำไมต้อง Design Pattern? ทำไมต้อง Architecture?

“ไม่มี pattern ไหนที่ดีที่สุด มีแต่ pattern ที่เหมาะกับบริบทที่สุด”


Part 1 — ทำไมต้องมี Structure?

ปัญหาที่แท้จริง

ลองนึกภาพ app ง่ายๆ สักตัว — หน้าจอแสดง list ของ todo items จาก API แล้วกดเพิ่มได้

ถ้าเขียนแบบไม่คิดอะไรเลย ทุกอย่างจะอยู่ใน ViewController ตัวเดียว:

class TodoViewController: UIViewController {
    var todos: [Todo] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        // UI setup
        let url = URL(string: "https://api.example.com/todos")!
        URLSession.shared.dataTask(with: url) { data, _, _ in
            let todos = try! JSONDecoder().decode([Todo].self, from: data!)
            self.todos = todos
            DispatchQueue.main.async {
                self.tableView.reloadData()
            }
        }.resume()
    }

    func addTodo(_ title: String) {
        // validation
        guard !title.isEmpty else { return }
        // networking
        var request = URLRequest(url: URL(string: "https://api.example.com/todos")!)
        request.httpMethod = "POST"
        request.httpBody = try! JSONEncoder().encode(["title": title])
        URLSession.shared.dataTask(with: request) { data, _, _ in
            // parse response, update UI, handle error... ทุกอย่างอยู่ที่นี่
        }.resume()
    }
}

ตอน feature มี 2-3 อัน มันก็โอเค แต่พอ app โตขึ้น:

ทุกปัญหาเกิดจากสิ่งเดียว: ทุกอย่างผูกกันหมด — UI, networking, business logic, navigation อยู่ในก้อนเดียว

หลักการ: Separation of Concerns

วิธีแก้ที่เป็นสากล: แยกความรับผิดชอบออกจากกัน

แต่คำถามคือ “แยกยังไง?” — และนี่คือจุดที่ Design Pattern กับ Architecture เข้ามา

แผนที่: 2 คำถามที่ต้องตอบ

การจัด structure ของ app มี 2 level ที่ต่างกัน:

คำถามที่ 1: "หน้าจอนี้จัดโค้ดยังไง?"
→ ตอบด้วย Design Pattern (MVC, MVP, MVVM, VIPER, TCA)
→ scope: หนึ่งหน้าจอ / หนึ่ง feature

คำถามที่ 2: "ทั้ง app จัดโครงสร้างยังไง?"
→ ตอบด้วย Architecture (Clean, Hexagonal, Onion, Modular)
→ scope: ทั้ง app / ทั้ง system
Design Pattern Architecture
scope 1 หน้าจอ / 1 module ทั้ง app / ทั้ง system
ตอบคำถาม “code ในหน้านี้จัดยังไง?” “layer ต่างๆ สัมพันธ์กันยังไง?”
ตัวอย่าง MVC, MVP, MVVM, TCA Clean, Hexagonal, Onion
อุปมา วิธีจัดห้อง (เฟอร์นิเจอร์วางยังไง) ผังอาคาร (กี่ชั้น บันไดอยู่ตรงไหน)

app หนึ่งอาจใช้ Clean Architecture (3 layers) แล้วใน Presentation layer ใช้ MVVM เป็น design pattern — มันอยู่คนละ level ใช้ด้วยกันได้

บทความนี้จะไปทีละ level — เริ่มจาก Pattern (ใกล้ตัว จับต้องได้) แล้วค่อย zoom ออกไป Architecture (ภาพใหญ่)


Part 2 — Design Patterns: จัดการหนึ่งหน้าจอ

MVC — Model-View-Controller

Why MVC?

MVC คือคำตอบแรกสุดที่ Apple เลือกใช้ใน UIKit แนวคิดง่าย:

User Action → Controller → Model

               View ← (update)
// Model
struct Todo: Codable {
    let id: Int
    let title: String
    var isDone: Bool
}

// Service (part of Model layer)
class TodoService {
    func fetchTodos(completion: @escaping ([Todo]) -> Void) {
        // networking logic ย้ายมาอยู่ที่นี่
    }
}

// Controller
class TodoViewController: UIViewController {
    let service = TodoService()
    var todos: [Todo] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        service.fetchTodos { [weak self] todos in
            self?.todos = todos
            self?.tableView.reloadData()
        }
    }
}

ได้อะไร?

แล้วทำไมยังไม่พอ?

ปัญหาของ MVC ใน iOS คือ UIViewController เป็นทั้ง Controller และ View ในตัว มัน own ทั้ง view lifecycle และ logic:

class TodoViewController: UIViewController {
    // View concerns
    func setupUI() { ... }
    func tableView(_ tableView: UITableView, cellForRowAt...) { ... }

    // Controller concerns
    func didTapAdd() {
        guard isValid(titleField.text) else { ... }  // validation
        service.addTodo(titleField.text!) { ... }      // business logic
        updateBadgeCount()                              // presentation logic
    }
}

ผลคือ ViewController ทำหน้าที่เยอะเกินไป — เรียกกันว่า Massive View Controller ไฟล์ยาวหลายพันบรรทัด test ยาก เพราะจะ test validation logic ก็ต้องสร้าง ViewController ขึ้นมาทั้งตัว

MVC เหมาะกับ:


MVP — Model-View-Presenter

Why MVP?

ปัญหาของ MVC คือ Controller ทำงานเยอะเกินและ test ยาก MVP แก้ตรงนี้โดยดึง presentation logic ออกมาเป็น Presenter ที่เป็น plain Swift object (ไม่ import UIKit):

User Action → View → Presenter → Model
                ↑         |
                └─────────┘ (update view)
// Presenter — plain Swift, no UIKit
protocol TodoView: AnyObject {
    func showTodos(_ todos: [Todo])
    func showError(_ message: String)
    func showLoading()
}

class TodoPresenter {
    weak var view: TodoView?
    let service: TodoServiceProtocol

    init(service: TodoServiceProtocol) {
        self.service = service
    }

    func viewDidLoad() {
        view?.showLoading()
        service.fetchTodos { [weak self] result in
            switch result {
            case .success(let todos):
                self?.view?.showTodos(todos)
            case .failure(let error):
                self?.view?.showError(error.localizedDescription)
            }
        }
    }

    func didTapAdd(title: String?) {
        guard let title, !title.isEmpty else {
            view?.showError("Title cannot be empty")
            return
        }
        service.addTodo(title: title) { [weak self] result in
            // handle result
        }
    }
}

// ViewController ทำหน้าที่เป็น View เท่านั้น
class TodoViewController: UIViewController, TodoView {
    var presenter: TodoPresenter!

    override func viewDidLoad() {
        super.viewDidLoad()
        presenter.viewDidLoad()
    }

    func showTodos(_ todos: [Todo]) {
        self.todos = todos
        tableView.reloadData()
    }

    func showError(_ message: String) {
        // show alert
    }

    func showLoading() {
        // show spinner
    }

    @IBAction func addTapped() {
        presenter.didTapAdd(title: titleField.text)
    }
}

ได้อะไร?

class MockTodoView: TodoView {
    var shownError: String?
    func showError(_ message: String) { shownError = message }
    func showTodos(_ todos: [Todo]) { }
    func showLoading() { }
}

func testAddEmptyTitle() {
    let presenter = TodoPresenter(service: MockService())
    let mockView = MockTodoView()
    presenter.view = mockView

    presenter.didTapAdd(title: "")

    XCTAssertEqual(mockView.shownError, "Title cannot be empty")
}

แล้วทำไมยังไม่พอ?

MVP เหมาะกับ:


MVVM — Model-View-ViewModel

Why MVVM?

MVP ต้อง manually call view methods ทีละตัว MVVM แก้ตรงนี้ด้วยแนวคิด data binding — แทนที่ Presenter จะสั่ง View ให้ update MVVM ให้ ViewModel expose state ออกมา แล้ว View ไป observe เอาเอง:

User Action → View → ViewModel → Model
                ↑         |
                └─────────┘ (data binding / observe)
// ViewModel
class TodoViewModel: ObservableObject {
    @Published var todos: [Todo] = []
    @Published var errorMessage: String?
    @Published var isLoading = false

    private let service: TodoServiceProtocol

    init(service: TodoServiceProtocol) {
        self.service = service
    }

    func loadTodos() {
        isLoading = true
        service.fetchTodos { [weak self] result in
            self?.isLoading = false
            switch result {
            case .success(let todos):
                self?.todos = todos
            case .failure(let error):
                self?.errorMessage = error.localizedDescription
            }
        }
    }

    func addTodo(title: String) {
        guard !title.isEmpty else {
            errorMessage = "Title cannot be empty"
            return
        }
        // ...
    }
}

ใน SwiftUI binding เกิดขึ้นโดยธรรมชาติ:

struct TodoListView: View {
    @StateObject var viewModel = TodoViewModel(service: TodoService())

    var body: some View {
        List(viewModel.todos) { todo in
            Text(todo.title)
        }
        .overlay {
            if viewModel.isLoading {
                ProgressView()
            }
        }
        .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
            // ...
        }
        .onAppear {
            viewModel.loadTodos()
        }
    }
}

ใน UIKit ต้องพึ่ง Combine หรือ closure-based binding:

class TodoViewController: UIViewController {
    let viewModel = TodoViewModel(service: TodoService())
    var cancellables = Set<AnyCancellable>()

    override func viewDidLoad() {
        super.viewDidLoad()

        viewModel.$todos
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in self?.tableView.reloadData() }
            .store(in: &cancellables)

        viewModel.$isLoading
            .receive(on: DispatchQueue.main)
            .sink { [weak self] loading in self?.spinner.isHidden = !loading }
            .store(in: &cancellables)

        viewModel.loadTodos()
    }
}

ได้อะไร?

แล้วทำไมยังไม่พอ?

MVVM เหมาะกับ:


VIPER — View-Interactor-Presenter-Entity-Router

Why VIPER?

MVVM ยังมีปัญหาเรื่อง navigation และ ViewModel ที่รับผิดชอบเยอะเกิน VIPER แก้ด้วยการ แยกละเอียดขึ้นอีก:

View ↔ Presenter ↔ Interactor → Entity

          Router
// Router — จัดการ navigation
protocol TodoRouting {
    func navigateToDetail(todo: Todo)
    func navigateToAddTodo()
}

class TodoRouter: TodoRouting {
    weak var viewController: UIViewController?

    func navigateToDetail(todo: Todo) {
        let detailVC = TodoDetailBuilder.build(todo: todo)
        viewController?.navigationController?.pushViewController(detailVC, animated: true)
    }
}

// Interactor — pure business logic
protocol TodoInteracting {
    func fetchTodos()
    func addTodo(title: String)
}

class TodoInteractor: TodoInteracting {
    weak var presenter: TodoPresenting?
    let service: TodoServiceProtocol

    func fetchTodos() {
        service.fetchTodos { [weak self] result in
            switch result {
            case .success(let todos):
                self?.presenter?.didFetchTodos(todos)
            case .failure(let error):
                self?.presenter?.didFailWithError(error)
            }
        }
    }
}

// Presenter — presentation logic + routing decisions
class TodoPresenter: TodoPresenting {
    weak var view: TodoViewProtocol?
    var interactor: TodoInteracting
    var router: TodoRouting

    func viewDidLoad() {
        view?.showLoading()
        interactor.fetchTodos()
    }

    func didFetchTodos(_ todos: [Todo]) {
        let viewModels = todos.map { TodoCellViewModel(title: $0.title, done: $0.isDone) }
        view?.showTodos(viewModels)
    }

    func didSelectTodo(at index: Int) {
        router.navigateToDetail(todo: todos[index])
    }
}

ได้อะไร?

แล้วทำไมหลายทีมหนีจาก VIPER?

VIPER เหมาะกับ:


The Composable Architecture (TCA)

Why TCA?

MVVM กับ SwiftUI ใช้ง่ายก็จริง แต่พอ app ซับซ้อนขึ้น state management เริ่มมีปัญหา:

TCA (จาก Point-Free) แก้ด้วยแนวคิด unidirectional data flow:

View → send(Action) → Reducer → new State → View updates

                      Effects (API calls, etc.)

                      Action (response) → Reducer → ...
@Reducer
struct TodoFeature {
    @ObservableState
    struct State: Equatable {
        var todos: [Todo] = []
        var isLoading = false
        var errorMessage: String?
    }

    enum Action {
        case onAppear
        case todosResponse(Result<[Todo], Error>)
        case addTapped(title: String)
        case deleteTodo(IndexSet)
    }

    @Dependency(\.todoClient) var todoClient

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .onAppear:
                state.isLoading = true
                return .run { send in
                    let result = await Result { try await todoClient.fetchAll() }
                    await send(.todosResponse(result))
                }

            case .todosResponse(.success(let todos)):
                state.isLoading = false
                state.todos = todos
                return .none

            case .todosResponse(.failure(let error)):
                state.isLoading = false
                state.errorMessage = error.localizedDescription
                return .none

            case .addTapped(let title):
                guard !title.isEmpty else {
                    state.errorMessage = "Title cannot be empty"
                    return .none
                }
                // ...
                return .none

            case .deleteTodo(let indexSet):
                state.todos.remove(atOffsets: indexSet)
                return .none
            }
        }
    }
}

ได้อะไร?

@Test func addEmptyTitle() async {
    let store = TestStore(initialState: TodoFeature.State()) {
        TodoFeature()
    }

    await store.send(.addTapped(title: "")) {
        $0.errorMessage = "Title cannot be empty"
    }
}

แล้วทำไมไม่ใช้ทุก project?

TCA เหมาะกับ:


สังเกต: ยิ่งแก้ ยิ่งเห็นปัญหาที่ลึกกว่า

ถ้ามองย้อน evolution ของ design patterns:

MVC  → "แยก data ออกจาก UI"
MVP  → "แยก logic ออกมา test ได้"
MVVM → "ให้ state sync อัตโนมัติ"
VIPER → "แยกทุก concern รวมถึง navigation"
TCA  → "ควบคุม state flow ทั้งหมด"

แต่ละตัวแก้ปัญหาของตัวก่อนหน้าได้ดีขึ้น แต่สังเกตว่า ทุกตัวยังพูดถึงแค่ “code ในหน้าจอนี้” — ยังมีคำถามที่ไม่มี pattern ไหนตอบ:

เมื่อ zoom ออกจาก “หนึ่งหน้าจอ” ไปมอง “ทั้ง app” — เราต้องการอะไรที่ใหญ่กว่า design pattern

นั่นคือ Architecture


Part 3 — Architecture: จัดการทั้ง App

ปัญหาที่ Design Pattern ตอบไม่ได้

ลองดู MVVM ที่ดูเหมือนจัดดีแล้ว:

class TodoViewModel: ObservableObject {
    @Published var todos: [Todo] = []

    func loadTodos() {
        let url = URL(string: "https://api.example.com/todos")!
        URLSession.shared.dataTask(with: url) { data, _, _ in
            let todos = try! JSONDecoder().decode([Todo].self, from: data!)
            let sorted = todos.sorted { !$0.isDone && $1.isDone }
            DispatchQueue.main.async { self.todos = sorted }
        }.resume()
    }
}

UI logic แยกแล้วก็จริง แต่ ViewModel ยังรู้มากเกินไป:

ถ้าวันหนึ่ง:

นี่คือปัญหาของ coupling ข้าม layer — ปัญหาที่ต้องแก้ด้วย architecture


Dependency Inversion — หลักการที่ทุก Architecture ตั้งอยู่บน

ก่อนจะดู Clean/Hexagonal/Onion ต้องเข้าใจ concept นี้ก่อน เพราะ ทั้ง 3 ตัวเป็นแค่ 3 วิธีจัดโครงสร้างรอบหลักการเดียวกัน

ปัญหา: dependency ไหลผิดทาง

สมมติแบ่ง code เป็น layer แบบตรงไปตรงมา (Layered Architecture):

Presentation → Business Logic → Data Access (API, DB)

Business Logic (ส่วนที่มีค่าที่สุด เปลี่ยนน้อยที่สุด) กลับ depend on Data Access (ส่วนที่เปลี่ยนบ่อย — เปลี่ยน API endpoint, เปลี่ยน database, เพิ่ม cache):

class TodoService {
    let api = TodoAPI()  // ← ผูกตรงกับ concrete class

    func getPendingFirst() async throws -> [Todo] {
        let dtos = try await api.fetchAll()       // ← ต้องยิง API จริง ถึงจะ test ได้
        return dtos.map { Todo(from: $0) }
            .sorted { !$0.isDone && $1.isDone }
    }
}

วิธีแก้: กลับทิศ dependency ด้วย protocol

แทนที่ Business Logic จะเรียก TodoAPI ตรงๆ ให้มัน define protocol (สัญญา) ว่าอยากได้อะไร แล้วให้ Data layer มา implement:

// Business Logic layer DEFINE สัญญา
protocol TodoRepository {
    func fetchAll() async throws -> [Todo]
}

// Business Logic layer ใช้สัญญา (ไม่สนว่าใครทำจริง)
struct FetchTodosUseCase {
    let repository: TodoRepository  // ← depend on protocol, not concrete

    func execute() async throws -> [Todo] {
        let todos = try await repository.fetchAll()
        return todos.sorted { !$0.isDone && $1.isDone }
    }
}

// Data layer IMPLEMENT สัญญา
class TodoAPIRepository: TodoRepository {
    func fetchAll() async throws -> [Todo] {
        let (data, _) = try await URLSession.shared.data(from: apiURL)
        return try JSONDecoder().decode([Todo].self, from: data)
    }
}

// Test ก็ IMPLEMENT สัญญาอีกแบบ
class MockTodoRepository: TodoRepository {
    var stubbedTodos: [Todo] = []
    func fetchAll() async throws -> [Todo] { stubbedTodos }
}

ตอนนี้ dependency กลับทิศ:

ก่อน:  Business Logic  →  Data Access
       (depend on concrete)

หลัง:  Business Logic  ←  Data Access
       (define protocol)    (implement protocol)

Business Logic ไม่รู้จัก URLSession ไม่รู้จัก Core Data ไม่รู้จักอะไรเลย มันรู้จักแค่ TodoRepository protocol ที่มันเป็นคน define เอง

ผลคือ:

🎮 ลองเล่น: Interactive Demo — ดู arrow พลิกทิศ — กด Invert แล้ว swap REST → GraphQL → Core Data → Mock เห็นว่า Business Logic ไม่ขยับเลย

นี่คือ foundation — ต่อไปจะดูว่า Clean, Hexagonal, Onion จัดโครงสร้างรอบหลักการนี้ยังไง (ต่างกันแค่ lens ที่มอง)


Clean Architecture (Uncle Bob, 2012)

Why Clean Architecture?

Robert C. Martin (Uncle Bob) จัดโครงสร้างเป็น concentric circles — ยิ่งอยู่ข้างในยิ่งสำคัญ ยิ่ง stable:

┌─────────────────────────────────────────────┐
│  Frameworks & Drivers (outermost)           │
│  ┌─────────────────────────────────────┐    │
│  │  Interface Adapters                  │    │
│  │  ┌─────────────────────────────┐    │    │
│  │  │  Use Cases                   │    │    │
│  │  │  ┌─────────────────────┐    │    │    │
│  │  │  │  Entities (core)    │    │    │    │
│  │  │  └─────────────────────┘    │    │    │
│  │  └─────────────────────────────┘    │    │
│  └─────────────────────────────────────┘    │
└─────────────────────────────────────────────┘

The Dependency Rule: source code dependencies ชี้เข้าข้างในเท่านั้น

4 ชั้น (จากในสุดออกมา):

Layer หน้าที่ ตัวอย่าง
Entities core business objects + enterprise-wide rules Todo, User, Order + rules เช่น “order > 10,000 ต้องอนุมัติ”
Use Cases application-specific business rules FetchTodosUseCase, CreateOrderUseCase
Interface Adapters แปลง data ระหว่าง layer ViewModel, Presenter, Repository implementations, DTO mapping
Frameworks & Drivers external tools UIKit, SwiftUI, URLSession, CoreData, Firebase

กฎเหล็ก: The Dependency Rule

Dependency ชี้เข้าข้างในเท่านั้น — วงนอกรู้จักวงใน แต่วงในไม่รู้จักวงนอก:

// === Entities (innermost) ===
struct Todo {
    let id: UUID
    var title: String
    var isDone: Bool
    var createdAt: Date

    var isOverdue: Bool {
        !isDone && Date().timeIntervalSince(createdAt) > 7 * 24 * 3600
    }
}

// === Use Cases ===
protocol TodoRepository {
    func fetchAll() async throws -> [Todo]
    func save(_ todo: Todo) async throws
}

struct FetchTodosUseCase {
    let repository: TodoRepository

    func execute() async throws -> [Todo] {
        let todos = try await repository.fetchAll()
        return todos.sorted { !$0.isDone && $1.isDone }
    }
}

struct ToggleTodoUseCase {
    let repository: TodoRepository

    func execute(todo: Todo) async throws {
        var updated = todo
        updated.isDone.toggle()
        try await repository.save(updated)
    }
}

// === Interface Adapters ===
class TodoListViewModel: ObservableObject {
    @Published var todos: [TodoRowItem] = []
    private let fetchTodos: FetchTodosUseCase
    private let toggleTodo: ToggleTodoUseCase

    init(fetchTodos: FetchTodosUseCase, toggleTodo: ToggleTodoUseCase) {
        self.fetchTodos = fetchTodos
        self.toggleTodo = toggleTodo
    }

    func load() async {
        do {
            let todos = try await fetchTodos.execute()
            self.todos = todos.map { TodoRowItem(todo: $0) }
        } catch {
            // handle error
        }
    }
}

class TodoAPIRepository: TodoRepository {
    let client: HTTPClient

    func fetchAll() async throws -> [Todo] {
        let dtos: [TodoDTO] = try await client.get("/todos")
        return dtos.map { $0.toDomain() }
    }

    func save(_ todo: Todo) async throws {
        let dto = TodoDTO(from: todo)
        try await client.put("/todos/\(todo.id)", body: dto)
    }
}

// === Frameworks & Drivers (outermost) ===
// SwiftUI View, URLSession-based HTTPClient, CoreData, etc.

ได้อะไร?

ทำไมหลายทีม implement แล้วปวดหัว?

Clean Architecture เหมาะกับ:


Hexagonal Architecture / Ports & Adapters (Alistair Cockburn, 2005)

Why Hexagonal?

Hexagonal (หรือ Ports & Adapters) เกิดก่อน Clean Architecture มอง structure ต่างออกไป — ไม่คิดเป็นชั้นๆ แต่คิดเป็น inside/outside:

                  ┌─────────────────────┐
   Driving        │                     │        Driven
   (input)        │    Application      │        (output)
                  │      Core           │
  ┌─────┐  Port  │                     │  Port  ┌─────────┐
  │ UI  │──────→ │  Business Logic     │ ──────→│ Database │
  └─────┘        │  + Domain Model     │        └─────────┘
  ┌─────┐  Port  │                     │  Port  ┌─────────┐
  │ API │──────→ │                     │ ──────→│ Email    │
  └─────┘        │                     │        └─────────┘
  ┌─────┐  Port  │                     │  Port  ┌─────────┐
  │Test │──────→ │                     │ ──────→│ Mock     │
  └─────┘        └─────────────────────┘        └─────────┘

key concepts:

มี 2 ประเภท port:

Driving Port (input) Driven Port (output)
ทิศทาง โลกภายนอก → Application Application → โลกภายนอก
ใคร define Application define interface Application define interface
ตัวอย่าง ManagingTodos StoringTodos, SendingNotifications
Adapter UI controller, REST endpoint, test API client, SMTP client, mock
// === APPLICATION CORE ===

// Driving Port — "application ทำอะไรได้บ้าง"
protocol ManagingTodos {
    func allTodos() async throws -> [Todo]
    func addTodo(title: String) async throws -> Todo
    func toggleTodo(id: UUID) async throws
}

// Driven Port — "application ต้องการอะไรจากข้างนอก"
protocol StoringTodos {
    func fetchAll() async throws -> [Todo]
    func save(_ todo: Todo) async throws
}

protocol SendingNotifications {
    func notify(user: User, message: String) async throws
}

// Application Service — implements driving port, uses driven ports
class TodoService: ManagingTodos {
    let store: StoringTodos
    let notifier: SendingNotifications

    init(store: StoringTodos, notifier: SendingNotifications) {
        self.store = store
        self.notifier = notifier
    }

    func addTodo(title: String) async throws -> Todo {
        let todo = Todo(id: UUID(), title: title, isDone: false)
        try await store.save(todo)
        try await notifier.notify(user: currentUser, message: "Todo added: \(title)")
        return todo
    }

    func toggleTodo(id: UUID) async throws {
        var todos = try await store.fetchAll()
        guard let index = todos.firstIndex(where: { $0.id == id }) else { return }
        todos[index].isDone.toggle()
        try await store.save(todos[index])
    }
}

// === ADAPTERS (outside the hexagon) ===

// Driving Adapter — SwiftUI connects to driving port
struct TodoListView: View {
    let service: ManagingTodos

    var body: some View { /* ... */ }
}

// Driven Adapter — SQLite
class SQLiteTodoStore: StoringTodos {
    func fetchAll() async throws -> [Todo] { /* SQLite queries */ }
    func save(_ todo: Todo) async throws { /* SQLite insert */ }
}

// Driven Adapter — Push notification
class PushNotificationSender: SendingNotifications {
    func notify(user: User, message: String) async throws { /* APNs */ }
}

// Driven Adapter — Mock for testing
class MockTodoStore: StoringTodos {
    var todos: [Todo] = []
    func fetchAll() async throws -> [Todo] { todos }
    func save(_ todo: Todo) async throws { todos.append(todo) }
}

Hexagonal vs Clean — ต่างกันตรงไหน?

Clean Architecture Hexagonal
โครงสร้าง concentric circles (4 layers) hexagon: inside vs outside
จำนวน layers 4 (Entities, Use Cases, Adapters, Frameworks) 2 (Application Core, Adapters)
แยก entity กับ use case? แยกชัด 2 layers รวมอยู่ใน Application Core
เน้น Dependency Rule (ชี้เข้าข้างใน) Ports & Adapters (ช่องทางเข้า/ออกชัดเจน)
จุดเด่น business rule หลายระดับ symmetry — UI กับ Database เป็น “external” เท่าเทียมกัน

จุดต่างที่สำคัญที่สุด: Hexagonal มองว่า UI กับ Database เท่าเทียมกัน — ทั้งคู่เป็นแค่ “adapter” ที่ต่อเข้ากับ core ผ่าน port โดยเฉพาะเรื่อง driving vs driven ที่ช่วยแยกชัดว่า “ใครเรียกเรา” vs “เราเรียกใคร”

Hexagonal เหมาะกับ:


Onion Architecture (Jeffrey Palermo, 2008)

Why Onion?

Onion Architecture อยู่กลางๆ ระหว่าง Clean กับ Hexagonal — เป็น concentric circles เหมือน Clean แต่ focus ที่ Domain Model เป็นศูนย์กลาง:

┌───────────────────────────────────────┐
│         Infrastructure                │  UI, DB, API, Frameworks
│  ┌───────────────────────────────┐    │
│  │     Application Services      │    │  Use Cases, orchestration
│  │  ┌───────────────────────┐    │    │
│  │  │  Domain Services       │    │    │  Business rules ข้าม entities
│  │  │  ┌───────────────┐    │    │    │
│  │  │  │ Domain Model  │    │    │    │  Entities + Value Objects
│  │  │  └───────────────┘    │    │    │
│  │  └───────────────────────┘    │    │
│  └───────────────────────────────┘    │
└───────────────────────────────────────┘

ต่างจาก Clean Architecture ตรงที่ แบ่ง business logic ละเอียดกว่า:

Clean Onion
inner core Entities (data + basic rules) Domain Model (Entities + Value Objects + Domain Events)
business logic กี่ชั้น 2 (Entities, Use Cases) 3 (Domain Model, Domain Services, App Services)
เน้น Dependency Rule ทั่วไป Domain-Driven Design (DDD) concepts
// === Domain Model (innermost) ===
struct Money: Equatable {
    let amount: Decimal
    let currency: Currency

    static func + (lhs: Money, rhs: Money) -> Money {
        precondition(lhs.currency == rhs.currency)
        return Money(amount: lhs.amount + rhs.amount, currency: lhs.currency)
    }
}

struct Order {
    let id: UUID
    var items: [OrderItem]
    var status: OrderStatus

    var total: Money {
        items.reduce(Money(amount: 0, currency: .thb)) { $0 + $1.subtotal }
    }

    mutating func approve() throws {
        guard status == .pending else { throw OrderError.cannotApprove(status) }
        guard total.amount > 0 else { throw OrderError.emptyOrder }
        status = .approved
    }
}

// === Domain Services (cross-entity business rules) ===
protocol PricingPolicy {
    func applyDiscount(to order: Order, customer: Customer) -> Money
}

struct VIPPricingPolicy: PricingPolicy {
    func applyDiscount(to order: Order, customer: Customer) -> Money {
        guard customer.isVIP else { return Money(amount: 0, currency: .thb) }
        return Money(amount: order.total.amount * 0.1, currency: .thb)
    }
}

// === Application Services (orchestration) ===
protocol OrderRepository {
    func findById(_ id: UUID) async throws -> Order?
    func save(_ order: Order) async throws
}

struct ApproveOrderUseCase {
    let orderRepo: OrderRepository
    let pricingPolicy: PricingPolicy
    let notifier: OrderNotifying

    func execute(orderId: UUID) async throws {
        guard var order = try await orderRepo.findById(orderId) else {
            throw OrderError.notFound
        }
        try order.approve()
        try await orderRepo.save(order)
        try await notifier.notifyApproved(order)
    }
}

Onion เหมาะกับ:


3 Architecture เดียวกัน ต่าง lens

ความจริงที่สำคัญที่สุดของ section นี้:

Clean, Hexagonal, Onion ใช้หลักการเดียวกัน — Dependency Inversion — ต่างกันแค่วิธีอธิบายและจัดโครงสร้าง

Hexagonal:  2 zones  — Application Core | Adapters
Clean:      4 layers — Entities | Use Cases | Adapters | Frameworks
Onion:      4 layers — Domain Model | Domain Services | App Services | Infrastructure

ทั้ง 3 ตัวเห็นตรงกันว่า:

  1. Business logic อยู่ตรงกลาง
  2. Dependencies ชี้เข้าข้างใน
  3. External concerns (UI, DB, API) อยู่ข้างนอก เปลี่ยนได้

วิธีเลือก:

สถานการณ์ เลือก เหตุผล
external system เยอะ, input หลายทาง Hexagonal Ports & Adapters, driving vs driven ชัดเจน
business logic ซับซ้อน, ใช้ DDD Onion แบ่ง domain ละเอียด 3 ชั้น
ต้องการ general guideline Clean Dependency Rule เป็น concept กว้างที่สุด
app ง่ายๆ ไม่ต้องซีเรียส ไม่ต้องเลือก MVVM ธรรมดาก็พอ

Architecture อื่นๆ ที่ควรรู้จัก

Modular Architecture

ไม่ได้แข่งกับ Clean/Hexagonal แต่เป็น orthogonal concept — แทนที่จะแบ่งตาม layer แบ่งตาม feature:

App/
├── TodoModule/        ← feature module
│   ├── Domain/
│   ├── Data/
│   └── Presentation/
├── AuthModule/        ← feature module
│   ├── Domain/
│   ├── Data/
│   └── Presentation/
└── SharedModule/      ← shared utilities

แต่ละ module เป็น Swift Package ที่ build ได้อิสระ ข้อดี:

ในทางปฏิบัติมักใช้ Modular + Clean/Hexagonal — Modular แบ่ง feature, Clean/Hexagonal จัดโครงสร้างในแต่ละ module

Event-Driven Architecture

แทนที่ component จะเรียกกันตรง ใช้ events สื่อสาร:

// แทนที่จะ...
class OrderService {
    let emailService: EmailService
    let analyticsService: AnalyticsService

    func placeOrder(_ order: Order) {
        save(order)
        emailService.sendConfirmation(order)     // tight coupling
        analyticsService.trackPurchase(order)     // tight coupling
    }
}

// ใช้ events...
class OrderService {
    let eventBus: EventBus

    func placeOrder(_ order: Order) {
        save(order)
        eventBus.publish(OrderPlaced(order: order))  // ไม่รู้จักใครเลย
    }
}

// listeners register separately
eventBus.subscribe(OrderPlaced.self) { event in
    emailService.sendConfirmation(event.order)
}
eventBus.subscribe(OrderPlaced.self) { event in
    analyticsService.trackPurchase(event.order)
}

เหมาะกับ: system ที่มี side effects เยอะ (notification, analytics, audit log) และไม่อยากให้ core logic ต้องรู้จัก side effect ทั้งหมด


Part 4 — ประกอบร่าง

Big Picture: ทุกอย่างเชื่อมกันยังไง

                    Architecture (app-level structure)
                    ┌─────────────────────────────────┐
                    │                                 │
              ┌─────┴──────┐    ┌──────┴───────┐    ┌─┴───────────┐
              │   Clean    │    │  Hexagonal   │    │   Onion     │
              │ (4 layers) │    │(ports/adapt) │    │ (DDD-focus) │
              └─────┬──────┘    └──────┬───────┘    └─┬───────────┘
                    │                  │               │
                    └──────────┬───────┘───────────────┘

                    All based on: Dependency Inversion

                    ┌──────────┴──────────┐
                    │  Presentation Layer  │ ← Design Pattern เสียบตรงนี้
                    │                     │
              ┌─────┴──┐ ┌──┴──┐ ┌──┴───┐ ┌──┴──┐
              │  MVC   │ │ MVP │ │ MVVM │ │ TCA │
              └────────┘ └─────┘ └──────┘ └─────┘

Decision Framework: เลือกยังไง

คำถามที่ต้องตอบก่อนเลือก Pattern

คำถาม ถ้าตอบว่า… Pattern ที่เหมาะ
ทีมมีกี่คน? 1-2 คน MVC, MVVM
3-5 คน MVVM, MVP
5+ คน VIPER, Clean Arch
ต้อง unit test แค่ไหน? ไม่ค่อย test MVC
test business logic MVP, MVVM
test ทุก layer VIPER, TCA, Clean
UI framework? SwiftUI MVVM, TCA
UIKit MVP, MVVM, VIPER
State ซับซ้อนแค่ไหน? ง่าย (CRUD) MVC, MVVM
ซับซ้อน (real-time, offline, sync) TCA, Clean Arch
App อายุคาดว่านานแค่ไหน? Prototype / MVP MVC
1-3 ปี MVVM
3+ ปี, enterprise Clean Arch + MVVM/VIPER

คำถามที่ต้องตอบก่อนเลือก Architecture

คำถาม ถ้าตอบว่า… Architecture ที่เหมาะ
มี business rule ซับซ้อนมั้ย? แทบไม่มี (CRUD) ไม่ต้องมี architecture layer แยก
มี แต่ไม่ซับซ้อน Clean Architecture (simple version)
ซับซ้อนมาก (DDD-level) Onion Architecture
มี external system กี่ตัว? 1-2 (API + DB) Clean ก็พอ
5+ (API, DB, push, analytics, payment…) Hexagonal — ports/adapters ชัด
ต้อง swap infrastructure มั้ย? ไม่น่าเปลี่ยน อย่า over-abstract
อาจเปลี่ยน Hexagonal หรือ Clean
ทีมแบ่งยังไง? feature teams Modular Architecture
layer teams (frontend/backend/domain) Clean/Onion layer structure

ความจริงที่ไม่ค่อยมีใครพูด

1. ผสมได้

ไม่มีกฎว่าทุกหน้าจอต้องใช้ pattern เดียวกัน:

2. Pattern ไม่ใช่ศาสนา

ถ้า MVVM ViewModel เริ่มบวม ไม่จำเป็นต้องเปลี่ยนไป VIPER ทั้ง app อาจแค่แยก ViewModel เป็น sub-ViewModels หรือดึง logic ออกเป็น use case ก็พอ

3. Consistency สำคัญกว่า “ดีที่สุด”

ทีม 10 คนที่ทุกคนเขียน MVVM แบบเดียวกัน > ทีม 10 คนที่ 3 คนใช้ VIPER, 4 คนใช้ MVVM, 3 คนใช้ TCA

4. เริ่มง่ายก่อน แล้ว evolve

MVC → "ViewController บวมแล้ว" → ดึง logic ออกมาเป็น ViewModel → MVVM
MVVM → "state sync ยาก" → เพิ่ม unidirectional flow → TCA-like
MVVM → "navigation วุ่นวาย" → เพิ่ม Router/Coordinator → hybrid
MVVM → "ViewModel รู้มากเกิน" → แยก Use Case + Repository → Clean Architecture

ไม่จำเป็นต้องออกแบบ architecture สมบูรณ์แบบตั้งแต่วันแรก architecture ที่ดีคือ architecture ที่ evolve ตาม requirement ที่เปลี่ยนไป


สรุป — วิธีคิดเบื้องหลังการเลือก

ทุก design pattern และ architecture เกิดจากคำถามเดียว: “ตรงไหนที่เจ็บ?”

ระดับหน้าจอ (Design Pattern):

ระดับ app (Architecture):

อย่าเลือก pattern หรือ architecture เพราะมันอยู่ใน trend หรือเพราะ blog ดังๆ บอก เลือกเพราะมันแก้ปัญหาที่ทีมเจออยู่จริงๆ

Pattern ที่ดีที่สุดคือ pattern ที่ทุกคนในทีมเข้าใจ แก้ปัญหาที่มีอยู่จริง และไม่สร้างปัญหาใหม่ที่ใหญ่กว่าเดิม

🎮ลองเล่น Demo