😽

Clean Architecture と DDD

に公開

ドメイン駆動設計(DDD)とは?

まずは意味のわからん DDD の略から。Domain-Driven Design。

そして、ChatGTPに放り投げる。

なんとなくわかるようなわからないような。おそらくドメイン(業務領域)ってものが一番大事なものとして、据えられている考えだけど、そもそもこのドメインっていうのは、例えば ECサイトとかだっから「注文」とか「決済」とか「出荷」とか、そのサービスを運営する上で必要となる、エンジニアとビジネスサイドの共通言語(ユビキタス言語)・機能・制約などのこと。

そして、そのドメインを崇拝して、そのドメインごとにモデル化(業務・概念の抽象化)して、設計を進めるのがドメイン駆動設計(DDD)ってこと。

まあ、要は仕様とか制約とか、ビジネス上のロジックがとても複雑になる場合に、サービスの保守とか改修を楽にしたり、新米エンジニアがサービスのことをドメイン毎に理解できたりするから、すんなり入れるところがいいところだよね。

でもまあ、細かくドメイン毎に細かくモデル化していくことが前提となるので、簡単なサービスとかにはやや Too much 感も出てきたりする。

Clean Architecture

じゃあ、DDDとのクリーンアーキテチャの違いはなんやねっていうと、DDDはどっちかっていうと、ビジネスドメインを主軸に据えた設計思想で、クリーンアーキテクチャはシステム全体の構造設計思想。ただ、ドメインやビジネスロジックなどを中心に据えてモデル化をしたりするのは、DDDの思想をかなり意識していて、それらを念頭にしながら、各層で依存度を低くして、保守や変更をしやすくしていこうっていうやつ。

ただ、DDDで前述したようにドメイン(役割毎)にボイラープレート(定型コード)が増えることになるので、複雑じゃないサービスにとってはやや too much っていうデメリットもある。

うん。でもまあ、Clean Architecture でいいんじゃないって感じだけど。システムなんて、いつどうするわからないわけだし。

具体的な設計思想

なんか記事の書き方によっては、アプリケーション層とかデータ層とか言葉が増えたりするけど、ざっくり分けると以下のような流れ。

Presentation層 → Domain層 → Infrastructure層

もうちょい細かく。

View → UseCase(抽象化したりprotocol) → Repository(抽象化したりprotocol) → Entity

Presentation層

Swift でいうところの View とか Controller。

Domain層

  • なんのデータをどこから取得するか
  • どのデータをどのように変更するか

のような、そのサービスを運営する上でのロジック、例えばカートの商品を決済するとか。そういうビジネスロジックと呼ばれるものを定義する。あとは Order みたいな、そのサービス固有の Entity とかも Domain層に入ると思っている(なんかInfrastructure層に入れてる記事とかあるけど)。だって、Entityってそのサービスのドメインそのものじゃん?

Infrastructure層

実際にデータを取得するロジックを定義するところ。ドメイン層の「ロジック」と区別するために、あえてデータロジックとでも呼んでみようか。

サーバから取得したり、ローカルDBから取得したりする場合があると思うけど、どっちから取るとか、そういうのもこの層でデータロジックとして定義していく。

サンプルコード

基本的にユースケース毎にファイルを追加していくイメージ。例えば Cart に商品追加するためのロジックは、CartAddUseCase。Cart から商品を削除するためのロジックは CardDeleteUseCase 的な。

ということで、SwiftUI を使ったサンプルコードを書いてみる。

View

import SwiftUI

struct TodoView: View {
    @StateObject var viewModel: TodoViewModel

    public init(viewModel: TodoViewModel) {
        _viewModel = StateObject(wrappedValue: viewModel)
    }

    public var body: some View {
        List(viewModel.todos, id: \.id) { todo in
            HStack {
                Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                Text(todo.title)
            }
        }
        .onAppear {
            viewModel.loadTodos()
        }
    }
}

ViewModel

import Foundation

public final class TodoViewModel: ObservableObject {
    @Published public private(set) var todos: [Todo] = []
    private let getToddsUseCase: GetTodosUseCase

    public init(getTodosUseCase: GetTodosUseCase) {
        self.getToddsUseCase = getTodosUseCase
    }

    public func loadTodos() {
        todos = getToddsUseCase.execute()
    }
}

UseCase

import Foundation

public protocol GetTodosUseCase {
    func execute() -> [Todo]
}

public final class GetTodosInteractor: GetTodosUseCase {
    private let repository: TodoRepository

    public init(repository: TodoRepository) {
        self.repository = repository
    }

    public func execute() -> [Todo] {
        repository.fetchTodos()
    }
}

Repository

import Foundation

public protocol TodoRepository {
    func fetchTodos() -> [Todo]
    func save(todo: Todo)
}

public class InMemoryTodoRepository: TodoRepository {
    private var storage: [Todo] = [
        Todo(title: "hoge"),
        Todo(title: "hoge2", isCompleted: true)
    ]

    public init() {}

    public func fetchTodos() -> [Todo] {
        return storage
    }

    public func save(todo: Todo) {
        if let idExist = storage.firstIndex(where: { $0.id == todo.id }) {
            storage[idExist] = todo
        } else {
            storage.append(todo)
        }
    }
}

Entity

import Foundation

public struct Todo {
    public let id: UUID
    public let title: String
    public var isCompleted: Bool
    
    public init(id: UUID = UUID(), title: String, isCompleted: Bool = false) {
        self.id = id
        self.title = title
        self.isCompleted = isCompleted
    }
}

最後にエントリーポイントから Dependency Injection(依存性の注入)

import SwiftUI

@main
struct CleanArchitectureSampleApp: App {
    var body: some Scene {
        WindowGroup {
            let repo = InMemoryTodoRepository()
            let useCase = GetTodosInteractor(repository: repo)
            let viewModel = TodoViewModel(getTodosUseCase: useCase)

            TodoView(viewModel: viewModel)
        }
    }
}

Discussion