Clean Architectureを読んで、実際にTSでToDoアプリのAPIを実装してみました。
目次
クリーンアーキテクチャとは
クリーンアーキテクチャはソフトウェアをレイヤーに分割することで、関心事の分離を実現し、以下の特性を持ったシステムを生み出します。
- フレームワーク非依存:システムをフレームワークの制約で縛るのではなく、フレームワークをツールとして使用する
- テスト可能:ビジネスルールはUIやDB、サーバー、その他の外部要素がなくてもテストできる
- UI非依存:UIはシステムの他の部分を変更することなく、簡単に変更できる
- データベース非依存:ビジネスルールはDBに束縛されていない
- 外部エージェント非依存:ビジネスルールは外界のインターフェイスについて何も知らない
一般的に縁の中央に近づくほどソフトウェアのレベルが上がり、縁の外側は仕組みで内側は方針となっています。
縁の内側は外側について何も知らず、外側で宣言された関数やクラス、変数などは内側にあるコードで触れてはならず、依存性は外から中だけに向かっていきます。
Clean ArchitectureでAPIを実装する
今回はToDoアプリのAPIをClean Architectureで実装しました。
具体的にはTaskテーブルを作成し、そこにToDoのデータが格納され、一覧や詳細、登録などをこのAPIで実現できるようになっています。
中身はシンプルなCRUDで、どの処理をどこに書くかを意識して実装しました。また、ドメイン層ではDDDを意識した作りにしました。といっても今回はドメインがTaskしかないため振る舞いはほどんどなく、簡易的なものになっています。
Enterprise Business Rules(企業のビジネスルール)
このレイヤーにはアプリケーション全体の最重要ビジネスルールをカプセル化したドメインを格納しています。
今回はToDoアプリを想定したAPIとなっており、ビジネスロジックは更新時のみで、データ構造とアクセサメソッドのみ定義しています。
またDDDを意識した実装にし、それぞれのプロパティを値オブジェクトとして定義しています。
コンストラクタの引数が関数内で変更されないため、readonlyプロパティをつけています。こうすることで、引数が読み込み専用で変更されないことが担保されます。
・Domain
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | import moment from 'moment' import { FCC } from './Fcc' export class Task { constructor( readonly id: TaskId, readonly content: TaskContent, readonly status: TaskStatus, readonly createdAt: TaskCreatedAt, readonly updatedAt: TaskUpdatedAt ) {} readonly STATUS = { TODO: 1, DONE: 2, } as const; updateStatus(status: number) { const task = new Task( new TaskId(this.id.value), new TaskContent(this.content.value), new TaskStatus(Number(status)), new TaskCreatedAt(this.createdAt.value), new TaskUpdatedAt(this.updatedAt.value) ) return task } } export class TaskId { constructor(readonly value: number) {} } export class TaskContent { constructor(readonly value: string) {} } export class TaskStatus { constructor(readonly value: number) {} } export class TaskCreatedAt { constructor(readonly value: moment.Moment) {} } export class TaskUpdatedAt { constructor(readonly value: moment.Moment) {} } export class Tasks extends FCC<Task> { constructor(values: Task[]) { super(values) } } |
Application Business Rules(アプリケーションのビジネスルール)
このレイヤーにはアプリケーション固有のビジネスルールが含まれ、入出力するデータの流れを調整します。
このレイヤーの変更がドメインに影響を与えることはなく、またこのレイヤーがDBやUI、共通のフレームワークなどの外部の変更の影響を受けることもありません。
ここでは依存関係を中心に向かうようにしたいため、依存関係逆転の原則(DIP)を使ってPortにインターフェイスを定義しています。そうすることでUseCaseがPortというインターフェイスに依存することになり、Portのインスタンス化をGatewayで行うようにしています。
具体的にはコンストラクタでTaskGatewayをTaskPortとして受け取ることで、インターフェイスを継承しています。
・UseCase
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | import { TaskPort } from '../port/TaskPort' export class TaskUseCase { private taskPort: TaskPort constructor(taskPort: TaskPort) { this.taskPort = taskPort } getById(id: number) { return this.taskPort.find(id) } getList() { return this.taskPort.findAll() } create(content: string) { return this.taskPort.create(content) } async update(id: number, status: number) { const task = await this.taskPort.find(id) const updateTask = task.updateStatus(status) return this.taskPort.update(updateTask) } async delete(id: number) { return this.taskPort.delete(id) } } |
・Port
1 2 3 4 5 6 7 8 9 10 | import { Task, Tasks } from '../domain/Task' export interface TaskPort { findAll(): Promise<Tasks> find(id: number): Promise<Task> create(content: String): Promise<any> update(task: Task): Promise<any> delete(id: number): Promise<any> } |
Interface Adaptors(インターフェイスアダプター)
このレイヤーではドメインやユースケースに便利な形式から、永続フレームワーク(DB)に便利な形式にデータを変換します。
円の内側のコードは、DBについて何も知らず、DBがSQLであれば全てのSQLはこのレイヤーに限定する必要があります。
ここではPortの具象クラスであるGatewayとResourceを格納しています。そのため、GatewayではPortのインターフェイスを継承しています。
GatewayではconvertTaskメソッドでSQLから返ってきたEntityをDomainに変換する処理をしています。
ResourceのserializeTaskメソッドでTaskドメインからAPIのレスポンスとして返したい形に変換しています。
・Gateway
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 | import { TaskPort } from '../port/TaskPort' import { TaskDriver } from '../driver/TaskDriver' import { Task, TaskContent, TaskCreatedAt, TaskId, Tasks, TaskStatus, TaskUpdatedAt } from '../domain/Task' export class TaskGateway implements TaskPort { private taskDriver: TaskDriver constructor(taskDriver: TaskDriver) { this.taskDriver = taskDriver } private convertTask(result: any) { const task = new Task( new TaskId(result.id), new TaskContent(result.content), new TaskStatus(result.status), new TaskCreatedAt(result.created_at), new TaskUpdatedAt(result.updated_at) ) return task } async find(id: number): Promise<Task> { const result = await this.taskDriver.find(id) return this.convertTask(result[0]) } async findAll(): Promise<Tasks> { try { const results = await this.taskDriver.findAll() return results.map((result: Task) => { return this.convertTask(result) }) } catch (e) { throw e } } async create(content: String): Promise<any> { try { const result = await this.taskDriver.create(content) return result } catch (e) { throw e } } async update(task: Task): Promise<any> { try { const result = await this.taskDriver.update(task) return result } catch (e) { throw e } } async delete(id: number): Promise<any> { try { const result = await this.taskDriver.delete(id) return result } catch (e) { throw e } } } |
・Resource
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 | import { TaskUseCase } from '../usecases/TaskUseCase' import { Task } from '../domain/Task' export class TaskResource { private taskUseCase: TaskUseCase constructor(taskUseCase: TaskUseCase) { this.taskUseCase = taskUseCase } async findTask(req: any, res: any) { const id = req.params.id const result = await this.taskUseCase.getById(id) return this.serialize(result) } async findAllTasks(req: any, res: any) { const result = await this.taskUseCase.getList() return this.serialize(result) } async createTask(req: any, res: any) { const { content } = req.body const result = await this.taskUseCase.create(content) return result } async updateTask(req: any, res: any) { const id = req.params.id const { status } = req.body const result = await this.taskUseCase.update(id, status) return result } async deleteTask(req: any, res: any) { const id = req.params.id const result = await this.taskUseCase.delete(id) return result } private serializeTask = (task: Task) => { return { id: task.id.value, content: task.content.value, status: task.status.value, createdAt: task.createdAt.value, updatedAt: task.updatedAt.value } } private serialize(data: any) { if (!data) { throw new Error('data is undefined or null') } if (Array.isArray(data)) { return data.map(task => this.serializeTask(task)) } return this.serializeTask(data) } } |
Frameworks & Drivers(フレームワークとドライバ)
最も外側の円は、フレームワークやツールで構成されています。例えばDBやWebフレームワークなどがこのレイヤーに格納されています。RouterやDriverもこのレイヤーに格納しています。
Tasksテーブルのカラムであるcreated_atとupdated_atはdefaultValueを「CURRENT_TIMESTAMP」、「CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP」にして自動的に登録されるようにしています。
statusもdefaultValueを1にすることでcreateではデフォルトで登録できるようにしています。
・Driver
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | import { Task } from '../domain/Task' import { IDBConnection } from './db/IDBConnection' export class TaskDriver { private connection: any constructor(connection: IDBConnection) { this.connection = connection } async find(id: number): Promise<any> { const result = await this.connection.execute( 'select * from tasks where id = ?', id ) return result } async findAll(): Promise<any> { const result = await this.connection.execute('select * from tasks') return result } async create(content: String): Promise<any> { const result = await this.connection.execute( 'insert into tasks (content) values (?)', content ) return result } async update(task: Task): Promise<any> { const result = this.connection.execute( 'update tasks set status = ? where id = ?', [task.status.value, task.id.value] ) return result } async delete(id: number): Promise<any> { const result = await this.connection.execute( 'delete from tasks where id = ?', id ) return result } } |
Routerではexpressのrouterを使ってルーティング設定をしています。
またRouterで各層の依存関係を定義(DI)することで、それぞれレイヤーで利用可能な状態にします。
・Router
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | import express = require('express') import { TaskUseCase } from '../usecases/TaskUseCase' import { TaskGateway } from '../gateway/TaskGateway' import { TaskResource } from '../rest/TaskResource' import { MysqlConnection } from './db/MysqlConnection' import { TaskDriver } from './TaskDriver' const mysqlConnection = new MysqlConnection() const taskDriver = new TaskDriver(mysqlConnection) const taskGateway = new TaskGateway(taskDriver) const taskUsecase = new TaskUseCase(taskGateway) const taskResource = new TaskResource(taskUsecase) const router = express.Router() router.get('/tasks', async (req: express.Request, res: express.Response) => { const results = await taskResource.findAllTasks(req, res) res.send(results) }) router.get( '/tasks/:id', async (req: express.Request, res: express.Response) => { const result = await taskResource.findTask(req, res) res.send(result) } ) router.post('/tasks', async (req: express.Request, res: express.Response) => { const result = await taskResource.createTask(req, res) res.send(result) }) router.patch( '/tasks/:id', async (req: express.Request, res: express.Response) => { const result = await taskResource.updateTask(req, res) res.send(result) } ) router.delete( '/tasks/:id', async (req: express.Request, res: express.Response) => { const result = await taskResource.deleteTask(req, res) res.send(result) } ) export default router |
Serverではexpressを使ってサーバー処理を書いています。routerのパスや待ち受けるポート番号などを設定しています。
また、CORSで「http://localhost:3000」のみアクセスできるようにしています。
・Server
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | import express from 'express' import router from './router' import bodyParser from 'body-parser' import cors from 'cors' const app = express() app.use(bodyParser.urlencoded({ extended: false })) app.use(bodyParser.json()) const allowedOrigins = ['http://localhost:3000'] const options: cors.CorsOptions = { origin: allowedOrigins } app.use(cors(options)) app.use('/api', router) app.listen(5000, () => { console.log('listening on port 5000') }) export default app |
・IDBConnection
1 2 3 | export abstract class IDBConnection { abstract execute(query: string, params: any): any } |
・MysqlConnection
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 | import mysql from 'mysql' import dotenv from 'dotenv' import util from 'util' import { IDBConnection } from './IDBConnection' export class MysqlConnection extends IDBConnection { private pool: any constructor() { super() dotenv.config() this.pool = mysql.createPool({ connectionLimit: 5, host: process.env.DB_HOST_DEV, user: process.env.DB_USER_DEV, password: process.env.DB_PASSWORD_DEV, database: process.env.DB_NAME_DEV, timezone: 'utc' }) this.pool.getConnection((error: any, connection: any) => { if (error) { if (error.code === 'PROTOCOL_CONNECTION_LOST') { console.error('Database connection was closed.') } if (error.code === 'ER_CON_COUNT_ERROR') { console.error('Database has too many connections.') } if (error.code === 'ECONNREFUSED') { console.error('Database connection was refused.') } } if (connection) connection.release() return }) this.pool.query = util.promisify(this.pool.query) // pool event this.pool.on('connection', (connection: any) => { console.log('mysql connection create') }) this.pool.on('release', (connection: any) => { console.log('Connection %d released', connection.threadId) }) } execute(query: string, params: any = null) { if (params !== null) { return this.pool.query(query, params) } else { return this.pool.query(query) } } } |