こちらはDEV Communityに2021年9月2日に投稿され、現在反響を巻き起こしているフロントエンドにおけるクリーンアーキテクチャの実装についてのAlexさんの記事になります(原文はこちら)(twitterにて翻訳掲載許可取得済み)。
かなり大ボリュームな超大作記事となっておりますが、Reactなどを使ったフロントエンドプロジェクトのディレクトリー構成やファイルごとの責務の切り分けのベストプラクティスなどの決定版といえるものがまだまだ出てこない中で、個人的にまさに待ち侘びていたような内容の記事かと思い、是非日本のフロントエンドコミュニティでも知見が共有されればと思いました。
それでは以下、本文です。
*翻訳は大部分をDeepL翻訳によって行っていますが、適宜修正してあります。
#Clean Architecture on Frontend
少し前に、私はフロントエンドにおけるクリーンアーキテクチャについての講演を行いました。この記事では、その講演の概要を説明し、少し拡張しています。
まず、参照いたただくと便利なものへのリンクを貼っておきます。
[これから設計するアプリケーションのソースコード](The source code for the application we're going to design)
[実際のアプリケーションのサンプル](Sample of a working application)
##計画の内容
まず、クリーンアーキテクチャとは何かを説明し、ドメイン、ユースケース、アプリケーション・レイヤーなどの概念を理解します。その上で、フロントエンドにどのように適用されるのか、また、その価値があるのかどうかを議論します。
次に、クリーンアーキテクチャのルールに従って、クッキー屋さんのフロントエンドをデザインします。そして最後に、ユースケースの1つをゼロから実装して、使えるかどうかを確認します。
このストアのUIフレームワークにはReactが使われていますが、これはこのアプローチがReactにも使えることを示すためです。(そして、この記事の元になった講演は、すでにReactを使っている開発者に向けたものだったからです😄) Reactは必須ではありませんが、この記事で紹介しているものは、他のUIライブラリやフレームワークでも使うことができます。
コードには少しだけTypeScriptが登場しますが、エンティティを記述するための型やインターフェイスの使い方を説明するためだけのものです。ここで見るものはすべてTypeScriptなしでも使えますが、コードの表現力は落ちます。
今日はOOPの話はほとんどしませんので、この記事が重度のアレルギーを引き起こすことはありません。最後に一度だけOOPについて言及しますが、アプリケーションの設計を止めることはありません。
また、テストはこの記事のメイントピックではないので、今日は省略します。しかし、テスト可能性については念頭に置き、それを改善する方法については途中で言及するつもりです。
##アーキテクチャと設計
設計とは、基本的には、物事を分解して...元に戻せるようにすることです。...物事を構成可能なものに分離すること、それが設計です。
- Rich Hickey. Design Composition and Performance
システム設計とは、ここで引用文したように、システムを後で組み立て直せるように分離することです。そして最も重要なことは、あまり手間をかけずに簡単に組み立てることが出来るようにすることです。
私もそう思います。しかし、私はアーキテクチャのもうひとつの目標は、システムの拡張性だと考えています。プログラムに求められるものは常に変化しています。新しい要求に対応するために、プログラムの更新や修正が容易であることが望まれます。クリーンアーキテクチャは、この目標を達成するために役立ちます。
##The Clean Architecture
クリーン・アーキテクチャとは、アプリケーション・ドメインに近接しているかどうかによって、責任と機能の一部を分離する方法である。
ドメインとは、プログラムでモデル化した実世界の一部を意味します。これは、データの変換が実世界の変換を反映していることです。例えば、製品の名前を更新した場合、古い名前を新しい名前に置き換えることがドメイン変換です。
The Clean Architectureは、その中の機能が層に分かれていることから、3層アーキテクチャと呼ばれることもあります。The Clean Architectureに関するオリジナルの投稿では、レイヤーを強調した図が掲載されています。
###ドメイン層
中心にあるのがドメイン層です。アプリケーションの対象領域を表すエンティティやデータ、そしてそのデータを変換するためのコードを指します。ドメインは、1つのアプリケーションと他のアプリケーションを区別する核心です。
ドメインとは、ReactからAngularに移行しても、あるいはいくつかのユースケースを変更しても変わらないものだと考えることができます。ストアの場合は、商品、注文、ユーザー、カート、そしてそれらのデータを更新する機能などです。
ドメインエンティティのデータ構造と、その変換の本質は、外界から独立しています。外部イベントはドメイン変換のきっかけとなるが、どのように変換されるかは決定しません。
アイテムをカートに追加する機能は、アイテムがどのように追加されたかを気にしません。ユーザー自身が「購入」ボタンを押して追加したのか、プロモーションコードを使って自動的に追加したのかを問いません。どちらの場合でも、アイテムを受け入れ、追加されたアイテムを含む更新されたカートを返します。
###アプリケーション層
ドメインの周りには、アプリケーション層があります。この層はユースケース、つまりユーザーシナリオを記述します。あるイベントが発生した後のことを担当します。
例えば、「カートに入れる」というシナリオはユースケースです。ボタンがクリックされた後に取るべきアクションを記述します。これは、次のような「オーケストレーター」の一種です。
サーバへリクエストを送る→
それにドメイン変換を行う→
レスポンスデータを使ってUIを再描画する
また、アプリケーション層にはポートがあります。これは、アプリケーションが外部とどのように通信するかを指定するものです。通常、ポートはインターフェイスであり、動作契約(behavior contarct)でもあります。
ポートは、アプリケーションの希望と現実の間の「バッファーゾーン」の役割を果たします。入力ポートは、アプリケーションが外界からどのように連絡を受けたいのかを示します。出力ポートは、アプリケーションが外界とどのように通信して準備を整えるのかを示します。
ポートについては、後で詳しく説明します。
###アダプタ(Adapters)層
一番外側のレイヤは、外部サービスへのアダプタを含みます。アダプタは、外部サービスの互換性のない API を、アプリケーションの希望に沿った API に変換するために必要です。
アダプタは、私たちのコードとサードパーティのサービスのコードとの間の結合度を下げるための優れた方法です。結合度が低いと、他のモジュールが変更されたときに1つのモジュールを変更する必要性が減ります。
アダプタはよく次のように分けられます。
- ドライビング(driving) - アプリケーションに信号を送信するもの。
- ドリブン(driven) - アプリケーションから信号を受け取るもの
ユーザーは最も頻繁にドライビングアダプタと対話します。例えば、UIフレームワークがボタンのクリックを処理するのは、ドライビングアダプタの仕事です。ドライビングアダプタは、ブラウザのAPI(基本的にはサードパーティのサービス)と連携し、イベントをアプリケーションが理解できる信号に変換します。
ドリブンアダプタは、インフラストラクチャと対話します。フロントエンドでは、インフラのほとんどはバックエンドサーバーですが、検索エンジンなどの他のサービスと直接やりとりすることもあります。
中心から離れれば離れるほど、コードの機能は「サービス指向」になり、アプリケーションのドメイン知識から離れていくことに注意してください。これは後に、どのモジュールをどのレイヤーに所属させるかを決める際に重要になります。
###依存関係のルール
3層構造のアーキテクチャには、外側の層だけが内側の層に依存できるという依存関係のルールがあります。これは次のことを意味します。
- ドメインは独立していなければならない。
- アプリケーション層はドメインに依存できる。
- 外側の層は何にでも依存できる。
このルールを乱用しない方が良いのですが、時にはこのルールに違反することもあります。例えば、依存関係がないはずのドメインでも、「ライブラリのような」コードを使うと便利な場合があります。このような例は、ソースコードを見るときにチェックしてみましょう。
依存関係の方向性が制御されていないと、複雑で混乱したコードになってしまいます。例えば、依存関係のルールを破ると、次のようになります。
- モジュールAがBに依存し、BがCに依存し、CがAに依存するような循環的な依存関係。
- 一部をテストするためにシステム全体をシミュレートしなければならないような、テスト容易性の低下。
- 結合度が高すぎて、結果的にモジュール間の相互作用が脆弱になる。
##クリーンアーキテクチャの利点
さて、このコードの分離がもたらすものについて説明しましょう。いくつかの利点があります。
###ドメインの分離
すべての主要なアプリケーション機能は、ドメインという1つの場所に隔離され、集められています。
ドメイン内の機能は独立しているため、テストが容易になります。モジュールの依存関係が少なければ少ないほど、テストに必要なインフラストラクチャも少なくなり、モックやスタブも必要なくなります。
また、独立したドメインは、ビジネス上の要件に対してテストしやすいです。これは開発者が、アプリケーションが何をすべきかを把握するのに役立ちます。さらに、独立したドメインは、ビジネス言語からプログラミング言語への「翻訳」におけるエラーや不正確さをより早く発見するのに役立ちます。
###独立したユースケース
アプリケーションシナリオ、ユースケースは別々に記述されます。これらによって、私たちがどのようなサードパーティのサービスを必要とするかが決定されます。私たちは外の世界を私たちのニーズに合わせるのであって、その逆ではありません。そのため、サードパーティのサービスをより自由に選択することができます。例えば、現在の決済システムが高額な料金を請求するようになったら、すぐに変更することができます。
また、ユースケースのコードはフラットになり、テスト可能で拡張性があります。この点については、後ほど例を挙げて説明します。
###交換可能なサードパーティのサービス
外部サービスが置き換え可能になるのは、アダプタのおかげです。インターフェイスを変更しない限り、どの外部サービスにインターフェイスが組み込まれるかは問題ではありません。
このようにして、他人のコードの変更が自分のコードに直接影響しないように、変更の伝播に対する障壁を作ります。また、アダプタはアプリケーションのランタイムにおけるバグの伝播を制限します。
##クリーンアーキテクチャのコスト
アーキテクチャとはまず道具です。他のツールと同様に、クリーンアーキテクチャにも、その利点の他にコストがあります。
###時間がかかる
主なコストは時間です。設計時だけでなく、実装時にも時間が必要です。なぜなら、アダプタを書くよりもサードパーティのサービスを直接呼び出す方が常に簡単だからです。
また、システムのすべてのモジュールの相互作用を事前に考えることは困難です。なぜなら、すべての要件と制約が事前にわからないからです。設計時には、システムがどのように変化するかを念頭に置き、拡張の余地を残しておく必要があります。
###過剰なまでの冗長性
一般的に、クリーンなアーキテクチャの正統的な実装は、必ずしも便利ではなく、時には有害でさえあります。プロジェクトの規模が小さい場合、完全な実装はやり過ぎで、新規参入の敷居を高くしてしまいます。
予算や納期を守るために、設計上のトレードオフが必要になるかもしれません。このようなトレードオフとはどういうことか、具体的に例を挙げて説明します。
###オンボーディングが困難になる可能性
クリーンアーキテクチャーを全面的に導入すると、オンボーディングが困難になります。
プロジェクトの初期段階で過剰なエンジニアリングを行うと、後になって新しい開発者を迎え入れるのが難しくなります。このことを念頭に置いて、コードをシンプルに保つ必要があるのです。
###コード量の増加
フロントエンドに特有の問題として、クリーンなアーキテクチャによって、最終的なバンドル内のコード量が増加することがあります。ブラウザに渡すコードが増えれば増えるほど、ブラウザはダウンロード、解析、解釈をしなければなりません。
コードの量を監視し、どこで手を抜くかを決定しなければなりません。
- ユースケースをもう少しシンプルに表現してみる。
- ユースケースをもう少しシンプルに記述するとか、ユースケースをバイパスしてアダプターから直接ドメインの機能にアクセスするとか。
- コードの分割を微調整する必要があるかもしれません。…などなど
###コスト削減の方法
手を抜き、アーキテクチャの「クリーンさ」を犠牲にすることで、かかる時間とコードの量を減らすことができます。私は一般的に急進的なアプローチは好きではありません。ルールを破ることがより現実的(例えば、潜在的なコストよりもメリットが大きい)であれば、私はそれを破ります。
ですから、しばらくの間はクリーンなアーキテクチャーのいくつかの側面に抵抗があっても、まったく問題はありません。しかし、絶対に割く価値のある必要最低限のリソースは、次の2つです。
####ドメインの抽出
抽出されたドメインは、私たちが設計しているものを全般的に理解し、それがどのように機能すべきかを理解するのに役立ちます。抽出されたドメインは、新しい開発者がアプリケーションやそのエンティティ、それらの間の関係を理解するのを容易にします。
他のレイヤーをスキップしたとしても、コードベース上に広がっていない抽出されたドメインを使って作業やリファクタリングを行うことが容易になります。他のレイヤーは必要に応じて追加できます。
####依存関係のルールに従う
捨ててはいけない2つ目のルールは、依存性のルール、というかその方向性です。外部のサービスは我々のニーズに合わせなければならず、そうでないものはありません。
もし、検索APIを呼び出せるようにコードを「微調整」していると感じたら、何かが間違っています。問題が広がる前にアダプタを書いたほうがいいでしょう。
##アプリケーションの設計
理論的な話をしてきましたが、いよいよ実践に入ります。あるクッキー屋さんのアーキテクチャを設計してみましょう。
この店では、原材料が異なるさまざまな種類のクッキーを販売します。ユーザーはクッキーを選んで注文し、第三者の決済サービスで代金を支払います。
ホーム・ページには、購入できるクッキーのショーケースがあります。クッキーを購入できるのは、ユーザーが認証された場合のみとなります。ログインボタンを押すと、ログインページが表示され、ログインできるようになります。
(私はウェブデザイナーではないので、見た目は気にしないでください😄)。)
ログインに成功すると、カートにクッキーを入れることができるようになります。
クッキーをカートに入れると、注文することができます。支払いが完了すると、リストに新しい注文が追加され、ショッピングカートがクリアされます。
ここでは、チェックアウトのユースケースを実装します。残りのユースケースについては、ソースコードをご覧ください。
まず、どのような種類のエンティティ、ユースケース、機能を持つのかを定義します。そして、それらがどのレイヤーに属するべきかを決めましょう。
###ドメインの設計
アプリケーションで最も重要なものは、ドメインです。これは、アプリケーションの主要なエンティティとそのデータ変換を行う場所です。アプリのドメイン知識をコードで正確に表現するためには、ドメインから始めることをお勧めします。
ストアのドメインには以下が含まれます。
各エンティティのデータタイプ:user、cookie、cart、order。
各エンティティを作成するためのファクトリ、またはOOPで記述する場合はクラス。
そして、そのデータの変換関数です。
ドメイン内の変換関数は、ドメインのルールにのみ依存し、それ以外には依存しないものとします。そのような関数とは、例えば次のようなものです。
- トータルコストを計算する関数。
- ユーザーの味の好みの検出
- ショッピングカートに入っているかどうかの判断、などなど。
###アプリケーション層の設計
アプリケーション層には、ユースケースが含まれます。ユースケースには、必ずアクター、アクション、そして結果があります。
店舗では、次のように区別することができます。
- 商品購入のシナリオ
- 決済:サードパーティの決済システムの利用
- 商品や注文とのやりとり:更新、閲覧
- 役割に応じたページへのアクセス。
ユースケースは通常、対象分野の観点から説明されます。例えば、「チェックアウト」のシナリオは、実際にはいくつかのステップで構成されています。
- ショッピングカートから商品を取り出し、新しい注文をする。
- 注文の支払いを行う。
- 支払いに失敗した場合、ユーザーに通知する。
- カートを清算し、注文を表示する。
ユースケース関数は、これらのシナリオを記述するコードになります。
また、アプリケーション層には、外部との通信を行うためのポート-インターフェースがあります。
###アダプタ層の設計
アダプタ層では、外部サービスへのアダプタを宣言します。アダプタは、サードパーティのサービスの互換性のないAPIを我々のシステムに適合させます。
フロントエンドでは、アダプタは通常、UIフレームワークとAPIサーバーのリクエストモジュールです。ここでは、以下のようにします。
- UIフレームワーク
- API リクエストモジュール
- ローカルストレージ用のアダプタ
- アプリケーション層へのAPI回答のアダプターとコンバーター
機能が「サービス的("service-like")」であればあるほど、図の中心から離れていくことに注意してください。
###MVCアナロジーの利用
あるデータがどのレイヤーに属しているのかを知るのが難しいことがあります。ここでは、MVCのちょっとした(そして不完全な!)例えが役に立つかもしれません。
- モデルは通常、ドメインエンティティです
- コントローラはドメイン変換、アプリケーション層です
- Viewはドライビングアダプターです
概念は細部では異なりますが、非常によく似ており、このアナロジーはドメインとアプリケーションのコードを定義するために使用することができます。
##詳細設計: ドメイン
必要なエンティティが決まったら、それらがどのように動作するかを定義していきましょう。
早速、プロジェクトのコード構造を見てみましょう。分かりやすくするために、コードをフォルダ-レイヤーに分けています。
src/
|_domain/
|_user.ts
|_product.ts
|_order.ts
|_cart.ts
|_application/
|_addToCart.ts
|_authenticate.ts
|_orderProducts.ts
|_ports.ts
|_services/
|_authAdapter.ts
|_notificationAdapter.ts
|_paymentAdapter.ts
|_storageAdapter.ts
|_api.ts
|_store.tsx
|_lib/
|_ui/
ドメインは domain/ ディレクトリに、アプリケーション層は application/ に、アダプタは services/ にあります。このコード構造に代わるものについては、最後に説明します。
###ドメインエンティティの作成
ドメインには4つのモジュールを用意します。
- product;
- user;
- order;
- shopping cart
主役はユーザーです。ユーザーに関するデータは、セッション中にストレージに保存されます。このデータを型付けしたいので、ドメインのユーザータイプを作成します。
ユーザータイプには、ID、名前、メール、好みやアレルギーのリストが含まれます。
export type UserName = string;
export type User = {
id: UniqueId;
name: UserName;
email: Email;
preferences: Ingredient[];
allergies: Ingredient[];
};
ユーザーはカートにクッキーを入れます。カートと商品の型を追加しましょう。商品には、ID、名前、1円単位の値段、原材料のリストが入ります。
export type ProductTitle = string;
export type Product = {
id: UniqueId;
title: ProductTitle;
price: PriceCents;
toppings: Ingredient[];
};
ショッピングカートでは、ユーザーが入れた商品のリストのみを保持します
import { Product } from "./product";
export type Cart = {
products: Product[];
};
支払いが完了すると、新しい注文が作成されます。注文のエンティティタイプを追加しましょう。
注文の型には、ユーザーID、注文商品のリスト、作成日時、ステータス、注文全体の合計金額が含まれます。
export type OrderStatus = "new" | "delivery" | "completed";
export type Order = {
user: UniqueId;
cart: Cart;
created: DateTimeString;
status: OrderStatus;
total: PriceCents;
};
###エンティティ間の関係の確認
このようにエンティティタイプを設計することの利点は、その関係図が現実と一致しているかどうかを既に確認できることです。
以下のことを確認することができます
- 主役が本当にユーザーであるかどうか。
- 注文に十分な情報があるかどうか。
- あるエンティティを拡張する必要があるかどうか。
- 将来的に拡張性に問題がないかどうか。
また、この段階ではすでに、エンティティ同士の互換性や、エンティティ間の信号の方向性に関するエラーを型によって強調することができます。
すべてが期待通りであれば、ドメイン変換の設計を始めることができます。
###データトランスフォームの作成
先ほど設計した型のデータには、さまざまなことが起こります。カートに商品を追加したり、カートを消去したり、商品やユーザー名を更新したりすることになります。これらの変換のために、それぞれ別の関数を作成します。
例えば、ある成分や好みに対してユーザーがアレルギーを持っているかどうかを調べるには、hasAllergy関数とhasPreference関数を書きます。
export function hasAllergy(user: User, ingredient: Ingredient): boolean {
return user.allergies.includes(ingredient);
}
export function hasPreference(user: User, ingredient: Ingredient): boolean {
return user.preferences.includes(ingredient);
}
関数 addProduct と contains は、アイテムをカートに追加したり、アイテムがカートに入っているかどうかを確認するために使用されます。
export function addProduct(cart: Cart, product: Product): Cart {
return { ...cart, products: [...cart.products, product] };
}
export function contains(cart: Cart, product: Product): boolean {
return cart.products.some(({ id }) => id === product.id);
}
また、商品リストの合計金額を計算する必要があります。これには関数 totalPrice を使用します。必要であれば、この関数に追加して、プロモーションコードや季節的な割引など、さまざまな条件を考慮することができます。
export function totalPrice(products: Product[]): PriceCents {
return products.reduce((total, { price }) => total + price, 0);
}
ユーザーが注文を作成できるようにするために、createOrderという関数を追加します。この関数は、指定されたユーザーとそのカートに関連付けられた新しい注文を返します。
export function createOrder(user: User, cart: Cart): Order {
return {
user: user.id,
cart,
created: new Date().toISOString(),
status: "new",
total: totalPrice(products),
};
}
どの関数でも、データを快適に変換できるようにAPIを構築していることに注意してください。私たちは引数を取り、思い通りの結果を返します。
設計段階では、まだ外部からの制約はありません。そのため、データ変換をできるだけ対象領域に近い形で反映させることができます。また、変換内容が現実に近ければ近いほど、動作確認も容易になります。
##詳細設計 共有カーネル
ドメインタイプを説明する際に使用したいくつかの型にお気づきでしょうか。例えば、Email、UniqueId、DateTimeStringなどです。これらはタイプエイリアスです。
type Email = string;
type UniqueId = string;
type DateTimeString = string;
type PriceCents = number;
プリミティブへの偏重をなくすために、通常はタイプエイリアスを使います。
単なるstringではなく、DateTimeStringを使うのは、どんな文字列が使われているかを明確にするためです。型が対象領域に近ければ近いほど、エラーが発生したときの対処が容易になります。
指定された型は、shared-kernel.d.tsというファイルにあります。共有カーネルとは、コードとデータのことで、これらに依存することでモジュール間の結合が強まることはありません。この概念については、「DDD、Hexagonal、Onion、Clean、CQRS、...How I put it all together」で詳しく説明しています。
実際には、共有カーネルはこのように説明できます。私たちはTypeScriptを使用し、その標準型ライブラリを使用していますが、それらを依存関係とは考えていません。なぜなら、それらを使用しているモジュールは、おそらくお互いのことを何も知らず、非結合のままであるからです。
すべてのコードが共有カーネルに分類できるわけではありません。主な、そして最も重要な制限は、そのようなコードはシステムのどの部分とも互換性がなければならないということです。アプリケーションの一部がTypeScriptで書かれていて、別の部分が別の言語で書かれている場合、共有カーネルには、両方の部分で使用できるコードのみを含めることができます。例えば、JSON形式のエンティティ仕様は問題ありませんが、TypeScriptヘルパーは問題です。
今回のケースでは、アプリケーション全体がTypeScriptで書かれているので、組み込み型に対するタイプエイリアスも共有カーネルに分類できます。このようなグローバルに利用可能な型は、モジュール間の結合を増やさず、アプリケーションのどの部分でも利用することができます。
##詳細設計 アプリケーション層
ドメインがわかったところで、次はアプリケーション層に移りましょう。この層にはユースケースが含まれます。
コードでは、シナリオの技術的な詳細を記述します。ユースケースとは、アイテムをカートに入れたり、チェックアウトに進んだりした後に、データに何が起こるべきかを説明するものです。
ユースケースには外部とのやりとりが含まれるため、外部サービスを利用することになります。外界とのインタラクションは副作用です。副作用のない機能やシステムの方が、作業やデバッグがしやすいことがわかっています。そして、私たちのドメイン関数のほとんどは、すでに純粋な関数として書かれています。
クリーンな変換と不純な世界との相互作用を組み合わせるために、アプリケーション層を不純なコンテキストとして使用することができます。
####純粋変換のための不純なコンテクスト
純粋変換のための不純なコンテキストとは、以下のようなコード構成のことです。
- 最初に副作用を実行して、あるデータを取得します。
- 次に、そのデータに対して純粋な変換を行います。
- そして、その結果を保存または渡すために、再び副作用を実行します。
「カートに商品を入れる」というユースケースでは、次のようになります。
- まず、ハンドラはストアからカートの状態を取得します。
- 次に、追加するアイテムを渡してカート更新関数を呼び出します。
- そして、更新されたカートをストレージに保存します。
全体のプロセスは、副作用、純粋な関数、副作用という「サンドイッチ」のようになっています。主なロジックはデータ変換に反映され、世界とのコミュニケーションはすべて命令型シェルに隔離されています。
不純なコンテクストは、不純なシェルの中の機能的なコアと呼ばれることもあります。Mark Seemann氏がブログでこのことを書いています。ユースケース関数を書くときには、このアプローチを使います。
##ユースケースの設計
ここでは、チェックアウトのユースケースを選択して設計します。非同期で多くのサードパーティのサービスと連携しているため、最も代表的なケースです。その他のシナリオやアプリケーション全体のコードはGitHubで見ることができます。
このユースケースで実現したいことを考えてみましょう。ユーザーのカートにはクッキーが入っており、ユーザーがチェックアウトボタンをクリックすると、カートの中にはクッキーが入っています。
- 新しい注文をします。
- サードパーティの決済システムで支払いを行います。
- 支払いに失敗した場合は、その旨をユーザーに通知します。
- 支払いに失敗した場合は、その旨をユーザーに通知し、支払いに成功した場合は、注文をサーバーに保存します。
- 注文をローカルデータストアに追加し、画面に表示する。
APIと関数のシグネチャに関しては、ユーザーとカートを引数として渡し、それ以外のことは関数が勝手にやってくれるようにしたいと考えています。
type OrderProducts = (user: User, cart: Cart) => Promise<void>;
もちろん理想的には、このユースケースは2つの別々の引数を取るのではなく、すべての入力データを自身の中にカプセル化するようなコマンドを取るべきです。しかし、コード量を肥大化させたくはないので、このままにしておきます。
###アプリケーション層のポートを書く
ユースケースの手順を詳しく見てみましょう。注文の作成自体はドメイン機能です。それ以外はすべて、私たちが利用したい外部サービスです。
ここで重要なのは、外部サービスは我々のニーズに合わせなければならず、そうでない場合は適応しないということです。そこで、アプリケーション層では、ユースケースそのものだけでなく、これらの外部サービスへのインターフェースであるポートを記述します。
ポートは、まず、アプリケーションにとって便利なものでなければなりません。もし、外部サービスのAPIが我々のニーズに合わない場合は、アダプタを作成します。
必要となるサービスを考えてみましょう。
- 決済システム
- イベントやエラーをユーザーに通知するサービス
- データをローカル・ストレージに保存するサービス
ここでは、サービスの実装ではなく、そのインターフェースについて説明しています。この段階では、要求される動作を記述することが重要です。なぜなら、この動作は、シナリオを記述する際にアプリケーション層で頼りにする動作だからです。
この動作を具体的にどのように実装するかは、まだ重要ではありません。これにより、どの外部サービスを使用するかの決定を最後の最後まで延期することができ、コードを最小結合にすることができます。実装については後ほど説明します。
また、インターフェースを機能別に分けていることにも注目してください。決済関連はすべて1つのモジュールに、ストレージ関連は別のモジュールにまとめています。このようにすることで、異なるサードパーティのサービスの機能が混ざらないようにすることが容易になります。
###決済システムのインターフェース
クッキーストアはサンプルアプリケーションなので、決済システムは非常にシンプルなものになります。tryPayメソッドがあり、支払いに必要な金額を受け取り、それに応じて問題がないかどうかの確認を送信します。
export interface PaymentService {
tryPay(amount: PriceCents): Promise<boolean>;
}
エラー処理については、別の大きな記事のトピックになりますので、ここでは扱いません😃。
そう、通常、決済はサーバー上で行われますが、これはサンプル例なので、すべてをクライアント上で行いましょう。決済システムと直接通信するのではなく、私たちのAPIと簡単に通信することができます。ちなみにこの変更は、このユースケースにのみ影響を与え、他のコードはそのまま残ります。
###ローカルストレージインターフェース
新しい注文をローカルリポジトリに保存します。
このストレージは、ReduxでもMobXでも何でも構いません。リポジトリは、異なるエンティティのためのマイクロストレージに分割することも、すべてのアプリケーションデータのための1つの大きなリポジトリにすることもできます。これらは実装上の詳細ですので、今は重要ではありません。
私は、ストレージのインターフェースを各エンティティごとに分けるのが好きです。ユーザーデータストアには別のインターフェイス、ショッピングカートには別のインターフェイス、オーダーストアには別のインターフェイスを用意します。
export interface OrdersStorageService {
orders: Order[];
updateOrders(orders: Order[]): void;
}
ここでの例では、オーダーストアのインターフェースのみを作成していますが、その他の部分はソースコードをご覧ください。
###ユースケース機能
作成したインターフェースと既存のドメイン機能を使って、ユースケースを構築できるかどうか見てみましょう。先ほど説明したように、スクリプトは以下のステップで構成されます。
- データを確認する
- 注文を作成する
- 注文の支払い
- 問題があれば通知する
- 結果の保存
まず、使用するサービスのスタブを宣言しましょう。インターフェイスを適切な変数に実装していないとTypeScriptに指摘されますが、今のところは問題ありません。
const payment: PaymentService = {};
const notifier: NotificationService = {};
const orderStorage: OrdersStorageService = {};
これらのスタブは、実際のサービスのように使用することができます。スタブのフィールドにアクセスしたり、メソッドを呼び出したりすることができます。これは、ユースケースをビジネス言語からソフトウェア言語に「翻訳」するときに便利です。
次に、orderProductsという関数を作成します。内部では、まず最初に新しい注文を作成します。
async function orderProducts(user: User, cart: Cart) {
const order = createOrder(user, cart);
}
ここでは、インターフェイスが動作の契約(contract for behavior)であるという事実を利用しています。つまり、将来的にはスタブが現在期待している動作を実際に行うことになります。
async function orderProducts(user: User, cart: Cart) {
const order = createOrder(user, cart);
// Try to pay for the order;
// Notify the user if something is wrong:
const paid = await payment.tryPay(order.total);
if (!paid) return notifier.notify("Oops! 🤷");
// Save the result and clear the cart:
const { orders } = orderStorage;
orderStorage.updateOrders([...orders, order]);
cartStorage.emptyCart();
}
このユースケースでは、サードパーティのサービスを直接呼び出さないことに注意してください。インターフェースに記述されている動作に依存しているため、インターフェースが同じであれば、どのモジュールがどのように実装しているかは気にしません。これにより、モジュールの交換が可能になります。
##詳細設計 アダプター層
ユースケースをTypeScriptに「翻訳」しました。あとは、現実がニーズにマッチしているかどうかをチェックしなければなりません。
通常はそうではありません。そこで、アダプタを使って外の世界を自分のニーズに合うように調整します。
###UIとユースケースの結合
1つ目のアダプタは、UIフレームワークです。ブラウザのネイティブAPIとアプリケーションを接続します。注文の作成の場合は、"チェックアウト "ボタンとそのクリックハンドラで、ユースケースの関数を起動します。
export function Buy() {
// Get access to the use case in the component:
const { orderProducts } = useOrderProducts();
async function handleSubmit(e: React.FormEvent) {
setLoading(true);
e.preventDefault();
// Call the use case function:
await orderProducts(user!, cart);
setLoading(false);
}
return (
<section>
<h2>Checkout</h2>
<form onSubmit={handleSubmit}>{/* ... */}</form>
</section>
);
}
ユースケースをフックで提供してみましょう。中にあるすべてのサービスを取得し、その結果、フックからユースケースの関数そのものを返すことになります。
export function useOrderProducts() {
const notifier = useNotifier();
const payment = usePayment();
const orderStorage = useOrdersStorage();
async function orderProducts(user: User, cookies: Cookie[]) {
// …
}
return { orderProducts };
}
フックを "crooked dependency injection(屈折した依存性注入) "として使用しています。まずフックの useNotifier, usePayment, useOrdersStorage を使ってサービスインスタンスを取得し、次に useOrderProducts 関数のクロージャを使って orderProducts 関数の中でそれらを利用できるようにします。
ここで重要なのは、ユースケース関数がまだ他のコードから分離されていることで、これはテストのために重要です。記事の最後でレビューとリファクタリングを行う際に、完全に引き出してさらにテストしやすい状態にします。
###決済サービスの実装
このユースケースでは、PaymentServiceインターフェースを使用しています。これを実装してみましょう。
支払いには、偽のAPIスタブを使用します。繰り返しになりますが、今すぐサービス全体を書かなければならないわけではありません。
export function fakeApi<TResponse>(response: TResponse): Promise<TResponse> {
return new Promise((res) => setTimeout(() => res(response), 450));
}
fakeApi関数は、450ms後に起動されるタイムアウトで、サーバーからの応答の遅れをシミュレートしています。この関数は、引数として渡されたものを返します。
import { fakeApi } from "./api";
import { PaymentService } from "../application/ports";
export function usePayment(): PaymentService {
return {
tryPay(amount: PriceCents) {
return fakeApi(true);
},
};
}
usePaymentの戻り値を明示的にタイプしています。こうすることで、TypeScriptはこの関数が実際にインターフェイスで宣言されたすべてのメソッドを含むオブジェクトを返すかどうかをチェックします。
###通知サービスの実装
通知はシンプルなアラートとします。このコードはデカップリングされているので、後でこのサービスを書き直すことは問題ありません。
import { NotificationService } from "../application/ports";
export function useNotifier(): NotificationService {
return {
notify: (message: string) => window.alert(message),
};
}
###ローカルストレージの実装
ローカルストレージをReact.Contextとhooksとします。新しいコンテキストを作成し、その値をプロバイダに渡し、プロバイダをエクスポートし、フックを介してストアにアクセスします。
const StoreContext = React.createContext<any>({});
export const useStore = () => useContext(StoreContext);
export const Provider: React.FC = ({ children }) => {
// ...Other entities...
const [orders, setOrders] = useState([]);
const value = {
// ...
orders,
updateOrders: setOrders,
};
return (
<StoreContext.Provider value={value}>{children}</StoreContext.Provider>
);
};
それぞれの機能に対応したフックを書く予定です。こうすることで、ISPを壊すことはありませんし、ストアも、少なくともインターフェイスに関しては、アトミックになります。
export function useOrdersStorage(): OrdersStorageService {
return useStore();
}
また、このアプローチでは、ストアごとに追加の最適化をカスタマイズすることができます。セレクタの作成やメモの作成などが可能です。
###データフロー図の検証
作成したユースケースの中で、ユーザーがどのようにアプリケーションと通信するかを検証してみましょう。
ユーザーはUI層と対話しますが、UI層はポートを通じてのみアプリケーションにアクセスできます。つまり、必要に応じてUIを変更することができるのです。
ユースケースはアプリケーション層で処理され、どのような外部サービスが必要かを正確に教えてくれます。メインロジックとデータはすべてドメイン内にあります。
外部サービスはすべてインフラの中に隠れており、我々の仕様に従うことになります。メッセージを送信するサービスを変更する必要がある場合、コードの中で修正しなければならないのは、新しいサービスのためのアダプターだけです。
この方式により、コードは交換可能で、テスト可能で、変化する要件に合わせて拡張可能になります。
##改善すべき点
全体としては、これで十分にスタートでき、クリーンアーキテクチャについてのザッとした理解を得られたといえるでしょう。しかし、この例をよりシンプルにするために簡略化した点を説明したいと思います。
このセクションは任意ですが、「手抜きのない」クリーンなアーキテクチャがどのようなものか、理解を深めることができるでしょう。
できることをいくつか紹介します。
###価格には数字ではなくオブジェクトを使う
私が価格を数字で表現していることにお気づきでしょうか。これはあまり良いことではありません。
type PriceCents = number;
数字は数量を示すだけで、通貨を示すものではありませんし、通貨のない価格は意味がありません。理想的には、priceはvalueとcurrencyの2つのフィールドを持つオブジェクトとして作られるべきです。
type Currency = "RUB" | "USD" | "EUR" | "SEK";
type AmountCents = number;
type Price = {
value: AmountCents;
currency: Currency;
};
これにより、通貨を保存する際の問題が解決され、通貨の変更や追加の際の手間や神経を大幅に省くことができます。複雑にならないように、例ではこの型を使っていません。しかし、実際のコードでは、この型に近いものになるでしょう。
それとは別に、価格の値についても触れておきましょう。私は常に、流通している通貨の中で最も小さい端数の金額を設定しています。例えば、1ドルの場合はセントです。
このように価格を表示することで、割り算や分数を意識せずに済むのです。お金の場合、浮動小数点演算の問題を避けたい場合には、特に重要です。
###レイヤーではなく機能でコードを分割
コードは「レイヤーごと」ではなく「フィーチャーごと」にフォルダ分けすることができます。1つの機能とは、下の模式図のパイの一部のことです。
この構造はさらに好ましいもので、特定の機能を別々にデプロイすることができ、これはしばしば便利なことです。
「DDD, Hexagonal, Onion, Clean, CQRS, ...How I put it all together」で読むことをお勧めしました。
また、概念的にはコンポーネントコード分割と非常に似ていますが、より理解しやすい「Feature Sliced」も見てみることをお勧めします。
###コンポーネントの使い分けに注意
システムをコンポーネントに分けて考えるのであれば、コンポーネント間でのコードの使用についても言及する価値があります。オーダー作成機能を思い出してみましょう。
import { Product, totalPrice } from "./product";
export function createOrder(user: User, cart: Cart): Order {
return {
user: user.id,
cart,
created: new Date().toISOString(),
status: "new",
total: totalPrice(products),
};
}
この関数は、別のコンポーネントである製品のtotalPriceを使用しています。このような使い方をすること自体は問題ないのですが、コードを独立した機能に分割したい場合、他の機能の機能に直接アクセスすることはできません。
この制約を回避する方法は、「DDD、Hexagonal、Onion、Clean、CQRS、...How I put it all together」や「Feature Sliced」でも読むことができます。
###エイリアスではなく、ブランド化された型を使う
共有カーネルでは、タイプエイリアスを使用しました。型エイリアスは操作が簡単で、新しい型を作成して、例えば文字列を参照するだけです。しかし、その欠点は、TypeScriptにはエイリアスの使用を監視し、それを強制するメカニズムがないことです。
誰かがDateTimeStringの代わりにstringを使ったとしても、コードは問題なくコンパイルされます。
問題はまさに、より広い型が使われているにもかかわらず、コードがコンパイルされてしまうことです(賢い言い方をすれば、前提条件が弱められている)。なぜなら、特別な品質の文字列だけでなく、どんな文字列でも使用できるため、エラーが発生する可能性があるからです。
第二に、真実の情報源が2つになるので、読むのに混乱します。本当に日付だけを使う必要があるのか、それとも基本的にどんな文字列でも良いのかが不明瞭です。
TypeScriptに特定の型が必要であることを理解させるには、ブランディング、つまりブランド化された型を使う方法があります。ブランディングを使えば、型がどのように使われているかを正確に把握することができますが、コードは少し複雑になります。
###ドメインに依存する可能性に注意
次に気になるのは、createOrder関数でドメイン内に日付が作られていることです。
import { Product, totalPrice } from "./product";
export function createOrder(user: User, cart: Cart): Order {
return {
user: user.id,
cart,
// Вот эта строка:
created: new Date().toISOString(),
status: "new",
total: totalPrice(products),
};
}
new Date().toISOString()はプロジェクト内で頻繁に繰り返されると思われるので、何らかのヘルパーに入れておきたいと思います。
export function currentDatetime(): DateTimeString {
return new Date().toISOString();
}
そしてそれをドメインで使います
import { currentDatetime } from "../lib/datetime";
import { Product, totalPrice } from "./product";
export function createOrder(user: User, cart: Cart): Order {
return {
user: user.id,
cart,
created: currentDatetime(),
status: "new",
total: totalPrice(products),
};
}
しかし、ドメイン内の何かに依存することはできないことをすぐに思い出します。つまり、どうすればいいのでしょうか?createOrderは、注文のためのすべてのデータを完全な形で受け取るのが良いでしょう。日付は最後の引数として渡すことができます。
function someUserCase() {
// Use the `dateTimeSource` adapter,
// to get the current date in the desired format:
const createdOn = dateTimeSource.currentDatetime();
// Pass already created date to the domain function:
createOrder(user, cart, createdOn);
}
これにより、ドメインの独立性が保たれ、テストもしやすくなります。
例題では、2つの理由からこの点に焦点を当てないことにしました。主旨から外れてしまうことと、言語機能のみを使用する場合には、独自のヘルパーに依存することに問題はないと考えているからです。このようなヘルパーは、コードの重複を減らすだけなので、共有カーネルと考えることもできます。
###カートとオーダーの関係に注目
この例では、カートは商品のリストを表しているだけなので、OrderはCartを含んでいます。
export type Cart = {
products: Product[];
};
export type Order = {
user: UniqueId;
cart: Cart;
created: DateTimeString;
status: OrderStatus;
total: PriceCents;
};
注文とは関係のない追加のプロパティが Cart にある場合、この方法ではうまくいかないことがあります。このような場合は、データ・プロジェクションや中間DTOを使用するのが良いでしょう。
オプションとして、「商品リスト」エンティティを使用することもできます。
type ProductList = Product[];
type Cart = {
products: ProductList;
};
type Order = {
user: UniqueId;
products: ProductList;
created: DateTimeString;
status: OrderStatus;
total: PriceCents;
};
###ユーザーケースをよりテスト可能なものにする
ユースケースにも議論すべき点がたくさんあります。今のところ、orderProducts関数はReactから切り離してテストするのは難しく、これは悪いことです。理想的には、最小限の労力でテストできるようにすべきです。
現在の実装の問題点は、ユースケースからUIへのアクセスを提供するフックにあります
export function useOrderProducts() {
const notifier = useNotifier();
const payment = usePayment();
const orderStorage = useOrdersStorage();
const cartStorage = useCartStorage();
async function orderProducts(user: User, cart: Cart) {
const order = createOrder(user, cart);
const paid = await payment.tryPay(order.total);
if (!paid) return notifier.notify("Oops! 🤷");
const { orders } = orderStorage;
orderStorage.updateOrders([...orders, order]);
cartStorage.emptyCart();
}
return { orderProducts };
}
規範的な実装では、ユースケース関数はフックの外側に配置され、サービスは最後の引数またはDIを介してユースケースに渡されます。
type Dependencies = {
notifier?: NotificationService;
payment?: PaymentService;
orderStorage?: OrderStorageService;
};
async function orderProducts(
user: User,
cart: Cart,
dependencies: Dependencies = defaultDependencies
) {
const { notifier, payment, orderStorage } = dependencies;
// ...
}
そして、そのフックがアダプターになります
function useOrderProducts() {
const notifier = useNotifier();
const payment = usePayment();
const orderStorage = useOrdersStorage();
return (user: User, cart: Cart) =>
orderProducts(user, cart, {
notifier,
payment,
orderStorage,
});
}
そうすると、フックコードはアダプタとみなされ、ユースケースだけがアプリケーション層に残ることになります。orderProducts関数は、必要なサービスのモカを依存関係として渡すことでテストすることができます。
###自動依存性注入の設定
そこで、アプリケーション層では、サービスを手動で注入するようになりました。
export function useOrderProducts() {
// ここではフックを使って各サービスのインスタンスを取得しています。
// orderProductsのユースケースの中で使用されます。
const notifier = useNotifier();
const payment = usePayment();
const orderStorage = useOrdersStorage();
const cartStorage = useCartStorage();
async function orderProducts(user: User, cart: Cart) {
// ...ユースケースの中でこれらのサービスを使用します
}
return { orderProducts };
}
しかし、一般的には、これを自動化し、依存性注入で行うことができます。すでに最後の引数を通じて最もシンプルなバージョンのインジェクションを見ましたが、さらに進んで自動注入を設定することもできます。
この特定のアプリケーションでは、DIを設定することにあまり意味がないと思いました。要点がずれてしまうし、コードが複雑になりすぎてしまうからです。また、Reactやフックの場合は、指定したインターフェイスの実装を返す「コンテナ」として使うことができます。たしかに手作業ではありますが、エントリーの敷居が高くなることはなく、新人開発者にとっては読みやすいものになっています。
##実際のプロジェクトではもっと複雑になることも
投稿の例は洗練されていて、意図的にシンプルにしてあります。人生は、この例よりもはるかに驚きと複雑さに満ちていることは明らかです。そこで、クリーンアーキテクチャで作業する際に起こりうる一般的な問題についてもお話したいと思います。
####ビジネスロジックの分岐
最も重要な問題は、知識が不足している対象分野です。あるお店に、商品、値引き商品、評価損商品があるとします。これらのエンティティをどのように適切に記述すればよいのでしょうか?
拡張される「ベース」となるエンティティがあるべきでしょうか?このエンティティは具体的にどのように拡張されるべきでしょうか?追加のフィールドが必要ですか?これらのエンティティは相互に排他的であるべきですか?単純なエンティティではなく、別のエンティティがある場合、ユーザーケースはどのように振る舞うべきですか?重複をすぐに減らすべきでしょうか?
チームもステークホルダーも、システムが実際にどのように動作すべきかをまだ知らないため、質問が多すぎて答えが出ないことがあります。仮定しかない場合は、分析麻痺に陥る可能性があります。
具体的な解決策は特定の状況に依存しますが、私がお勧めできるのは一般的なものをいくつか挙げることだけです。
たとえそれが「拡張」と呼ばれるものであっても、継承は使用しないでください。たとえ、インターフェースが本当に継承されているように見えたとしても。明らかに階層化されているように見えたとしてもです。ちょっと待ってください。
コードのコピーペーストは必ずしも悪ではなく、ツールなのです。ほとんど同じものを2つ作り、現実にどのように振る舞うか、観察してみましょう。ある時点で、両者が大きく異なったものになっているか、あるいは本当に1つの分野でしか異なっていないことに気づくでしょう。ありとあらゆる条件やバリエーションに対するチェックを作るよりも、似たような2つのエンティティを1つに統合する方が簡単です。
それでも何かを拡張しなければならない場合は...。
共分散、反比例、不変性に留意し、誤って必要以上の作業をしてしまわないようにしましょう。
異なるエンティティや拡張機能を選択する際には、BEMのブロックやモディファイアとの類似性を利用してください。BEMの文脈で考えれば、コードを独立したエンティティか「モディファイア-エクステンション」かを判断するのにとても役立ちます。
####相互依存のユースケース
2つ目の大きな問題は、関連するユースケースで、あるユースケースのイベントが別のユースケースを誘発することです。
この問題を解決する唯一の方法は、ユースケースをより小さく、アトミックなユースケースに分割することです。そうすれば、簡単にまとめることができます。
一般的に、このようなスクリプトの問題は、プログラミングにおけるもう一つの大きな問題であるエンティティの構成に起因しています。
エンティティを効率的に構成する方法については、すでに多くの文献があり、数学のセクションもあります。それについては別の記事を書くことにします。
##結論
この記事では、フロントエンドのクリーンなアーキテクチャに関する私の講演の概要を説明し、少し展開してみました。
これはゴールドスタンダードではなく、異なるプロジェクト、パラダイム、言語での経験をまとめたものです。コードを切り離して、独立したレイヤー、モジュール、サービスを作ることができる便利なスキームだと思っています。これらは別々にデプロイして公開できるだけでなく、必要に応じてプロジェクトからプロジェクトへ移行することもできます。
OOPに触れていないのは、アーキテクチャとOOPが直交しているからです。確かに、アーキテクチャはエンティティの構成について述べていますが、構成の単位を何にするか、つまりオブジェクトにするか、関数にするかについては指示していません。例で見てきたように、異なるパラダイムで作業することができます。
OOPについては、先日、クリーンアーキテクチャをOOPで使う方法についての記事を書きました。この投稿では、canvas上で木の絵を生成するものを書いています。
このアプローチをチップスライシング、ヘキサゴナル・アーキテクチャ、CQSなどの他のものと具体的にどのように組み合わせることができるかについては、「DDD、Hexagonal、Onion、Clean、CQRS、...How I put it all together」やこのブログの一覧のシリーズを読むことをお勧めします。非常に洞察力に富み、簡潔で的を得ています。
##文献
この記事とソースコード:
実践的な設計:
- The Clean Architecture
- Model-View-Controller
- DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together
- Ports & Adapters Architecture
- More than Concentric Layers
- Generating Trees Using L-Systems, TypeScript, and OOP Series' Articles
システム設計:
設計とコーディングについての本
TypeScript, C# や他の言語の概念:
- Interface
- Closure
- Set Theory
- Type Aliases
- Primitive Obsession
- Floating Point Math
- Branded Types и How to Use It
パターン、メソドロジー: