🐶

クリーンアーキテクチャについて学ぶ

2022/02/10に公開約13,800字

はじめに

都内でフロントエンド開発をしております。
最近 BFF にクリーンアーキテクチャの概念を盛り込み始めたため、そのアウトプットとして本記事を書きました。

クリーンアーキテクチャは既に多くの記事が存在しますが、私なりに噛み砕いて記述してるので誰かの手助けになれば幸いです 🙏

昨今のアプリケーションの課題

アプリケーションは常に新しい要求が求められ、新規機能追加や UI リニューアルなどをリアルタイムに行っていくことが必須です。
しかし、将来性を考慮せずリリースにフォーカスした実装はシステムの複雑性を高めることに繋がり、大きなバグにつながるかもしれません。

バグに繋がらなくともシステムの拡張性を低め、今後の新規機能を追加するのに大きな工数がかかる可能性も存在します。

内部品質外部品質を保ちながらシステムを稼働させるのはとても難しい課題です。

アーキテクチャ

そこで登場するのがアーキテクチャです。
アーキテクチャとはシステムの品質担保をしつつ、市場に提供する設計手法と私は考えます。

ここでいう品質担保とは下記です。

  • 外部品質
    • バグがない
    • 処理速度がはやい
    • UI が崩れていない
    • 各画面によって正しい処理を実施する
  • 内部品質
    • コードの保守性
    • コードのテスト容易性
    • コードの拡張性

この中でもシステムのおけるアーキテクチャは内部品質にフォーカスしたものがほとんどです。
理由ですが、ほとんどの外部品質内部品質が悪いことによる症状であり、根本的な原因は内部品質にあります。

よってアーキテクチャを採用することによって内部品質を高める = 外部品質が高まることでバグの発生率を下げつつ、開発・保守・運用がしやすくという循環を生むことができます。

クリーンアーキテクチャ

そこで今回学ぶのがクリーンアーキテクチャです。
このアーキテクチャはソフトウェアをレイヤーに分けることで依存関係の分離を実現し高品質なシステムを構築するアプローチです。

クリーンアーキテクチャ

各層の概要を下記に示します。

Enterprise Business Rules

Enterprise Business Rulesはシステムの中枢となる層で、ビジネスルールをカプセル化した層です。
domainentitiy と呼称されますが、本記事では以後 domain と呼んでいきます。
ビジネスルールとはシステムのルールや手続きのことであり、お店を例に挙げると商品、カート、注文、ユーザーなどが該当します。
上記に加えて商品を購入する、注文履歴を調べるなどの行為もビジネスルールに該当します。

ざっくり述べるとシステムなどは一旦考えずに、業務要件をまとめたものでしょうか。
オブジェクト指向をもとに、各ドメインができることだけを記述していくようなイメージです。

このビジネスルールは外部要件(DB や端末)に影響されない普遍的な部分であるため、他の層に依存することは一切ありません。

Application Business Rules

次にApplication Business Rulesです。usecase と呼称される領域です。
この層はアプリケーション固有のビジネスルールが記述される層です。

domainではシステムを考慮しないビジネスルールを定義しました。
usecaseではシステムとしてのビジネスルールが実装されます。
例えばユーザー情報を保存する機能の場合、「全ての値が正常値か検証する。正常であればユーザーを更新 or 生成」などでしょうか。
これは自動化されたシステムを成り立たせる上での処理なのでユースケースに該当します。
ここで重要なのが、usecasedomainに依存し、後述するInterface Adaptersなどの外側の層には依存しないことです。

Interface Adapters

次にInterface Adaptersです。
この層は入力、永続化、表示といった、usecaseと外部世界のやり取りを担当するものが当てはまります。
クリーンアーキテクチャの図に記載されている、各種領域の概要は下記です。

  • gateways
    • データの取り扱いに関する抽象化を担当します。
    • プログラミングする上でよく挙げられる Repository がこの領域の担当となります。
  • controller
    • MVC などでよく聞くコントローラーです。
    • MVC と役割は同じで、入力値を usecase に渡す & レスポンスを返すなど橋渡しを担当します
  • presenter
    • プレゼンターその名の通り入力値 / 出力値を変換する役割を持ちます
    • input に対しては usecase が扱いやすい形に変換
    • output に対しては外部(Web や DB)が扱いやすい形に変換

Frameworks & Drivers

次にFrameworks & Driversです。クリーンアーキテクチャの一番外の層になります。
ここはシステムで利用してる DB やフレームワークに依存するコードが配置されます。

実装してみる

以上の情報をもとに簡単な API を作成してみました。

概要

  • TODO リストの取得,更新,削除を実施する API

ディレクトリ構成

  • src
    • domain(Enterprise Business Rules)
    • application
      • usecases(Applications Business Rules)
    • interfaces(interface adapter)
      • controller
      • gateways
      • presenters
    • infrastructure(Frameworks & Drivers)

完成品はこちらにあります。

domains

まず初めにdomainsです。
今回は登場人物が todo のみなので、todo というオブジェクトを定義していきます。

import moment from "moment-timezone";
import { ID } from "../type";

export class Todo {
  private _id: ID;
  private _title: string;
  private _description: string;
  private _createdAt: moment.Moment;
  private _updatedAt: moment.Moment;

  get id(): ID {
    return this._id;
  }

  set id(id: ID) {
    this._id = id;
  }

  get title(): string {
    return this._title;
  }

  set title(title: string) {
    this._title = title;
  }

  get description(): string {
    return this._description;
  }

  set description(description: string) {
    this._description = description;
  }

  // ビジネスルールを格納
  isTitleFilled(): boolean {
    return this._title.length > 0;
  }

  isDescriptionFilled(): boolean {
    return this._description.length > 0;
  }

  // 省略 日付周りの定義も実施しています

  // idや時刻を生成しない
  // ドメインとしては「タスク」を生成するだけなので、システムで必要なidなどはentityで実施しない
  constructor(title: string, description: string) {
    this._title = title;
    this._description = description;
  }
}

本来であればもっと詳細なビジネスルールが存在すると思うのですが、概要把握が目的であるため簡単なものにしています。

applications

次に applications です。
システムに期待される振る舞いである、todo リストの CRUD 機能を作っていきます。

記述量が多くなる関係から Create に関する部分のみ明記します。

Todo.ts
import uuid4 from 'uuid4'
import moment from 'moment-timezone'
import { Todo } from '../../../domain/Todo'
import { ITodoRepository } from './ITodoRespository'

export class CreateTodo {
  private taskRepository: ITodoRepository

  constructor(taskRepository: ITodoRepository) {
    this.taskRepository = taskRepository
  }

  execute(title: string, description: string) {
    const task = new Todo(title, description)

    if (!task.isTitleFilled() || !task.isDescriptionFilled()) {
      throw new Error('ビジネスルールを破っているためエラー')
    }

    // アプリケーション要件的な要素はインスタンス化で設定せず、setterで設定
    task.id = uuid4()
    task.createdAt = moment()
    task.updatedAt = moment()

    return this.taskRepository.create(task)
  }
}

タスク登録のシステム的な振る舞いは下記です。

  • CreateTodo.ts は domains の Todo.ts を利用してタスクを生成。
  • 生成したタスクをデータとして保存。

生成は domains で可能ですが、保存処理はどのように実現するのでしょうか?

このデータの関わる保存処理は interface & Adapters 層の gateways が担当します。
再活となりますが gateways という領域は Repository の役割を持ちます。

これは usecase 層から見ると一つ外側の層となります。よって usecasegateways の直接的な実装に依存することは NG です。

この解決策として DI(dependency injection)が存在します。
上記は抽象的なインターフェースに依存することで、直接的な依存を回避することができます。

この抽象的なインターフェース定義が下記です。

ITodoRepository.ts
import { Todo } from "../../../domain/Todo";
import { ID } from "../../../type";

// usecase層と interface層(gateways(repository))を繋げる抽象インターフェース
export interface ITodoRepository {
  findAll(): Promise<Array<Todo>>;
  find(id: ID): Promise<Todo | null>;
  create(todo: Todo): Promise<Todo>;
  update(todo: Todo): Promise<Todo>;
  delete(id: ID): Promise<null>;
}

私は usecase 層にインターフェースを定義しました。
理由として gateways に定義すると、クリーンアーキテクチャの概要図に反してしまうからです。

DI はコードだけだと、わかりにくいので依存関係を図に起こしてみましょう。

DI

真ん中が抽象化されたインターフェースです。
内側の usecase 層である CreateTodo を見ると依存方向が反対になっていると思います。これが DI となります。

左側の Repository に関しては ITodoRepository に依存していれば何にでも取り替えることができます。
これによって DB の取り替えなどが簡単になります。

gateways

先ほどから上がっている gateways の実装を見ていきましょう。
今回はインメモリに todo リストを保存する形としました。

InMemoryTodo.ts
// interfaces/gateways/memory
import { Todo } from "../../../domain/Todo";

export let inMemoryTodo: Todo[] = [];
InMemoryTodo.ts
// interfaces/gateways/memory
import { ITodoRepository } from "../../../application/usecases/Todo/ITodoRespository";
import { Todo } from "../../../domain/Todo";
import { ID } from "../../../type";
import { inMemoryTodo } from "./InMemoryTodo";

export class TodoRepository implements ITodoRepository {
  private inmemoryTodo: Todo[];
  constructor() {
    // @ts-ignore
    inMemoryTodo = [
      new Todo("todo01", "インメモリtodo01"),
      new Todo("todo02", "インメモリtodo02"),
    ];
    this.inmemoryTodo = inMemoryTodo;
  }

  async create(todo: Todo): Promise<Todo> {
    new Promise(() => setTimeout(() => {}, 1000));
    this.inmemoryTodo.push(todo);
    return todo;
  }

  // そのほかのメソッドは省略
}

このような形で実際の DB との処理を記載していきます。
今回は memory ですが、mysql などでもITodoRepository.tsに依存した Repository を作ればすぐに取り替え可能です。

presenters

次にpresentersです。
これは web や DB などの外部からの input を内部で扱いやすく変換 or usecase が生成したデータを外部が扱いやすいように変換する層です。

今回は Todo リストの output を変換する serializer を作成しました。

ITodoSerializer.ts
import { Todo } from '../../domain/Todo'
import { ID } from '../../type'

interface TodoResponse{
  id:ID,
  title:string,
  description:string
}

export interface ITodoOutputSerializer {
  todo(todo:Todo):TodoResponse
  todos(todo:Todo[]):TodoResponse[]
}
TodoSerializer.ts
import { Todo } from '../../domain/Todo'
import { ITodoOutputSerializer } from './ITodoSerializer'

const serialize =(todo:Todo)=>{
  return {
    id:todo.id,
    title:todo.title,
    description:todo.description
  }
}

export class TodoSerializer implements ITodoOutputSerializer{
  todo(todo:Todo){
    return serialize(todo)
  }
  todos(todo:Todo[]){
    return todo.map(mTodo => serialize(mTodo))
  }
}

controllers

次に controllers です。
ここでは今まで作成した usecase や repository などを利用して input / output の処理を実装します。

TodoController.ts
import { TodoRepository } from '../gateways/memory/TodoRepository'
import { TodoSerializer } from '../presenters/TodoSerializer'
import { GetTodo } from '../../application/usecases/Todo/GetTodo'
import { GetTodoList } from '../../application/usecases/Todo/GetTodoList'
import { CreateTodo } from '../../application/usecases/Todo/CreateTodo'
import { UpdateTodo } from '../../application/usecases/Todo/UpdateTodo'
import { DeleteTodo } from '../../application/usecases/Todo/DeleteTodo'
import express from 'express'

type Request = {
  req: express.Request
}

export class TodoController {
  private todoSerializer: TodoSerializer
  private todoRepository: TodoRepository

  constructor() {
    this.todoRepository = new TodoRepository()
    this.todoSerializer = new TodoSerializer()
  }


  async create({req}:Request){
    const {title,description} = req.body
    const usecase = new CreateTodo(this.todoRepository)
    const result = await usecase.execute(title,description)

    return this.todoSerializer.todo(result)

  }
  // その他のメソッドは省略
}

infrastructure

最後に一番外の層であるinfrastructureについてです。
ここにはフレームワークや DB の詳細ファイルなどを格納します。

import express = require('express')
import { TodoController } from '../interfaces/controller/TodoController'

const todoController = new TodoController()
const router = express.Router()
router.post('/todo', async (req: express.Request, res: express.Response) => {
  const result = await todoController.create({req})
  res.send(result)
})

// その他のエンドポイントは省略


export default router

実行してみる

$ yarn start
$ curl http://localhost:3000/api/todo
[{"title":"todo01","description":"インメモリtodo01"},{"title":"todo02","description":"インメモリtodo02"}]

無事にインメモリのデータを取得することができました。
今回はインメモリを参照していますが MySQL などに切り替える場合は ITodoRepository に依存した Repository を別途作成すれば簡単に切り替えれると思います。

テストを書いてみる

少しだけテストを書いてみました。

domains

Todo.test.ts
import { Todo } from '../Todo'
import moment from 'moment-timezone'

describe('Todoドメインのテスト', function () {
  it('アプリケーション要件的なid,createAt,updateAtは外部から注入できる', function () {
    const todo = new Todo('タイトル', '説明文')
    todo.id = 'dummy'
    todo.createdAt = moment('20220110')
    todo.updatedAt = moment('20220110')

    expect(todo.id).toEqual('dummy')
    expect(todo.createdAt).toEqual(moment('20220110'))
    expect(todo.updatedAt).toEqual(moment('20220110'))
  })
  it('タイトルと説明文が入力済みの場合、trueを返す', function () {
    const todo = new Todo('タイトル', '説明文')
    expect(todo.isTitleFilled()).toBeTruthy()
    expect(todo.isDescriptionFilled()).toBeTruthy()
  })
  it('タイトルが未入力の場合、falseを返す', function () {
    const todo = new Todo('', '説明文')
    expect(todo.isTitleFilled()).toBeFalsy()
  })
  it('説明文が未入力の場合、falseを返す', function () {
    const todo = new Todo('タイトル', '')
    expect(todo.isDescriptionFilled()).toBeFalsy()
  })
})


業務であればもっと網羅的に記載しますが、何点か記載してみました。
当たり前ですが、domains に関しては依存するものがないのでテストがしやすいです。

usecases

CreateTodo.ts
import { CreateTodo } from '../CreateTodo'
import { ITodoRepository } from '../ITodoRespository'
import { ID } from '../../../../type'
import { Todo } from '../../../../domain/Todo'
import moment from 'moment-timezone'

const todoRepository: ITodoRepository = {
  find(id: ID): Promise<Todo | null> {
    throw 'not implemented'
  },
  findAll(): Promise<Array<Todo>> {
    throw 'not implemented'
  },
  create(todo: Todo): Promise<Todo> {
    throw 'not implemented'
  },
  update(todo: Todo): Promise<Todo> {
    throw 'not implemented'
  },
  delete(id: ID): Promise<null> {
    throw 'not implemented'
  }
}

describe('CreateTodoのテスト', function () {
  const title = 'title'
  const description = 'description'

  const todo = new Todo(title, description)
  todo.id = 'dummyId'
  todo.createdAt = moment()
  todo.updatedAt = moment()

  const createSpy = jest.spyOn(todoRepository, 'create').mockReturnValue(new Promise((resolve) => resolve(todo)))

  beforeEach(() => {
    createSpy.mockClear()
  })

  it('interface層のtodoRepository.create()が呼ばれている', async function () {
    const usecase = new CreateTodo(todoRepository)
    expect(await usecase.execute(title, description)).toEqual(todo)
    expect(createSpy).toHaveBeenCalled()
  })

  it('title or descriptionが空文字の場合エラーを返す', async function () {
    const usecase = new CreateTodo(todoRepository)
    expect(() => usecase.execute('', description)).toThrow()
    expect(createSpy).not.toHaveBeenCalled()
    expect(() => usecase.execute(title, '')).toThrow()
    expect(createSpy).not.toHaveBeenCalled()
  })
})

一つ外側の usecase のテストもしてみました。
具体に依存していない & DI によって Repository を外部注入できるので複雑なモックが必要なく書きやすい印象でした。

まとめ

ここまでクリーンアーキテクチャの各層の概要を説明と簡単な実装をしてきました。
お作法に則り依存方向の徹底と明確な責務分けを実施するすることで、書籍で記載されている下記メリットを見にしみて感じました。

  • フレームワーク独立

    • アーキテクチャは、機能満載のソフトウェアのライブラリが手に入ることには依存しない。これは、そういったフレームワークを道具として使うことを可能にし、システムをフレームワークの限定された制約に押し込めなければならないようなことにはさせない。
  • テスト可能

    • ビジネスルールは、UI、データベース、ウェブサーバー、その他外部の要素なしにテストできる。
  • UI 非依存

    • UI は、容易に変更できる。システムの残りの部分を変更する必要はない。たとえば、ウェブ UI は、ビジネスルールの変更なしに、コンソール UI と置き換えられる。
  • データベース 非依存

    • Oracle あるいは SQL Server を、Mongo, BigTable, CoucheDB あるいは他のものと交換することができる。ビジネスルールは、データベースに拘束されない。
      外部機能独立。実際のところ、ビジネスルールは、単に外側についてなにも知らない。

今後の業務ではクリーンアーキテクチャの 考え方 を念頭に置いて疎結合なシステム開発ができるよう取り組んでいきたいと思います。

Discussion

ログインするとコメントできます