ReactJsとVueJsを使わずにクリーンアーキテクチャをフロントエンドに適用する
この記事は私のブログでのオリジナル記事の英語翻訳です:Alejándonos de ReactJs y VueJs en el front end usando Clean Architecture。
クリーンアーキテクチャを使用する利点の一つは、UIフレームワークやライブラリから、つまりユーザーに対する提供メカニズムから、アプリケーションを切り離す能力です。
この利点は長期的なアプリケーションで将来にわたり、ライブラリやフレームワークの変更に適応することを可能にします。
この記事では、ReactJSとVueJsという2つの提供メカニズムを適用することで、フロントエンドのクリーンアーキテクチャを極限まで活用します。
2つの実装間でできる限り多くのコードを再利用します。
これは、ReactJsとVueJsのドメイン、データ、リモート表示のロジックを作成することで可能になります。
なぜフレームワークから離れるのか?
私は.Net、Android、iOS、Flutterを使ったさまざまな技術でクリーンアーキテクチャを適用してきました。長い間、フロントエンドでもプログラミングしており、それについて書いています。
アプリケーションを進化させる際の最大の問題の一つは、UIフレームワークへの結合です。
フロントエンドは徐々に、このタイプのアプリケーションが時間とともに得た責任のために、より構造化された方法で開発し、問題を解決することはバックエンドやモバイル開発など他のフロントで存在する問題と非常に似ています。
ReactJsやVueJsのようなフレームワークは、フロントエンドでこれらの課題に取り組むための生活を容易にします。
今日のフロントエンドアプリケーションは多くの場合バックエンドとは独立したアプリケーションであり、そのため独自のアーキテクチャを持つ必要があります。
加えて、このアーキテクチャは次の点で私たちを助ける必要があります:
- UI、フレームワーク、APIレスト、永続化、データベース、第三者のサービスから独立している。
- スケーラビリティ。
- テスト可能性。
つまり、ReactJsまたはVueJsアプリケーションを持つという視点を変えて、ReactJsまたはVueJsを使用してレンダリングするフロントエンドアプリケーションを持つことにすれば、将来はるかに簡単になります。
したがって、たとえば、以前のようにクラスを使用していたReactJSアプリケーションを、今行われているように関数とフックを使用するように進化させることは、はるかに些細なことです。VueJSでオプションAPIからコンポジションAPIに切り替える場合も同様です。
それはより些細なことです、なぜならフレームワークは厳密に必要なこと、例えばレンダリングのためだけに使用され、過度に使用されることなく、どんなタイプのロジックも、それがドメイン、データ、またはプレゼンテーションロジックであれ、離れて保持されるからです。
フレームワークは進化し、それを制御することはできませんが、どのようにしてそれらとの結合を制御し、その変更があなたにどのように影響するかを制御することができます。
しかし、この場合、フレームワークで起こる可能性のある変更にどのように適応するかについてはさておき、クリーンアーキテクチャを使用し、責任を分離するときに、コードのどれだけが変更されないかを見ていきます。
シナリオ
これは、実際の例のように見えるほど十分な機能を備えたショッピングカートです。グローバルステート、非グローバルステートがあり、リモートサービスへの呼び出しを模倣します。
アーキテクチャ
プロジェクト構造レベルでは、yarn workspacesを使用するモノレポであり、この方法でプロジェクトをモジュールまたはパッケージに分割して、コードを共有します。
いくつかのパッケージがあります:
- コア:このパッケージには、ReactJSによってレンダリングされたアプリとVueJsによってレンダリングされたアプリの間で共有されるすべてのコードが含まれます。
- React:このパッケージにはReactアプリのバージョンが見つかります。
- Vue:このパッケージにはVueアプリのバージョンが見つかります。
再利用されるコードは?
UIフレームワークから切り離す必要があるすべてのコードを再利用するつもりです。異なるバージョンの同じアプリであるため、このコードは共有され、2回書かれるべきではありません。
これはクリーンアーキテクチャの潜在力を実証する練習ですが、実際のアプリを開発するときでも、UIフレームワークの切り離しは必要です。
UIフレームワークを厳密に必要な部分にのみ使用することで、将来のフレームワークのバージョンの変更により適応しやすくなります。
これは、アプリケーションロジックを含むコードが時間とともに変わることが少なく、UIフレームワークに依存せずに切り離される可能性があるコードであるためです。
クリーンアーキテクチャのドメイン層は企業とアプリケーションのビジネスロジックが位置する場所です。
データ層は永続化と通信する場所です。
プレゼンテーションロジックは表示されるデータ、何かが見えるべきかどうか、データの読み込み中であることをユーザーに表示するべきか、エラーを表示するべきかを決定します。コンポーネントの状態が管理される場所です。
これらの3つの部分の各々には、切り離す必要があるロジックが含まれており、コアパッケージに存在します。
ドメイン層
ドメイン層は、企業とアプリケーションのビジネスロジックが位置する場所です。
ユースケース
ユースケースは意図であり、アプリケーションのビジネスロジックを含み、この例では次のものがあります:
- GetProductsUseCase
- GetCartUseCase
- AddProductToCartUseCase
- EditQuantityOfCartItemUseCase
- RemoveItemFromCartUseCase
GetProductsUseCaseの例を見てみましょう:
export class GetProductsUseCase {
private productRepository: ProductRepository;
constructor(productRepository: ProductRepository) {
this.productRepository = productRepository;
}
execute(filter: string): Promise<Either<DataError, Product[]>> {
return this.productRepository.get(filter);
}
}
このユースケースはシンプルで、データ層へのシンプルな呼び出しで構成されています。他の文脈では、例えば、製品を作成する際に同じSKUで既に存在しないことを検証する必要がある場合、より多くのロジックがあります。
ユースケースはEither型を返します。それが何か分からない場合は、これを読むことをお勧めします。
そのため、エラー処理はプロミスのキャッチを使用するのではなく、その自体プロミスの結果オブジェクトが結果が成功か否かを教えてくれます。
Eitherの使用対してクラシックなtry-catchにはいくつかの利点があります:
- エラーが発生したときに呼び出し元間でジャンプせずに、実行のフローが追いやすい。
- 何かが間違っていく可能性があることは明確に指示されている。起こりうるエラーが明示的に指示されている。
- 洗練されたスイッチを使用することで、将来さらに多くのエラーを追加する場合、TypeScriptがこの新しいエラーを考慮していない場所を警告する。
エラーの型は次のようになります:
export interface UnexpectedError {
kind: "UnexpectedError";
message: Error;
}
export type DataError = UnexpectedError;
将来的には、次のように進化する可能性があります:
export interface ApiError {
kind: "ApiError";
error: string;
statusCode: number;
message: string;
}
export interface UnexpectedError {
kind: "UnexpectedError";
message: Error;
}
export interface Unauthorized {
kind: "Unauthorized";
}
export interface NotFound {
kind: "NotFound";
}
export type DataError = ApiError | UnexpectedError | Unauthorized;
プレゼンテーション層では、洗練されたスイッチを使用している場合、TypeScriptは新しいエラーのためにさらに多くの症例を追加する必要があることを警告します。
エンティティ
エンティティには、企業ビジネスロジックが含まれています。
Cartの例を見てみましょう:
type TotalPrice = number;
type TotalItems = number;
export class Cart {
items: readonly CartItem[];
readonly totalPrice: TotalPrice;
readonly totalItems: TotalItems;
constructor(items: CartItem[]) {
this.items = items;
this.totalPrice = this.calculateTotalPrice(items);
this.totalItems = this.calculateTotalItems(items);
}
static createEmpty(): Cart {
return new Cart([]);
}
addItem(item: CartItem): Cart {
const existedItem = this.items.find(i => i.id === item.id);
if (existedItem) {
const newItems = this.items.map(oldItem => {
if (oldItem.id === item.id) {
return { ...oldItem, quantity: oldItem.quantity + item.quantity };
} else {
return oldItem;
}
});
return new Cart(newItems);
} else {
const newItems = [...this.items, item];
return new Cart(newItems);
}
}
removeItem(itemId: string): Cart {
const newItems = this.items.filter(i => i.id !== itemId);
return new Cart(newItems);
}
editItem(itemId: string, quantity: number): Cart {
const newItems = this.items.map(oldItem => {
if (oldItem.id === itemId) {
return { ...oldItem, quantity: quantity };
} else {
return oldItem;
}
});
return new Cart(newItems);
}
private calculateTotalPrice(items: CartItem[]): TotalPrice {
return +items
.reduce((accumulator, item) => accumulator + item.quantity * item.price, 0)
.toFixed(2);
}
private calculateTotalItems(items: CartItem[]): TotalItems {
return +items.reduce((accumulator, item) => accumulator + item.quantity, 0);
}
}
この例では、エンティティは基本型のプロパティを持つシンプルなものですが、実際の例では検証がある場合、エンティティと値オブジェクトをクラスで定義し、検証が行われるファクトリーメソッドで使用することができます。エラーまたは結果を返すためにEitherを使用します。
境界
境界はアダプターの抽象であり、例えば六角形アーキテクチャではポートと呼ばれています。それらはドメインのユースケース層で定義され、アダプターとの通信方法を指示します。
たとえば、データ層と通信するために、リポジトリパターンを使用します。
export interface ProductRepository {
get(filter: string): Promise<Either<DataError, Product[]>>;
}
データ層
データ層はアダプターが見つかる場所であり、アダプターはドメインと外部システム間の情報を変換する責任があります。
外部システムはウェブサービス、データベースなどです。
この簡単な例では、製品、ショッピングカート、カートアイテムを代表する同じエンティティをプレゼンテーション、ドメイン、データ層間で使用しています。
実際のアプリケーションでは、各層に異なるデータ構造があることがよくあり、層間でデータを渡すためにデータ転送オブジェクト(DTO)があることもあります。
この例では、メモリに保存されているデータを返すリポジトリがあります。
const products = [
...
];
export class ProductInMemoryRepository implements ProductRepository {
get(filter: string): Promise<Either<DataError, Product[]>> {
return new Promise((resolve, _reject) => {
setTimeout(() => {
try {
if (filter) {
const filteredProducts = products.filter((p: Product) => {
return p.title.toLowerCase().includes(filter.toLowerCase());
<br><br>こちらの記事はdev.toの良い記事を日本人向けに翻訳しています。<br>[https://dev.to/xurxodev/moving-away-from-reactjs-and-vuejs-on-front-end-using-clean-architecture-3olk](https://dev.to/xurxodev/moving-away-from-reactjs-and-vuejs-on-front-end-using-clean-architecture-3olk)