T Thingnoy
Home Design Patterns
Part 1

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

อ่าน ~214 นาที
swift ios architecture design-patterns
ทำไมต้อง 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 PatternArchitecture
scope1 หน้าจอ / 1 moduleทั้ง app / ทั้ง system
ตอบคำถาม”code ในหน้านี้จัดยังไง?""layer ต่างๆ สัมพันธ์กันยังไง?”
ตัวอย่างMVC, MVP, MVVM, TCAClean, 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 เอง

ผลคือ:

นี่คือ 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หน้าที่ตัวอย่าง
Entitiescore business objects + enterprise-wide rulesTodo, User, Order + rules เช่น “order > 10,000 ต้องอนุมัติ”
Use Casesapplication-specific business rulesFetchTodosUseCase, CreateOrderUseCase
Interface Adaptersแปลง data ระหว่าง layerViewModel, Presenter, Repository implementations, DTO mapping
Frameworks & Driversexternal toolsUIKit, 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)
ทิศทางโลกภายนอก → ApplicationApplication → โลกภายนอก
ใคร defineApplication define interfaceApplication define interface
ตัวอย่างManagingTodosStoringTodos, SendingNotifications
AdapterUI controller, REST endpoint, testAPI 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 ArchitectureHexagonal
โครงสร้างconcentric circles (4 layers)hexagon: inside vs outside
จำนวน layers4 (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 ละเอียดกว่า:

CleanOnion
inner coreEntities (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 หลายทางHexagonalPorts & Adapters, driving vs driven ชัดเจน
business logic ซับซ้อน, ใช้ DDDOnionแบ่ง domain ละเอียด 3 ชั้น
ต้องการ general guidelineCleanDependency 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 แค่ไหน?ไม่ค่อย testMVC
test business logicMVP, MVVM
test ทุก layerVIPER, TCA, Clean
UI framework?SwiftUIMVVM, TCA
UIKitMVP, MVVM, VIPER
State ซับซ้อนแค่ไหน?ง่าย (CRUD)MVC, MVVM
ซับซ้อน (real-time, offline, sync)TCA, Clean Arch
App อายุคาดว่านานแค่ไหน?Prototype / MVPMVC
1-3 ปีMVVM
3+ ปี, enterpriseClean 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 teamsModular 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 ที่ทุกคนในทีมเข้าใจ แก้ปัญหาที่มีอยู่จริง และไม่สร้างปัญหาใหม่ที่ใหญ่กว่าเดิม