Typescript + Clean Architecture + DDDでAPIを実装してみました。
目次
ソフトウェア要件の理解
今回は簡単なメンバー管理システムとして作っていきました。まずソフトウェア要件としてどんなものがあるか挙げていきます。
このように要件を挙げていくことで曖昧な要件から明確な仕様へと発展させていきます。本来なら開発するソフトウェアに詳しい人からヒアリングするのですが、今回は個人開発なので自分で挙げていきました。
- メンバーを管理するシステム
- メンバーは1つの部署に所属し、役職が付いている
- メンバーの基本情報(名前、性別、生年月日など)を入力して登録する
- メンバーの情報は更新や削除ができる
- メンバー一覧や詳細を見ることができる
- 部署一覧と所属しているメンバーの人数を見ることができる
- 役職一覧と役職ごとのメンバーの人数を見ることができる
- メンバーにはそれぞれ雇用形態がある
- 雇用形態一覧と雇用形態ごとのメンバーの人数を見ることができる
- メンバーの給与情報を登録・削除できる
- 給与情報一覧を見ることができる
ユースケース図の作成
ソフトウェア要件を挙げたらユースケース図を作っていきます。このソフトウェアで何ができるのかをユーザー目線で表現します。
今回は管理者のみ使う想定となっており、主にメンバー管理に関するユースケースが想定されます(認証系は省略しています)。
エンティティの抽出
ソフトウェア要件やユースケース図が作成できたら、モデリング(エンティティの抽出)していきます。今回は以下のエンティティ候補が抽出されました。またユビキタス言語として用語を統一させておきます。
その後、モデリングの流れとしてエンティティを識別する属性・振る舞いやバリデーションの検討などがありますが、割愛します。
Typescript + Clean Architecture + DDDでAPIを開発する
本記事ではメンバー関連の一連の処理を中心に取り扱います。Clean Architectureの中心部分から説明していきます。
Enterprise Business Rules
Enterprise Business Rulesではアプリケーション全体の最重要ビジネスルールをカプセル化したドメインを格納しています。
まず先程定義したMemberエンティティをドメインのエンティティとしてどう定義していく考えていきます。
Memberの情報として必要なデータを値オブジェクトとして定義し、それらをまとめたMemberProfileという値オブジェクトを作成します。このMemberProfileにはMember自身の情報のみ持つようにしています。
次にMemberProfileに一意の識別子であるMemberIdをもっとMemberというエンティティを作成しました。
次にMember自身の情報として持っていないが、役職や部署などMemberに関連づけられたデータを持ったMemberRelationというエンティティとMemberの識別子を持っていないMemberRelationAttributeという値オブジェクトを作成しました。
MemberRalationエンティティのstaticなfromメソッドはプリミティブなオブジェクトからエンティティに変換する際に使われます。
Memberに関連する値オブジェクトとエンティティを作成したらそれらの振る舞いとバリデーションを実装していきます。
・Member.ts
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 | import { FCC } from './Fcc' export class MemberProfile { constructor( readonly name: MemberName, readonly birthday: MemberBirthday, readonly gender: MemberGender ) {} } export class Member { constructor( readonly id: MemberId, readonly member: MemberProfile ) {} } export class MemberId { constructor(readonly value: number) {} } export class MemberName { constructor(readonly value: string) {} isMaxSize() { return this.value.length > 30 } } export class MemberBirthday { constructor(readonly value: Date) {} getAge(): number { const nowDate = new Date() const birthNumber = this.value.getFullYear() * 10000 + (this.value.getMonth() + 1 ) * 100 + this.value.getDate(); const nowNumber = nowDate.getFullYear() * 10000 + (nowDate.getMonth() + 1 ) * 100 + nowDate.getDate(); return Math.floor( (nowNumber - birthNumber) / 10000 ); } } export class MemberGender { constructor(readonly value: number) {} readonly GENDER = { MEN: 1, WOMAN: 2 } as const getName(): string { return this.value == this.GENDER.MEN ? '男性' : '女性' } } export class Members extends FCC<Member> { constructor(values: Member[]) { super(values) } } |
・MemberRelation.ts
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 | import { FCC } from "./Fcc" import { DepartmentId } from "./Department" import { MemberProfile, MemberId, MemberBirthday, MemberGender, MemberName, Member } from "./Member" import { PostId } from "./Post" import { EmploymentStatusId } from "./EmploymentStatus" export class MemberRelationAttribute { constructor( readonly memberProfile: MemberProfile, readonly departmentId: DepartmentId, readonly postId: PostId, readonly employmentStatusId: EmploymentStatusId ) {} update(id: MemberId): MemberRelation { return new MemberRelation( new Member( new MemberId(id.value), new MemberProfile( new MemberName(this.memberProfile.name.value), new MemberBirthday(this.memberProfile.birthday.value), new MemberGender(this.memberProfile.gender.value) ) ), new DepartmentId(this.departmentId.value), new PostId(this.postId.value), new EmploymentStatusId(this.employmentStatusId.value) ) } } export class MemberRelation { constructor( readonly member: Member, readonly departmentId: DepartmentId, readonly postId: PostId, readonly employmentStatusId: EmploymentStatusId ) {} static from( id: number, name: string, birthday: Date, gender: number, departmentId: number, postId: number, employmentStatusId: number ) { return new MemberRelation( new Member( new MemberId(id), new MemberProfile( new MemberName(name), new MemberBirthday(birthday), new MemberGender(gender) ) ), new DepartmentId(departmentId), new PostId(postId), new EmploymentStatusId(employmentStatusId) ) } } export class MemberRelations extends FCC<MemberRelation> { constructor(values: MemberRelation[]) { super(values) } } |
Application Business Rules
Application Business Rulesではアプリケーション固有のビジネスルールが含まれ、入出力するデータの流れを調整します。
MemberPortにインターフェイスを定義し、MemberGatewayで具象クラスを実装するようにしています。
MemberUseCaseではMemberControllerから受け取ったドメインをバリデーションなどで精査し、MemberGatewayの具象メソッドを呼び出すようにしています。ちなみにDIライブラリとしてTSyringeを使用しています。
・MemberPort.ts
1 2 3 4 5 6 7 8 9 10 11 12 | import { SalaryAttribute } from '../domain/Salary' import { MemberId } from '../domain/Member' import { MemberRelation, MemberRelationAttribute, MemberRelations } from '../domain/MemberRelation' export interface MemberPort { findAll(): Promise<MemberRelations> find(id: MemberId): Promise<MemberRelation> create(member: MemberRelationAttribute): Promise<void> createSalary(salary: SalaryAttribute, memberId: MemberId): Promise<void> update(member: MemberRelation): Promise<void> delete(id: MemberId): Promise<void> } |
・MemberUseCase.ts
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 | import { MemberPort } from '../port/MemberPort' import { MemberId } from '../domain/Member' import { MemberRelation, MemberRelationAttribute, MemberRelations } from '../domain/MemberRelation' import "reflect-metadata" import { inject, singleton } from "tsyringe" import { SalaryAttribute } from '../domain/Salary' @singleton() export class MemberUseCase { constructor(@inject("MemberPort") private memberPort: MemberPort) {} find(id: MemberId): Promise<MemberRelation> { return this.memberPort.find(id) } findAll(): Promise<MemberRelations> { return this.memberPort.findAll() } create(member: MemberRelationAttribute): Promise<void> { if (member.memberProfile.name.isMaxSize()) { throw new Error("名前は30文字以下です") } return this.memberPort.create(member) } createSalary(salary: SalaryAttribute, memberId: MemberId): Promise<void> { if (salary.remark.isMaxSize()) { throw new Error("備考は300文字以下です") } return this.memberPort.createSalary(salary, memberId) } async update(id: MemberId, member: MemberRelationAttribute): Promise<void> { const targetMember = await this.memberPort.find(id) const updateMember = member.update(targetMember.member.id) if (updateMember.member.member.name.isMaxSize()) { throw new Error("名前は30文字以下です") } return this.memberPort.update(updateMember) } delete(id: MemberId): Promise<void> { return this.memberPort.delete(id) } } |
Interface & Adapter
Interface & Adapterではドメインやユースケースに便利な形式からDBに便利な形式にデータを変換します。
MemberGatewayではMemberUseCaseから渡されたドメインをプリミティブなJson形式のオブジェクトに詰め替えてMemberDriverを呼び出しています。
なぜならDriver層にはドメインの知識を流出させたくないためであり、あくまでDriverに必要なデータだけをプリミティブな形で渡すようにします。
そしてDriverから返ってきた値をドメインに詰め替えてからUseCaseに返すようにします。
MemberControllerではリクエストされたパラメーターをドメインに詰め替えてUseCaseに渡し、UseCaseから返ってきたドメインをレスポンスとして返したい形式に変換して返します。またレスポンスとして返す時もリスポンスの型を定義し、変換して返すようにしています。
このようにこのレイヤでは主に入出力用に変換する処理を行っています。
・MemberGateway.ts
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 | import { MemberPort } from '../port/MemberPort' import { MemberDriver, MemberEntity } from '../driver/MemberDriver' import { MemberRelation, MemberRelationAttribute, MemberRelations } from '../domain/MemberRelation' import { MemberId, MemberGender, MemberName, MemberBirthday, MemberProfile, Member } from '../domain/Member' import { DepartmentId } from '../domain/Department' import { PostId } from '../domain/Post' import { inject, singleton } from "tsyringe" import { EmploymentStatusId } from '../domain/EmploymentStatus' import { SalaryAttribute } from '../domain/Salary' @singleton() export class MemberGateway implements MemberPort { constructor(@inject("MemberDriver") private memberDriver: MemberDriver) {} async find(id: MemberId): Promise<MemberRelation> { const member: MemberEntity = await this.memberDriver.find(id.value) return this.convertMemberValue(member) } async findAll(): Promise<MemberRelations> { const members: MemberEntity[] = await this.memberDriver.findAll() return new MemberRelations( members.map((member: MemberEntity) => this.convertMemberValue(member)) ) } async create(member: MemberRelationAttribute): Promise<void> { await this.memberDriver.create({ name: member.memberProfile.name.value, birthday: member.memberProfile.birthday.value, gender: member.memberProfile.gender.value, departmentId: member.departmentId.value, postId: member.postId.value, employmentStatusId: member.employmentStatusId.value }) } async createSalary(salary: SalaryAttribute, memberId: MemberId): Promise<void> { await this.memberDriver.createSalary({ paymentAmount: salary.paymentAmount.value, paymentType: salary.paymentType.value, remark: salary.remark.value, paymentDate: salary.paymentDate.value }, memberId.value) } async update(member: MemberRelation): Promise<void> { await this.memberDriver.update({ id: member.member.id.value, name: member.member.member.name.value, birthday: member.member.member.birthday.value, gender: member.member.member.gender.value, departmentId: member.departmentId.value, postId: member.postId.value, employmentStatusId: member.employmentStatusId.value }) } async delete(id: MemberId): Promise<void> { await this.memberDriver.delete(id.value) } private convertMemberValue(member: MemberEntity) { const converMember = MemberRelation.from( member.member.id, member.member.name, member.member.birthday, member.member.gender, member.departmentId, member.postId, member.employmentStatusId ) return converMember } } |
・MemberController.ts
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 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | import { MemberUseCase } from '../usecases/MemberUseCase' import { SalaryUseCase } from '../usecases/SalaryUseCase' import { MemberRelation, MemberRelationAttribute, MemberRelations } from '../domain/MemberRelation' import { MemberBirthday, MemberGender, MemberId, MemberName, MemberProfile } from '../domain/Member' import { DepartmentId } from '../domain/Department' import { PostId } from '../domain/Post' import { inject, singleton } from "tsyringe" import { EmploymentStatusId } from '../domain/EmploymentStatus' import { MemberSalary } from '../domain/MemberSalary' import { PaymentAmount, PaymentDate, PaymentType, Remark, SalaryAttribute } from '../domain/Salary' @singleton() export class MemberController { constructor( @inject("MemberUseCase") private memberUseCase: MemberUseCase, @inject("SalaryUseCase") private salaryUseCase: SalaryUseCase ) {} async findMember(id: number): Promise<MemberForDetailResponse> { const memberId = new MemberId(id) const member: MemberRelation = await this.memberUseCase.find(memberId) return this.serializeMemberForDetail(member) } async findMemberSalaries(id: number): Promise<MemberSalaries> { const memberId = new MemberId(id) const memberSalary: MemberSalary = await this.salaryUseCase.findByMemberId(memberId) return this.serializeMemberSalaries(memberSalary) } async findAllMembers(): Promise<MemberForListResponse[]> { const members: MemberRelations = await this.memberUseCase.findAll() return this.serializeMemberForList(members) } async createMember(createParams: CreateMemberParams): Promise<void> { const { name, birthday, gender, department_id, post_id, employment_status_id } = createParams const createMember = new MemberRelationAttribute( new MemberProfile( new MemberName(name), new MemberBirthday(birthday), new MemberGender(gender) ), new DepartmentId(department_id), new PostId(post_id), new EmploymentStatusId(employment_status_id) ) await this.memberUseCase.create(createMember) } async createMemberSalary(createSalaryParams: CreateMemberSalaryParams): Promise<void> { const { member_id, payment_amount, payment_type, remark, payment_date } = createSalaryParams const createSalary = new SalaryAttribute( new PaymentAmount(payment_amount), new PaymentType(payment_type), new Remark(remark), new PaymentDate(payment_date) ) const memberId = new MemberId(member_id) await this.memberUseCase.createSalary(createSalary, memberId) } async updateMember(id: number, updateMemberParams: UpdateMemberParams): Promise<void> { const memberId = new MemberId(id) const { name, birthday, gender, department_id, post_id, employment_status_id } = updateMemberParams const updateMember = new MemberRelationAttribute( new MemberProfile( new MemberName(name), new MemberBirthday(birthday), new MemberGender(gender) ), new DepartmentId(department_id), new PostId(post_id), new EmploymentStatusId(employment_status_id) ) await this.memberUseCase.update(memberId, updateMember) } async deleteMember(id: number): Promise<void> { const memberId = new MemberId(id) await this.memberUseCase.delete(memberId) } private serializeMemberForList(members: MemberRelations) { return members.map((member: MemberRelation) => { return { name: member.member.member.name.value, department_id: member.departmentId.value, post_id: member.postId.value, employment_status_id: member.employmentStatusId.value } }) } private serializeMemberForDetail(member: MemberRelation) { return { id: member.member.id.value, name: member.member.member.name.value, birthday: member.member.member.birthday.value, age: member.member.member.birthday.getAge(), gender: member.member.member.gender.getName(), department_id: member.departmentId.value, post_id: member.postId.value, employment_status_id: member.employmentStatusId.value } } private serializeMemberSalaries(memberSalary: MemberSalary) { return { member_id: memberSalary.member.id.value, member_name: memberSalary.member.member.name.value, salaries: memberSalary.salaries.map(salary => { return { id: salary.id.value, payment_amount: salary.salaryAttribute.paymentAmount.value, payment_type: salary.salaryAttribute.paymentType.value, remark: salary.salaryAttribute.remark.value, payment_date: salary.salaryAttribute.paymentDate.value, } }) } } } export interface CreateMemberParams { name: string, birthday: Date, gender: number, department_id: number, post_id: number, employment_status_id: number, } export interface CreateMemberSalaryParams { member_id: number, payment_amount: number, payment_type: string, remark: string, payment_date: Date } export interface UpdateMemberParams { name: string, birthday: Date, gender: number, department_id: number, post_id: number, employment_status_id: number, } export interface MemberForDetailResponse { id: number, name: string, birthday: Date, age: number, gender: string, department_id: number, post_id: number, employment_status_id: number } export interface MemberForListResponse { name: string, department_id: number, post_id: number, employment_status_id: number } export interface MemberSalaries { member_id: number, member_name: string, salaries: { id: number, payment_amount: number, payment_type: string, remark: string, payment_date: Date, }[] } |
Framework & Driver
Framework & Driverはフレームワークやツールで構成されています。
このレイヤにあるMemberDriverはDBから欲しいデータを取得しMemberGatewayに返すようにしています。今回はDBから取得(ORMはSequelizeを使用)していますが、APIならここでAPIリクエストをして返ってきたデータを返します。返すデータはEntityとして詰め直してGatewayに返します。
MemberGatewayでドメインからJson形式に変換したJsonオブジェクトの型とMemberGatewayに返すEntityの型をここで定義しています。
他にもRouterなどありますがここでは割愛しています。
・MemberDriver.ts
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 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 | import { singleton } from "tsyringe" const db = require('../../models') @singleton() export class MemberDriver { async find(id: number): Promise<MemberEntity> { const member = await db.member.findByPk(id, { include: [ { model: db.department, as: 'department' }, { model: db.post, as: 'post' } ] }) return { member: { id: member.id, name: member.name, birthday: member.birthday, gender: member.gender, }, departmentId: member.department_id, postId: member.post_id, employmentStatusId: member.employment_status_id } } async findAll(): Promise<MemberEntity[]> { const members = await db.member.findAll({ include: [ { model: db.department, as: 'department' }, { model: db.post, as: 'post' } ] }) return members.map((member: MemberModelEntity) => { return { member: { id: member.id, name: member.name, birthday: member.birthday, gender: member.gender, }, departmentId: member.department_id, postId: member.post_id, employmentStatusId: member.employment_status_id } }) } async create(member: MemberValueProfileJson): Promise<void> { await db.member.create({ name: member.name, birthday: member.birthday, gender: member.gender, department_id: member.departmentId, post_id: member.postId, employment_status_id: member.employmentStatusId }) } async createSalary(salary: SalaryJson, memberId: number): Promise<void> { await db.salary.create({ member_id: memberId, payment_amount: salary.paymentAmount, payment_type: salary.paymentType, remark: salary.remark, payment_date: salary.paymentDate, }) } async update(memberJson: MemberJson): Promise<void> { const targetMember = await db.member.findByPk(memberJson.id) targetMember.name = memberJson.name targetMember.birthday = memberJson.birthday targetMember.gender = memberJson.gender targetMember.department_id = memberJson.departmentId targetMember.post_id = memberJson.postId targetMember.employment_status_id = memberJson.employmentStatusId await targetMember.save() } async delete(id: number): Promise<void> { const targetMember = await db.member.findByPk(id) await targetMember.destroy() } } export interface MemberJson { id: number, name: string, birthday: Date, gender: number, departmentId: number, postId: number, employmentStatusId: number } export interface MemberValueProfileJson { name: string, birthday: Date, gender: number, departmentId: number, postId: number, employmentStatusId: number } export interface SalaryJson { paymentAmount: number, paymentType: string, remark: string | null, paymentDate: Date } export interface MemberEntity { member: { id: number, name: string, birthday: Date, gender: number }, departmentId: number, postId: number, employmentStatusId: number } export interface MemberModelEntity { id: number, name: string, birthday: Date, gender: number, department_id: number, post_id: number, employment_status_id: number } |