クリーンアーキテクチャについて学ぶ
はじめに
都内でフロントエンド開発をしております。
最近 BFF にクリーンアーキテクチャの概念を盛り込み始めたため、そのアウトプットとして本記事を書きました。
クリーンアーキテクチャは既に多くの記事が存在しますが、私なりに噛み砕いて記述してるので誰かの手助けになれば幸いです 🙏
昨今のアプリケーションの課題
アプリケーションは常に新しい要求が求められ、新規機能追加や UI リニューアルなどをリアルタイムに行っていくことが必須です。
しかし、将来性を考慮せずリリースにフォーカスした実装はシステムの複雑性を高めることに繋がり、大きなバグにつながるかもしれません。
バグに繋がらなくともシステムの拡張性を低め、今後の新規機能を追加するのに大きな工数がかかる可能性も存在します。
内部品質
と外部品質
を保ちながらシステムを稼働させるのはとても難しい課題です。
アーキテクチャ
そこで登場するのがアーキテクチャです。
アーキテクチャとはシステムの品質担保をしつつ、市場に提供する
設計手法と私は考えます。
ここでいう品質担保とは下記です。
- 外部品質
- バグがない
- 処理速度がはやい
- UI が崩れていない
- 各画面によって正しい処理を実施する
- 内部品質
- コードの保守性
- コードのテスト容易性
- コードの拡張性
この中でもシステムのおけるアーキテクチャは内部品質
にフォーカスしたものがほとんどです。
理由ですが、ほとんどの外部品質
は内部品質
が悪いことによる症状
であり、根本的な原因は内部品質
にあります。
よってアーキテクチャを採用することによって内部品質
を高める = 外部品質
が高まることでバグの発生率を下げつつ、開発・保守・運用がしやすくという循環を生むことができます。
クリーンアーキテクチャ
そこで今回学ぶのがクリーンアーキテクチャです。
このアーキテクチャはソフトウェアをレイヤーに分けることで依存関係の分離
を実現し高品質なシステムを構築するアプローチです。
各層の概要を下記に示します。
Enterprise Business Rules
Enterprise Business Rules
はシステムの中枢となる層で、ビジネスルールをカプセル化した層です。
domain
や entitiy
と呼称されますが、本記事では以後 domain
と呼んでいきます。
ビジネスルールとはシステムのルールや手続きのことであり、お店を例に挙げると商品、カート、注文、ユーザーなどが該当します。
上記に加えて商品を購入する、注文履歴を調べるなどの行為もビジネスルールに該当します。
ざっくり述べるとシステムなどは一旦考えずに、業務要件をまとめたものでしょうか。
オブジェクト指向をもとに、各ドメインができることだけを記述していくようなイメージです。
このビジネスルールは外部要件(DB や端末)に影響されない普遍的な部分であるため、他の層に依存することは一切ありません。
Application Business Rules
次にApplication Business Rules
です。usecase
と呼称される領域です。
この層はアプリケーション固有のビジネスルール
が記述される層です。
domain
ではシステムを考慮しないビジネスルール
を定義しました。
usecase
ではシステムとしてのビジネスルール
が実装されます。
例えばユーザー情報を保存する機能の場合、「全ての値が正常値か検証する。正常であればユーザーを更新 or 生成」などでしょうか。
これは自動化されたシステムを成り立たせる上での処理なのでユースケース
に該当します。
ここで重要なのが、usecase
はdomain
に依存し、後述する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 に関する部分のみ明記します。
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
層から見ると一つ外側の層となります。よって usecase
は gateways
の直接的な実装に依存することは NG です。
この解決策として DI(dependency injection)
が存在します。
上記は抽象的なインターフェースに依存することで、直接的な依存を回避することができます。
この抽象的なインターフェース定義が下記です。
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 はコードだけだと、わかりにくいので依存関係を図に起こしてみましょう。
真ん中が抽象化されたインターフェースです。
内側の usecase
層である CreateTodo
を見ると依存方向が反対になっていると思います。これが DI となります。
左側の Repository
に関しては ITodoRepository
に依存していれば何にでも取り替えることができます。
これによって DB の取り替えなどが簡単になります。
gateways
先ほどから上がっている gateways
の実装を見ていきましょう。
今回はインメモリに todo リストを保存する形としました。
// interfaces/gateways/memory
import { Todo } from "../../../domain/Todo";
export let inMemoryTodo: Todo[] = [];
// 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 を作成しました。
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[]
}
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 の処理を実装します。
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
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
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 あるいは他のものと交換することができる。ビジネスルールは、データベースに拘束されない。
外部機能独立。実際のところ、ビジネスルールは、単に外側についてなにも知らない。
- Oracle あるいは SQL Server を、Mongo, BigTable, CoucheDB あるいは他のものと交換することができる。ビジネスルールは、データベースに拘束されない。
今後の業務ではクリーンアーキテクチャの 考え方 を念頭に置いて疎結合なシステム開発ができるよう取り組んでいきたいと思います。
Discussion