長いこと自分のクソコードと戦ってきました。あちこち散らばるビジネスロジック、ロジック同士の密結合。どうしたらクリーンなコードが書けるのか、ずっと模索しています。ここ最近試行錯誤で試したクリーンアーキテクチャで良さそうな感触が得られたので、考えた事とサンプルコードを格闘の記録として残します。
とても長い記事ですので、嫌いな方はそっ閉じしてください。
TL;DR
- サンプルコードは Angular で書きました
- typeScript v4.1.5 を前提としています
- 私はフロントエンドエンジニアでまあまあ複雑な BtoB 商材のシステムの開発者です
設計の大事さ
フロントエンドにおける良い実装とは何でしょうか。早くリリースできるスピード感?カッコよい見た目?熟慮されたUIUX?ここがブレてしまうと今回の目的が見えなくなってしまうので、まず「良い実装」とは何かを整理してみました。
- 視認性:パッと見てそのページの目的が分かるもの、分かりやすい構造・見つけやすい位置
- 操作性:ユーザーがした(と思った)操作と結果表示が一致するもの
- 継続性:新規の実装や修正を加えた時に既存の挙動を破壊しないもの
- 保守性:どこで何が処理されているか推測できるもの、探さないコード
そして、それぞれを実現向上させると思われる手段を考えてみました。
- 視認性:デザイナー頑張る
- 操作性:動作確認やステークホルダーのフィードバックを受けて改善をサイクル化する
- 継続性:ユニットテストや結合テストを書く
- 保守性:分からねえ...
問題は「保守性」です。ファットなコンポーネントやサービスクラスには、データがどのように変化するのか推測しづらいコードがぶち込まれます。そういったコードに追加の実装を加える時、周辺をコードリーディングし、データの変化と影響の度合いを調査するところから作業が始まります。つまりそのコードは「変更に弱い」のです。
構造によって「どこで何が処理されているのか」を担保できないものでしょうか?たんすの引き出しの収納場所を決めておいたら(設計)、かたっぽ靴下ちゃんの相方を探すような(何が欠けているのか探し回る)時間は消滅するのではないでしょうか?これを解決できるのが、まだ出会ってないかもしれない何らかの設計ではないかと思っています。
なぜクリーンアーキテクチャ
クソコード量産機な自分に立ち向かうために、過去いくつかの設計を試してみました。
- オレオレ責務分割
- ステート管理
- クリーンアーキテクチャ < 今ここ
オレオレ責務分割とは、サービスクラスなどに自分なりのルールを設けロジックの書き方の統一を試みた方法です。ある程度コードは探しやすくなったものの根拠が曖昧なうえ構造に無駄が多く、収穫もありましたが満足できるものではありませんでした。ステート管理は悪くはないのですが、全てのロジックがステートに集中してしまいステートがカオスになる状態が発生します。コンポーネントのコードはめちゃくちゃシンプルになりますが、ページの状態やビジネスロジックの複雑さの解決、および密結合の解決には至りませんでした。次に興味を持ったのが DDD ですが、なかなか奥深い世界なゆえ理解と実践には至っていません。関連する記事などを読み漁るうちに辿り着いたのがクリーンアーキテクチャ、という訳です。
クリーンアーキテクチャが提唱された有名な書籍といえば Clean Architecture 達人に学ぶソフトウェアの構造と設計 ですが、DDD 含め他にもいろいろな技術投稿を参考とさせていただきました。
- ドメイン駆動設計 モデリング/実装ガイド
- FrontEnd:DDD
- WEBフロントエンドにおけるソフトウェア設計の考察
- Fat ComponentにしないためのWebフロントエンド設計
- Almin.js | JavaScriptアーキテクチャ
- Clean Architecture in Web Frontend #mixleap
- フロントエンドでClean Architectureを適用してみる(+サンプルコード)
- 大規模フロントエンドのクリーンアーキテクチャ化 ~ 年間売上1,000億円企業モノタロウの取組み ~
クリーンアーキテクチャは DDD とも通じる部分があり「ドメイン駆動設計 モデリング/実装ガイド」はぜひともお薦めしたい書籍ですし @philomagiさんのスライド はどれも共感が多く、とても学びになりました。
クリーンアーキテクチャの基本
クリーンアーキテクチャに関する技術投稿は山ほどあるので具体的な説明は割愛し、私の理解の要約のみとします。
- システムの根幹はビジネスロジック(= 詳細)
- Webやストレージなどビジネスロジックを取り巻くものはプラグイン(= 方針)
- 詳細と方針は分離する
- 方針はいつでも差し替え可能にしておく
- 方針の生息場所をレイヤーに分けて考える
- レイヤーに分ける事で依存を一方向に整える
書籍に登場する役割をもったまとまり(以下、登場人物)の抜粋を示します。
-
Entity
最重要ビジネスデータ・ビジネスルール。 -
UseCase
システムの意図を示すもの。システムを使用する方法の記述。 -
Presenter
レイヤー間のインターフェースの差分を吸収し扱いやすいフォーマットに変換するサービス。 -
ViewModel
ビューをレンダリングするためのモデル。
アーキテクチャを支える原則やパターンでは Humble Object パターン や SOLID の原則 が紹介されていました。これらも技術投稿が山ほどあるので説明は割愛します。ものすごい雑な書籍の感想ですが、今突きつけられている前提や環境にガチガチに依存してしまうと、がんじがらめで苦しい世界ができあがってしまうよね、ステーブルでありフレキシブルなコードを書こうよ、という理解です。
設計に関する技術投稿はわりとバックエンド寄りの技術が多いように思います。その中でフロントエンド領域に関しては「UI」「GUI」「Web」などシステムの表現場所として言及され、クリーンアーキテクチャにおいても「View」と言及されるだけでその先の具体的な設計については指示がありません。じゃあ「View」の世界では好き勝手やっていいのかって言うとそれは違うような気がします。CSS フレームワークやアプリケーションフレームワークはプラグインとして存在します。そしてユーザーオペレーションをトリガとしてページを変化させる時にも明確なルールが存在します。「View」はそこだけ切り取るとひとつの世界であり、やはり原理原則に則ったクリーンなコードを書く必要があると感じています。
考察:フロントエンドにクリーンアーキテクチャを適用する難しさ
フロントエンドでデファクトとなる設計手法がなかなか浸透しない状況を鑑みるに、クリーンアーキテクチャを始めとして提唱される手法がフロントエンドの世界観にピンポイントでマッチしない難しさがあると思っています。私自身もクリーンアーキテクチャの書籍を読んで「うーん、どうしたものか...」と思った点はいくつかありました。
プラグインへの要求が薄い
私個人の開発を例に上げると、リソースの取得元・保存先は RestAPI 一択です。リポジトリオブジェクトは常に一種類の実装しかありません。ファイル保存に切り替えるような事もないですし、ローカル開発環境では Node.js 製のビルトインサーバーが立ち上がるので敢えてモックのリポジトリオブジェクトに差し替える必要もありません。システムを利用するユーザーについても、通常のユーザーがアクセスするページとステークホルダーのアクセスする管理者ページが分離されているので、アクターは常に「このページを開いた人」という一種類しかないのです。プラグインを差し替える事のできる便利さ快適さがイメージしづらいというのが現状です。
モデルが見つからない
モデル抽出として、給与計算を例に考えてみます。企業(Company)には従業員(Employee)が所属していて、エンジニア(Engineer)と営業(Sales)の職種があるとします。職種ごとの賃金に家族手当(familyAllowance)と地域手当(areaAllowance)が上乗せされた額が給与として算出されるとします。
Company
└── Employee
├── familyAllowance
└── areaAllowance
==============================
Employee
├── Engineer
│ └── baseSalary
│
└── Sales
└── baseSalary
==============================
// 職種と手当によって給与が異なる
employee.calcSalary();
給与明細をフロントエンドでブラウザに出力する場合、RestAPI で計算結果さえ受け取れば十分です。
interface Salary {
familyName: string;
jobType: string;
amount: number;
}
const salary: Salary = {
familyName: 'ringtail003',
jobType: 'エンジニア',
amount: 100000000,
};
フロントエンドでは計算結果そのものがコンテキストであり、結果に至る根拠や制約が露出しない特徴があります。ここからビジネスのドメインをモデルとして抽出する事にあまり意味を見いだせません。
詰め替えコストの手間
引き続き給与明細の例で考えると、明細のコンテキストさえあれば、一覧も出力できますし明細も出力できます。
interface Salary {
familyName: string;
jobType: string;
amount: number;
}
// 一覧のページ
const salaryList: Salary[] = ...;
// 明細のページ
const salary: Salary = ...;
リポジトリオブジェクトからコンポーネントまで扱うインターフェースは一貫して変化しないため、異なるレイヤーと対話するためのインターフェース詰め替え作業が単なる手間に思えてきます。
パッケージ的な境界が作れない
TypeScript の特性上、縦横無尽な参照(import)が可能です。また JavaScript のサブセットである以上、単なるオブジェクトに関しては値の変更も簡単で、禁止するには seal / freeze などを使ってガチガチの実装を用意する必要があります。加えて「このサービスクラスはコンポーネントに直接 DI しないでね」「この変数はコンポーネントで上書きして良いけど、こっちのサービスクラスからは readonly にしてね」というようなルールを持ち込めない難しさがあります。
状態管理とバリデーションの持ち方が分からない
クリーンアーキテクチャの書籍になぞらえれば、ViewModel を生成してからのその先の処理がフロントエンドの本領域といったところでしょうか。解決の糸口が見えなかったものに「状態管理」「バリデーション」があります。これらは ViewModel として表出した値にユーザーオペレーションが変化を加える事で発生します。「リスト A の選択肢によって項目 B が変化する」「リスト A は未選択であってはならない」は明らかにビジネスルールですが、安易にモデルに実装した場合「リスト A が HTTP レスポンスの待機中」のような外側のレイヤーに引っ張られたロジックを持ち込む事になります。
考察:フロントエンドにクリーンアーキテクチャは不要なのか
ぐるぐるぐるぐる考えて「フロントエンドはリソースを GET で受け取り構造はそのままに値だけ変化させて POST する、いわゆる JSON 色付係ではないのか...?」という思考に陥りましたが、やはり保守性の向上は捨てがたい...。要求の複雑さに負けてつぎはぎのワークアラウンドを繰り返すシステムから抜け出したい...。ぐぬぬぬ...。
やっぱり設計は大事です。だって Humble Object パターン や SOLID の原則に「注:フロントエンドは除く」なんて書いてないし、思考停止したらエンジニアではなく、ただひたすら目に見えて動くものを作るだけのコーダーになってしまいます。
考察:どのようにクリーンアーキテクチャを適用できるか
プラグインの考え方を切り替える
クリーンアーキテクチャの書籍にあるように、フレームワークはプラグイン(方針)のひとつです。実際に差し替えるかどうかは別として、差し替え可能なように設計しておくメリットがあります。ビジネスロジックをフレームワークと切り離す事で、振る舞いに対する変更が UI に影響しなければ修正作業はぐっと楽になりますし、振る舞いと UI の変更をコンフリクトさせず別のメンバーで同時進行する事も可能になります。
これはアプリケーションフレームワークに限った話ではなく、CSSフレームワークも、サービスクラス群も、全てがプラグインです。役割と影響範囲を明確にする事で、リファクタや追加の修正が安全なものになります。
UI に表現するものがモデル
UI の問題解決をフロントエンドのドメインとし、UI そのままの形でモデリングする事にしました。先の給与明細で例えると「HTTP レスポンスの待機中」「一覧のページには 100 件しか表示しない」「ソート機能」などが該当します。ここには計算に至る根拠や制約は必要なく、結果をどのように加工して表現するかに焦点を当てます。
モデルはクリーンアーキテクチャ上「エンティティ」として位置付けされます。給与明細は UI からの要求がない限り、振る舞いを必要としない薄いエンティティです。
class Salary {
constructor(
public readonly familyName: string,
public readonly jobType: string,
public readonly amount: number,
) {}
}
不必要な詰め替えをしない
クリーンアーキテクチャの書籍には、レイヤーの境界に専用インターフェースを用意し(Input/Output)、抽象に依存する事で別レイヤーに属する具象への依存を避けるとありますが、リポジトリオブジェクト・ユースケース・コンポーネントと経由すると、たったひとつの操作でも 3 レイヤー * ( Input + Output ) で合計 6 インターフェースを用意する事になります。ここは少しルールを緩め、依存の方向を保てば(外側から内側)異なるレイヤーの具象に依存して良い、とするのが現実的かなと思いました。
フォルダ構造とネーミングルールで境界を意思表示する
例えば UserRepository というサービスクラスが、引数を変更するだけのメソッドを持っていたら「リポジトリオブジェクトなのに?」と違和感を感じますよね。たぶん。
class UserRepository {
// リポジトリなのに?永続化しないの?なんで?
postUser(user: User): void {
user.isSelected = true;
}
}
私が知る限り Repository パターン はフロントエンドでも当然のごとく鎮座して、エンジニアにルールを守らせています。フォルダ構造やネーミングルールの元に同じ書き方のものが集約できれば、Repository パターンと同じようにチーム内のエンジニアの認識が揃って「エンティティなのに?」「ユースケースなのに?」という違和感が生まれてくるはずです。
// フォルダ構造のルール違反
├── entities
├── repositories
├── use-cases
└── view-models
├── foo-view-model.ts
├── bar-view-model.ts
└── hoge-service.ts // 場所まちがった?
// 宣言方法のルール違反
class FooViewModel {
...
}
class BarViewModel {
...
}
// これだけ関数?おかしくない?
const HogeViewModel = () => {
...
};
状態管理とバリデーションをエンティティに寄せる
「リスト A の選択肢によって項目 B が変化する」「リスト A は未選択であってはならない」というビジネスルールを知っているのはエンティティです。別の場所にフラグやロジックを持たせてアドホックな解決策を取るのではなく、やはり本来のビジネスルールの生息場所に実装しようと帰着しました。またビジネスルールを知っているエンティティ自身が「そのルールに沿っているか」を検証できるため、バリデーションもエンティティに持たせる事にします。エンティティは UI の表現を踏襲した形とするため、このバリデーションはページに表示するバリデーションエラーと形状が一致します。
実装方針
レイヤー
クリーンアーキテクチャに倣い 4 つのレイヤーを意識します。依存は外側から内側の一方向を遵守します。レイヤーは概念上のもので、ファイルを探しやすくする目的でフォルダ構成とは一致させません。
データフロー
DOM のイベントをトリガとしてエンティティを変更するケースと、それに付随して HTTP リクエストを伴うケースの 2 パターンをシミュレートした図を書いて整理しました。エンティティおよびそれを変換したビューモデルは、コンテナコンポーネントの変数として持たせる事にしました。
登場人物
データフローを構成しているのはこのような登場人物です。これらの登場人物に合わせた名前をフォルダ構造とします。(/entities
/use-cases
等)
異なるレイヤー間でやり取りするための専用インターフェースは用意せず、登場人物に依存関係のルールを持たせます。依存を強制する事はできないため、絵に描いた餅とならないよう注意が必要ですが。
状態管理
「リスト A の選択肢によって項目 B が変化する、項目 B は HTTP リクエストで取得する」というケースを想定して状態管理をシミュレートします。
- Group の選択肢により GroupSetting の Type のリストが決まる
- Type の選択肢により GroupSetting の Items のリストが決まる
Group リストを示すエンティティの初期状態は null
です。HTTP リクエストを投げると同時に値は new GroupList()
に変わり、HTTP レスポンスを受け取ると setItems()
でリストの選択肢がセットされます。このエンティティの変化により UI の状態を切り替えます。
GroupSetting も同じように、初期状態 null
から new GroupSetting()
または setItems()
とエンティティが変化します。
サンプル実装
Container Component
// setting.component.html
<div>
<app-group-list
[vm]="vm.groupList"
(onGroupSelected)="handleGroupSelected($event)"
></app-group-list>
<app-group-setting
[vm]="vm.groupSetting"
(onTypeSelected)="handleTypeSelected($event)"
></app-group-setting>
...
</div>
// setting.component.ts
export class SettingComponent implements OnInit {
vm!: SettingViewModel;
entity!: Setting;
constructor(
private presenter: SettingUiPresenter,
private createSetting: CreateSettingUseCase,
private selectGroupList: SelectGroupListUseCase,
private selectTypeList: SelectTypeListUseCase,
...
) {}
ngOnInit(): void {
this.createSetting.exec((v) => this.useCaseHandler(v));
}
handleGroupSelected(groupId: number) {
this.selectGroupList.exec(this.entity, groupId, (v) =>
this.useCaseHandler(v)
);
}
handleTypeSelected(typeId: TypeId) {
this.selectTypeList.exec(this.entity, typeId, (v) =>
this.useCaseHandler(v)
);
}
...
private useCaseHandler(setting: Setting): void {
this.entity = setting;
this.vm = this.presenter.parse(setting);
}
}
- コンポーネントは、親コンポーネント(コンテナ)・子コンポーネントの 2 種類あります。
- コンテナコンポーネントにデータ(Entity/ViewModel)を持たせます。
- コンテナコンポーネントは UseCase と対話して Entity を得ます。
- コンテナコンポーネントは Presenter 経由で Entity を ViewModel に変換します。
- 子コンポーネントには ViewModel の一部を渡します。
UseCase
// create-setting-use-case.ts
export class CreateSettingUseCase {
constructor(
private groupRepository: GroupRepository,
) {}
exec(handler: (setting: Setting) => void): void {
const setting = new Setting();
setting.setGroupList(new GroupList());
handler(setting);
this.groupRepository.getList().subscribe((groupList) => {
setting.setGroupList(groupList);
handler(setting);
});
}
}
- UseCase は Entity を生成します。
- また Entity に対して変更を加えます。
Entity
// group-list.ts
export class GroupList extends Entity<GroupList> {
#items: Group[] | null;
constructor() {
super();
this.#items = null;
}
get items() {
return this.#items;
}
// 型の一致しないパラメータも受け取れるようにするため、
// エンティティ共通でセッターでなく setItems というメソッドを使った
setItems(items: Group[]): void {
this.#items = items;
this.#items.forEach((v) => (v.handler = (v) => this.onChangeHandler(v)));
}
getErrors(): ValidationErrorList {
const list = new ValidationErrorList(...);
// リストの項目がひとつも選択されていなければエラー
if (!(this.#items || []).some((v) => v.isSelected)) {
list.add(...);
}
// HTTP レスポンス待ちの場合エラー
if (this.#items === null) {
list.add(...);
}
// 子エンティティのエラーを伝搬
(this.#items || []).forEach((v) => list.concat(v.getErrors()));
return list;
}
private onChangeHandler(group: Group): void {
this.#items!.forEach((v) => {
if (v.id !== group.id) {
v.deselect();
}
});
}
}
- Entityには「ひとつしか選択できない」などの振る舞いを持たせます。
-
getErrors
というバリデーション検査のためのメソッドを持たせる事で、自身の制約違反を外から参照可能にします。
フロントエンドのモデルは「入力途中」という状態を維持する必要があるため、不整合も許容するようにしておきます。一番トップの親 Entity から子 Entity の getErrors を辿れば全てのバリデーションエラーを把握でき、コンテナコンポーネントに設置した保存ボタンを disabled にするなど制御できるという仕組みです。
ポリモーフィズムを実現する場合はファクトリを経由して Entity を生成します。
- item-list-factory.ts
- item-list-a.ts | 3 つまで選択可
- item-list-b.ts | 選択肢を持たない
- item-list-c.ts | 固定の選択肢を持ち選択解除できない
Entity の基底クラス
// entity.ts
export abstract class Entity<T> {
#isSealed = false;
handler: (item: T) => void = () => {};
abstract getErrors(): ValidationErrorList;
get isSealed() {
return this.#isSealed;
}
attach(): void {
this.#isSealed = true;
}
detach() {
this.#isSealed = false;
}
}
- Entity は基底クラスを継承する事としました。
isSeald
は自身に変更が加えられない状態を示すもので、継承した Entity または UseCase から自由にセットします。HTTP リクエストと同時にこのフラグを立て、レスポンスが返るまで次のクリックを受け付けない、等の処理の分岐に利用します。
handler
は自身に変更が加えられたタイミングを親 Entity に伝えるものです。リストの項目である Entity から「選択された」タイミングが親であるリストの Entity に伝わり「他の項目の選択を解除する」等の処理を指示します。
Repository
// group-repository.ts
export class GroupRepository {
constructor(
private presenter: GroupListApiPresenter
) {}
getList(): Rx.Observable<GroupList> {
return Rx.of(
this.presenter.parseGetListBody([
{ id: 1, label: 'group1' },
{ id: 2, label: 'group2' },
{ id: 3, label: 'group3' },
])
).pipe(delay(3000));
}
}
- Reposiotry は ApiPresenter を経由してHTTPリクエスト/レスポンスを Entity に変換します。
ApiPresenter
// group-list-api-presenter.ts
export class GroupListApiPresenter {
parseGetListBody(response: GetListResponse): GroupList {
const groupList = new GroupList();
groupList.setItems(response.map((v) => new Group(v)));
return groupList;
}
}
- ApiPresenter は HTTP レスポンス/リクエストのインターフェースと Entity を変換します。
- HTTP リクエスト・レスポンスは専用のインターフェースを用意します。
ApiPresenter がパースを請け負う事により「HTTP レスポンスの値は snake_case だけどフロントエンドでは CamelCase で扱いたい」というような開発者の要求に応える事ができます。
UIPresenter
// group-list-ui-presenter.ts
export class GroupListUiPresenter {
parse(groupList: GroupList | null): GroupListViewModel {
if (groupList === null) {
return {
isEmpty: true,
isLoading: false,
items: null,
};
}
if (!groupList.items) {
return {
isEmpty: false,
isLoading: true,
items: null,
};
}
return {
isEmpty: false,
isLoading: false,
items: groupList.items!.map(({ id, label, isSelected }) => {
return { id, label, isSelected };
}),
};
}
}
- UIPresenter は Entity を ViewModel に変換します。
- その際に構造を読み取り UI をレンダリングしやすい形にします。
ViewModel
// group-list-view-model.ts
export interface GroupListViewModel {
isEmpty: boolean;
isLoading: boolean;
items: GroupViewModel[] | null;
}
// group-view-model.ts
export interface GroupViewModel {
isSelected: boolean;
id: number;
label: string;
}
- ViewModel は振る舞いを持たないためインターフェースです。
- レンダリングに分かりやすいフラグなどを自由に持たせます。
- ViewModel を介する事でコンポーネントのロジックに
if (items !== null) {}
のような分岐を持たせないようにします。
Child Component
// group-list.component.html
<app-loading *ngIf="vm.isLoading"></app-loading>
<ng-template
[ngIf]="!vm.isLoading"
>
<ul>
<li
*ngFor="let item of vm.items;"
[attr.isSelected]="item.isSelected"
(click)="onGroupSelected.emit(item.id)"
>{{item.label}}</li>
</ul>
</ng-template>
// group-list.component.ts
export class GroupListComponent implements OnInit {
@Input() vm!: GroupListViewModel;
@Output() onGroupSelected = new EventEmitter<number>();
constructor() {}
ngOnInit(): void {}
}
- 子コンポーネントは ViewModel を受け取って表示するだけの役割です。
- ロジックは極力持たせません。
まとめ
ルールの導入には、それにそぐわない要求への対応がつきものです。そのルールが「設計」のようなでかい主語であればなおさらでしょう。私が好感触を得られたと思ったこのサンプルコードも、実践で使ううちいくつもの要求と折り合いが付かず戦う事になると思っています。もしかしたらそのうち、クリーンアーキテクチャとは別のまた優れた設計指針が提唱されるかもしれませんね。その時はまたチャレンジしてみたいと思います。
サンプルコードを書くにあたり読ませていただいた技術投稿および GitHub のコードは「フロントエンドに設計は必要なんだろうか」「ユースケースなんてフロントエンドで実際に実装されているんだろうか」という私の不安を打ち砕く大きな助けとなりました。感謝を込めて、本文中に記載したものを再掲させていただきます。