エンタープライズアプリケーションの開発におけるコーディングの考察と実装例

公開:2021/01/12
更新:2021/01/12
26 min読了の目安(約23800字TECH技術記事

「エンタープライズアプリケーションの開発におけるコーディングの考察と実装例」というタイトルとあるように、私自身がどのようにパッケージとコードを分けるかを検討した際の、考察結果と実装例のまとめです。

論点としては、ソフトウェアが持つ問題解決領域(ビジネスロジック)とアプリケーションロジックを切り分け、その間をどのように橋渡しするかについてです。あわせて、入出力の処理の流れを再利用可能な形とすることで、入力値の作成や UI の実装に集中する箇所がビジネスロジックに関する処理を意識しない作りとすることです。

(問題解決領域をいかに実装するかに至るモデリングが最も重要だと思いつつも、では上記の橋渡しはどうするのかが疑問に湧いたため、文書にして自らの考えを整理しようという、むしろポエムに近いです)

はじめに

エンタープライズアプリケーションの開発では、ビジネスに関する問題解決を担う箇所から、それをソフトウェアとして動作させるための周辺技術に関する箇所まで、広い範囲の実装が必要になります。今回では下記に重点を置きつつ、コーディングの観点から考察を書きました。

  • ソフトウェアが属人化せずに、理解可能な状態を保つこと
  • ビジネスとテクノロジーの変化に対して、コードに対する変更箇所が限定的であること

具体的な内容は下記であり、問題解決領域とアプリケーション領域と、それらを接続するための領域について簡単な実装例が書かれています。

  • パッケージの分け方
  • パッケージに実装するコードの責務の分け方
  • サンプルコード

サンプルコードは TypeScript です。サンプルコードの内容として下記が含まれます。内容はシンプルな実装例であり骨組みとなるレベルのものまでです。その他のプログラミング言語や利用するデータストアなどのシステム設計により、更に効果的な実装方法があれば、コードは大きく変わってくるものとします。

  • ビジネスロジックを実行するためのデータやクラス、及び関数
  • ビジネスロジックの結果を保存するためのインターフェース定義
  • ソフトウェアに対する入出力とビジネスロジックの実行を結びつけるための実装
  • CLI コマンドからの呼び出し
  • React コンポーネントからの呼び出し

参考書籍

いくつかの書籍に登場する用語や概念を引用するものの、書籍自体の内容については直接ではなく、概念レベルで触れていきます。

  • アナリシスパターン (著者 マーチン・ファウラー)
  • Clean Code (著者 ロバート・C・マーチン)
  • Clean Architecture (著者 ロバート・C・マーチン)
  • エリック・エヴァンスのドメイン駆動設計 (著者 エリック・エヴァンス)
  • 実践ドメイン駆動設計 (著者 ヴォーン・ヴァーノン)

概要

エンタープライズアプリケーションの開発において課題となる点として下記が挙げられます。

  • 複雑なビジネス要件
  • データの効率的な取り出し

どこかから入力が訪れ、それが複雑なビジネス要件を通過することで、データとして保存されます。保存されたデータは要求に基づいてどこかへ届けられます。ここでのデータとは、利用者にとって価値を提供する概念的なものでり、コンテンツとも言いかえられるものです。

書籍 アナリシスパターンでは、それを達成する上でソフトウェアを下記の階層に分割できると書かれています (3 層アーキテクチャ)。

  • 概念スキーマ: 問題解決領域
  • 外部スキーマ: アプリケーション
  • 内部(記憶)スキーマ: データ源

上記の分割はシステム全体の設計においては、クライアント/サーバで分けられたり、ライブラリの提供と利用に分けられたり、マイクロサービスで責務を分けられたりすることで実現できます。問題解決の方法は共通利用可能な知識として提供され、それによりアプリケーションは様々な形式を取ることができます。今回は、ビジネスの変化に常に追従するのは日々変更されるコードだと考え、この概念をコーディングレベルで考察します。

いずれのソフトウェアも一つのコードから始まり、そこから拡張されていきます。ソフトウェアが巨大になれば、サービスレベルやモジュールレベルで機能を分解することで、開発プロジェクトにおけるアジリティを維持することができますが、その際にコードが複雑であるほど分解に対するリスクとコストが上がります。そのため、分解される前のコードの品質は重要です。コードを整理した状態に保つには、継続的なテストとリファクタリングが効果的であり、ここではその際のレイヤーと責務と、コードに触れていきます。

レイヤー

レイヤーを下記のように分離し、ソフトウェアが持つ本質的な部分と、アプリケーションの動作において必要な部分を分けて実装します。今回においては、パッケージ名はそのままレイヤーを使っており、下記は概念的に上位のものから並べたものです。命名や並びは一例であり、最重要ロジックを他の処理や環境へ依存させないための構成です。ソフトウェアの特性により更に詳細に分けたほうが良ければレイヤーを増やすことを検討でき、サブパッケージで内部処理を更に細分化することもします。

  • entities (もしくは domain など)
  • services (もしくは usecases など)
  • app
  • repositories
  • main

本質としては、再利用可能な知識であるデータや処理を Entity としてまとめ、いずれの層からも利用できる状態を保つことで、ソフトウェアの目的達成に向けて相違が発生しないことを重要視しています。アプリケーションの入出力においては、流れに関する実装を切り離すことで、CLI や Web アプリケーションなどの UI 領域の実装に対して、その詳細のみを責務とさせます。

レイヤーの概念は Clean Architecture と似ていますが、書籍 Clean Architecture の中で書かれている構成例などとは詳細が異なります。また、用語については「永続化における実装をリポジトリとする」、「永続化対象の Entity を集約とする」、「集約が利用する値を値オブジェクトとする」など、DDD(ドメイン駆動設計)に関するものを引用しています。

なお、実装順序については上記の並びのとおりではなく、リファクタリングを重ねて最終的に Entity としてデータや処理が昇華されていくと、漸進的に開発を進めていくことができます。

外部への依存について

entities と services は、ミドルウェアやデータベース、通信に関わる具体的な処理(SQL や HTTP など)や、それに関連するライブラリやフレームワークに依存しません。ビジネスロジックの達成のために活用できるライブラリなどは、取り入れることを検討します。

app では、入出力の流れに関する処理と、CLI や GUI などの実際の入出力処理に分かれますが、前者も entities や services と同様に外部への依存をしません。後者は通信やレンダリングの仕様など、入出力に関する必要な処理や効率的な実装方法があれば必要に応じて外部へ依存しますが、実行環境などアプリケーションが動作する設定や条件については扱いません。

repositories は entities で定義されるリポジトリインターフェースの実装を持つ箇所であるため、それに関する仕様やライブラリに依存します。リポジトリ以外にインターフェースを entites が持つ場合においても同様です。

main はアプリケーションの実行環境や実行方法に関する実装を持ちます。

各レイヤー詳細

データと処理の再利用性を高めるため、下記のようにレイヤーを分け、下位レイヤーは上位レイヤーのみに依存するようにします。

パッケージ entities

ビジネスをモデルにしたものが実装されます。ソフトウェアの本質となるデータや処理をいずれのレイヤーからも呼び出し可能である状態を保ちます。それらはデータや関数など、形式は選びません。

データを扱う場合は、それらを永続化するためのインターフェースを含む可能性があり、これをリポジトリと呼びます。リポジトリは集約に対して、データをコレクション型のように扱うことのみを責務とします。リポジトリは、対象のデータを永続化するためのルールが本質的に重要である場合に定義されます。

実装例 データ/クラス

例のために架空で作り上げたユーザ概念を作りました。User クラスは、ユーザのデータ本体であり、かつ振る舞いを提供する集約です。データを一意に特定するための ID は変更不可能であることが望ましく、クラスに対するフィールドも不変となるように実装します。

Age クラス は年齢を扱うための値オブジェクトです。ここでは 10 歳未満は利用不可能な固有のルールであると仮定した実装となっています。値オブジェクトを集約とは別の箇所に実装するかどうかは、その値がいずれの集約からも再利用可能であるかどうかで切り分けることができます。例えば、マイナスの数値でないことをバリデーションするだけであれば、Age クラスは汎用的な値オブジェクトとして扱え、別パッケージ・別モジュールに定義することができます。

User クラスのフィールドである age を外部から参照可能とするか、参照不可能として代入や取得のメソッドを介しつつ型もプリミティブなものとするかどうかは分かれる箇所ですが、できる限り参照不可能としておくことで集約の実装の凝集度が上がり、内部処理のメンテナンス性が向上します。今回は年齢設定という機能しか持たせていませんが、User に対してビジネスロジックを実装する場合は、このクラスを充実させていきます。

UserRepository は User クラスの永続化に関するルールをまとめたインターフェースです。集約となるクラスは、アプリケーションのいずれかの場所で新しく生成され、再利用されます。その際に追跡・更新できるようにするための方法が定義されています。永続化には、リレーショナルデータベースやキーバリューストアによるデータストアや、ローカルストレージやクラウドストレージによるファイルシステムなど、インフラストラクチャにより実装が異なります。実行環境についてはこのレイヤーで触れないため、リポジトリの定義はインターフェースとして、抽象的に扱います。

User クラスにはインスタンスの再構成のために、reconstruction というメソッドが用意されています。リポジトリから集約をインスタンス化する際に利用する想定のものですが、今回のサンプルではその他のメソッドを利用して同様のことを処理できるため必要性は低いです。

export class User {
  readonly id: string;
  private age?: Age;

  constructor(id: string) {
    if (id === "") {
      throw new Error("User ID empty error");
    }
    this.id = id;
  }

  setAge(age: number) {
    this.age = new Age(age);
  }

  getAge(): number | undefined {
    return this.age?.value;
  }

  static reconstruction({ id, age }: { id: string; age?: number }): User {
    const user = new User(id);
    if (age) {
      user.setAge(age);
    }
    return user;
  }
}

class Age {
  private static AGE_MIN = 10;

  readonly value: number;

  constructor(value: number) {
    if (value < Age.AGE_MIN) {
      throw new Error("Age minumum error: " + value);
    }
    this.value = value;
  }
}

export interface UserRepository {
  findByID(id: string): Promise<User | undefined>;
  add(user: User): Promise<void>;
  removeByID(id: string): Promise<void>;
}

実装例 関数

処理内容そのものが重要である場合、関数として提供する事が考えられます。下記は例として、数学の基本的な方程式である、正方形と長方形の面積の求め方を提供しています。乱数や暗号化を扱うような処理がプログラミング言語から標準で提供されていることと同じ用に、いずれから呼び出されても副作用がなく、一定の計算結果を提供することが求められる場合は、Entity として関数を提供することが検討できます。

書籍 ドメイン駆動設計ではドメイン領域の処理について、集約に属さない(クラスが提供するメソッドではない)処理をドメインサービスと表現しています。ドメインサービスは、集約などのオブジェクトが持つ関数として実装できないか検討した上で、それでも孤立する場合は実装すると書かれています。関数が Entity として並び、それがモデルを表現しきれないことを防ぐためと考えられます。

その場合について、今回の計算に関して例を上げるならば、特定のグラフィック計算に関連して必要となるなど、計算処理の用途が限定される場合は、その集約などのクラスへ実装を閉じることが検討できます。

export function square(x: number): number {
  return x ** 2;
}

export function rectangle(x: number, y: number): number {
  return x * y;
}

イベントソーシングパターンについて

データの永続化のためのリポジトリパターン以外に、イベントソーシングパターンを取り入れるシーンが考えられます。Entity への操作を起因に、データストアやもしくは外部のシステムへ、データ追加を要求するケースなどが考えられます。その場合はリポジトリではなく、イベントソーシングを達成するための実装とインターフェースを定義します。書籍 実践ドメイン駆動設計では、イベントパブリッシャーとイベントサブスクライバと表現されています。イベントパブリッシャーがイベントを発行し、それをイベントサブスクライバへ渡す一連の流れを実装します。

ソフトウェアの要求事項を出していく上で、何かのアクションを起点としてデータ変更や通知などが伴うケースにおいて、イベントソーシングパターンを取り入れた実装を検討できます。

パッケージ services

services は entities の実装をどのように利用するかがまとめられたレイヤーであり、書籍 Clean Architecture ではコンポーネントを介してビジネスルールを呼び出すとされている箇所に相当します。ドメイン駆動設計ではアプリケーションサービスと呼ばれる箇所です。デザインパターンでは Facade パターンのような実装に近いかもしれません。entities はいずれの層からも呼び出せる中核の処理ですが、UI や入出力を担う箇所から直接呼び出すと全体の見通しが複雑になるため、それらをまとめたレシピのような責務を持ちます。アプリケーションが期待する入出力をシンプルなデータ構造に落とし込み、内部処理で entities へ渡す作りとしています。リポジトリなどの entites で定義されているインターフェースを利用する場合は、その参照をこのクラスに持たせることで、リポジトリも交えた処理を実行します。

ここに実装されるインターフェースやクラスの単位は、entities に実装されたサブパッケージやモジュールの単位でも良く、もしくは、いくつかを複合したものでも問題ありません。例えばここで定義した処理が外部のライブラリへ切り出されたとしても、下位のレイヤーは同じインターフェースでやり取りし続けられるような単位としておきます。ポイントとしては entites の実態を UI や入出力が意識しないように抽象レイヤーを提供することが目的です。ただ、ドメイン駆動設計の中でも語られているように、データに更新を伴う処理(entites の集約を保存する処理)は、一つのメソッドの中で一つのみとすると、トランザクションの単位でそのまま切り分けることができ、データの完全性など品質保持の助けとなります。

仕様をインターフェースとして公開し、実装を持つクラスは非公開なクラスに閉じたりすると、このレイヤーを利用する下位のレイヤーの依存性を抑えることができます。今回においてはシンプルに、実装を持つクラスをそのまま公開します。

実装例

下記の UserService は entities の項目で実装した User に対するまとまった処理を実行します。Repository を不変なフィールドとして持ち、ユースケース別にメソッドを持ちます。入出力はシンプルに値だけを扱うタイプであり、アプリケーション部分はこのサービスを用いることで入力値の作成や、出力値の表示に集中することができます。

register メソッドは、新規ユーザの登録を受け付けます。内部では Repository へデータの問い合わせと保存を実行しており、登録予定のユーザに ID の重複があればエラーとしています。データストアによっては INSERT と UPDATE の区別をする必要がなく、重複確認が不要となることも考えられますが、今回はビジネスルール上重複を許さないという仮定のもとで、チェックが実装されています。例えば、登録前に仮登録のフェーズが必要になった場合など、このレイヤーに求められる実装が変更となる場合は、Repository へ問い合わせ方法の種類を増やしたり、チェック処理自体を修正したりして、ビジネスルールへの変更に対応します。

このレイヤーの内部だけで利用する処理は、createUserFromDetail 関数のようにパッケージからは非公開で実装します。今回のケースでは entities の集約を生成する処理を実装しています。ただ、生成に関する処理が複雑になる場合はファクトリ(リポジトリのように永続化ではなく生成に責務を持つ処理)を別途実装し、それが重要なビジネスルールに関するのであれば entities から提供したりします。あくまでこのレイヤーの外部のパッケージからは entites の処理を意識しない作りとすることで、リファクタリングへ進む際にも、入出力処理への影響を抑止することができます。

import { User, UserRepository } from "../entities/user";

export declare namespace UserInOut {
  type UserDetail = {
    id: string;
    age: number;
  };

  type RegisterInput = UserDetail;

  type RegisterOutput = {
    id: string;
  };
}

export class UserService {
  private readonly userRepository: UserRepository;

  constructor(userRepository: UserRepository) {
    this.userRepository = userRepository;
  }

  /**
   * 新しいユーザを登録
   * @throws 登録済みのIDが入力された場合
   * @returns 登録したユーザのIDを返却
   */
  async register(
    input: UserInOut.RegisterInput
  ): Promise<UserInOut.RegisterOutput> {
    if (await this.userRepository.findByID(input.id)) {
      throw new Error("user already exists");
    }
    const user = createUserFromDetail(input);
    await this.userRepository.add(user);
    return {
      id: input.id,
    };
  }
}

function createUserFromDetail(detail: UserInOut.UserDetail): User {
  const user = new User(detail.id);
  user.setAge(detail.age);
  return user;
}

実装した処理を確認したい場合は、下記のようなリポジトリのモックを利用して動作を確認することができます。また、ユニットテストを実装しチェック機構もセットとすることで、後工程の安全性が高まります。公開するインターフェースやクラスの定義の良し悪しを、呼び出す側の観点からチェックすることもできます。

async function run() {
  const service = new UserService(new UserRepositoryMock());
  const output = await service.register({
    id: "abcd",
    age: 20,
  });
  console.log("add complete: " + output.id);
}

class UserRepositoryMock implements UserRepository {
  async findByID(id: string): Promise<User | undefined> {
    if (id === "abc") {
      return new User(id);
    }
    return undefined;
  }

  async add(user: User): Promise<void> {
    console.log("user add!");
  }

  async removeByID(id: string): Promise<void> {
    throw new Error("Method not implemented.");
  }
}

複数の集約の取り扱い

前述したユーザ登録へ、登録前に仮登録のフェーズが必要になった場合について、entities の User が登録済みのユーザだと仮定した場合、登録完了を待つ状態は概念的に別のものとなります。その場合は、User を拡張するよりも、PreRegisteredUser といった別の集約を実装すると、クラスの実装を小さく保つことができ、コードが持つ責務を切り分けることができます。その場合は、UserService が User と PreRegisteredUser の両方を持つ実装が検討されます。その場合は、PreRegisteredUserRepository もセットで定義される形になると考えられます。

登録の完了と同時に事前登録のデータの無効化も同時に必要とされるならば、上述の 「一つのメソッドに一つのリポジトリのデータ更新」のルールを崩すこととなりますが、一つのトランザクションの中でどちらのデータも更新することが考えられます。または、事前登録自体はタイムスタンプを持ち、期限切れにより管理されるのであれば、PreRegisteredUserRepository へは有効データの有無の確認のみとなり、「一つのメソッドに一つのリポジトリのデータ更新」のルールを維持し、データ変更による影響範囲を狭めることができます。

CQRS(コマンドクエリ責務分離) パターンについて

データの読み出しと保存において、パフォーマンスと再現性の観点において非合理的な処理となるケースがありえます。CQRS とは、読み出しに関する処理と、保存に関する処理を分けて実装することで、パフォーマンスやメンテナンス性を向上させる設計です。このレイヤーでの実装において、データ取得がボトルネックとなる場合は、CQRS を取り入れた実装を検討できます。

パッケージ app

利用者(外部システムやオペレーター、ユーザなど)の入出力から、entities と services を行使するレイヤーです。アプリケーションそのものを指すパッケージ名では言葉自体のコンテキストが広いため、入出力の流れに関する実装を持つサブパッケージを、一例として下記のように分けます。models は表示に関わるデータ定義(View Model)が実装され、controllers は入力されたデータを受け取りからビジネスロジックの実行結果を出力へ流す実装を持ちます。

  • controllers
  • models

実際の入力処理や表示に関わる箇所は、CUI や GUI や Web サーバなど、アプリケーションの形式により大きく分かれるため、ここでは具体的な分け方は定義しません。今回は CLI と React を用いた SPA(Single Page Application)とするため、下記のようにサブパッケージを作成し、コマンド実行に関わる処理や React コンポーネントの実装をします。これらのパッケージに実装される処理は、entites や services を直接利用せず、controllers を介してビジネスロジックを実行し、models の値に従い出力処理を実行します。controllers の実装を再利用できるため、入力値の作成や UI に関する処理に集中できます。

  • commands
  • components

実装例 models

app レイヤー内で入出力処理に利用されるシンプルなデータ定義を持ちます。入出力の流れの中で共通して利用なデータがあれば、それ以外のデータ定義の実装も検討できます。

UserRegisterState は User の登録処理に応じて出力されるデータ定義を実装しています。

error の有無で出力結果の出し分けをする想定としています。発生したエラーが、controllers の呼び出し元などへ戻されるべきであればこのフィールドは不要ですが、入力から出力までの流れを一方向に保つために、ここではそのように実装しています。

export type UserRegisterState = {
  userID: string;
  error?: Error;
};

実装例 controllers

アプリケーションの入出力の流れと、entities や services の実行を処理します。アプリケーション全体の全てを担うものではなく、CUI のコマンド単位や、UI の画面単位など、利用者のユースケースに沿った役割の限られた実装を持ちます。責務としてはアプリケーションの処理の流れ自体を持ち、外部からの入力値を受け付け、それをもとにビジネスロジックを実行し、出力処理へ結果を反映します。そのため、ビジネスロジックや入出力など、独特のロジックは持ちません。また、出力処理はインターフェースで定義され、具体的な実装も持ちません。

今回は UserService を内部に持ち、それに関する処理を提供する UserController を実装します。UserView は models に実装された出力データである UserRegisterState を受け取り出力処理を責務とするインターフェースです。

UserController の register は User の登録処理を実行します。入力値の引数として UserService の引数をそのまま受け取りますが、アプリケーション特有のデータがハンドリングに必要であれば、models や、controllers に必要なデータ定義を実装します。処理内容としてはビジネスロジック(今回は services の実行)に対するエラーハンドリングを行い、エラー有無により出力処理を分けています。プログラミング言語等の違いにより、エラーハンドリングについてはいくつか検討できますが、発生したエラーを呼び出し元へ返す(例外として戻す)流れとした場合、入力処理を行った Controller の呼び出し元でエラーに関する出力までのハンドリングを処理する必要が発生します。ビジネスロジックである services レイヤーではシンプルにエラーを戻しましたが、UI 等に関わる領域ではエラーをそのまま出力処理へ流したほうがシンプルな流れとなるため、ここではエラーを UserRegisterState へセットして UserView への処理へ進めています。

なお、アプリケーション全体に渡りビジネスロジックの実行に対するログが必要であれば、ここへ処理を実装することで、統一的なロギングが可能となります。

import { UserService, UserInOut } from "../../services/user";
import { UserRegisterState } from "../models/user";

export interface UserView {
  registered(state: UserRegisterState): Promise<void>;
}

export class UserController {
  private readonly service: UserService;
  private readonly view: UserView;

  constructor(service: UserService, view: UserView) {
    this.service = service;
    this.view = view;
  }

  async register(input: UserInOut.RegisterInput): Promise<void> {
    try {
      const output = await this.service.register({
        id: input.id,
        age: input.age,
      });
      await this.view.registered({
        userID: output.id,
      });
    } catch (error) {
      await this.view.registered({
        userID: input.id,
        error: error,
      });
    }
  }
}

実装例 commands

CLI 向けに UserController を実行する処理を実装します。コマンド作成のライブラリである commander.js を利用し、指定されたコマンドに対して、フラグ引数や実行対象の関数を追加する処理を作成します。ここでは、UserView の実装クラスの作成と、それを利用した UserController の実行を試します。

commander.js GitHub

register がコマンド実行箇所であり、UserViewConsole は出力結果を標準出力に表示する、UserView を実装したクラスです。UserRepositoryMock は UserService の動作確認で前述したとおり、User の永続化をする UserRepository のモッククラスです。

今は UserView に対する実装を他のパッケージ等に現在は分けていないため、register と同じ箇所に書かれていますが、下記のコードで本質的に必要な箇所は register までの記述です。UserViewConsole を別のパッケージに分ければ、表示に関する処理も再利用させる形で共通化できます。ID と年齢はコマンド実行の際のフラグ引数で指定します。

createUserController は UserController を作成して返却させる、Controller の生成に責務を持つ関数です。今回では実装していませんが、UserController を呼び出し元からセットさせたりすることで、createUserController に相当する処理を切り分けることができます。

import commander from "commander";
import { User, UserRepository } from "../../entities/user";
import { UserService } from "../../services/user";
import { UserController, UserView } from "../controllers/user";
import { UserRegisterState } from "../models/user";

export function addCommandUserRegister(cmd: commander.Command) {
  cmd
    .requiredOption("--id <id>", "User ID")
    .requiredOption("--age <age>", "User age")
    .action(register);
}

async function register(opts: { id: string; age: number }) {
  const controller = createUserController();
  try {
    await controller.register({
      id: opts.id,
      age: opts.age,
    });
  } catch (error) {
    console.error(error);
  }
}

function createUserController(): UserController {
  const service = new UserService(new UserRepositoryMock());
  return new UserController(service, new UserViewConsole());
}

class UserViewConsole implements UserView {
  async registered(state: UserRegisterState): Promise<void> {
    if (state.error) {
      console.info("user register error: " + state.userID);
      console.error(state.error);
    } else {
      console.info("user registered: " + state.userID);
    }
  }
}

class UserRepositoryMock implements UserRepository {
  async findByID(id: string): Promise<User | undefined> {
    if (id === "abc") {
      return new User(id);
    }
    return undefined;
  }

  async add(user: User): Promise<void> {
    console.log("user add!");
  }

  async removeByID(id: string): Promise<void> {
    throw new Error("Method not implemented.");
  }
}

実行結果は下記です。呼び出し元の関数の内容やコマンドは後述の main パッケージ に関する箇所で触れるため省略します。

# 正常
$ ./run.sh user register --id=012 --age=20
user add!
user registered: 012

# エラー(ユーザID重複により UserService から登録を拒否)
$ ./run.sh user register --id=abc --age=20
user register error: abc
Error: user already exists
    at UserService.<anonymous> (/src/package/services/user.ts:32:13)
    at step (/src/package/services/user.ts:33:23)
    at Object.next (/src/package/services/user.ts:14:53)
    at fulfilled (/src/package/services/user.ts:5:58)

実装例 components

SPA(Single Page Application) として動作する React アプリケーション 向けに UserController を実行する React コンポーネントを実装します。実装方法については、React コンポーネントの状態管理と表示を分離する箇所などにも踏み込んで進めます。

処理の結合度を下げるため、HTML のレンダリングと、値の状態管理を分けて実装します。Controller をラップするカスタムフックと、それを利用する React コンポーネントにコードを分けます。

カスタムフックの作成

React コンポーネントから状態管理の複雑さを除くため、ユーザ登録に関するカスタムフックを実装します。React Hooks の useReducer を利用し、UserController の処理を起点に状態の更新と、コンポーネントのレンダリングが実行されるようにします。UserViewReducer は useReducer の状態を更新するための UserView を満たす実装を持つクラスです。useReducer が作成した Dispatch を持ち、UserController を介して UserView が実行されたことを起点に Dispatch を実行して状態を更新します。

useUser が公開する関数であり、これを React コンポーネントから 呼び出すことで、状態管理を意識せず、かつ UserController を利用できるようになります。状態管理の処理自体はこのモジュールで閉じているため、状態変更の処理が増えても、変更箇所はこのモジュールだけです。

import { useReducer } from "react";
import { UserService } from "../../../services/user";
import { UserController, UserView } from "../../controllers/user";
import { UserRegisterState } from "../../models/user";

type Action = {
  type: "RESULT";
  payload: UserRegisterState;
};

type Dispatch = (action: Action) => void;

const reducer = (
  state: UserRegisterState,
  { type, payload }: Action
): UserRegisterState => {
  switch (type) {
    case "RESULT":
      return {
        ...state,
        userID: payload.userID,
        error: payload.error,
      };
  }
};

class UserViewReducer implements UserView {
  private readonly dispatch: Dispatch;

  constructor(dispatch: Dispatch) {
    this.dispatch = dispatch;
  }

  async registered(state: UserRegisterState): Promise<void> {
    this.dispatch({
      type: "RESULT",
      payload: state,
    });
  }
}

export const useUser = (
  service: UserService,
  initialState: UserRegisterState
): [UserRegisterState, UserController] => {
  const [state, dispath] = useReducer(reducer, initialState);
  const view = new UserViewReducer(dispath);
  const controller = new UserController(service, view);
  return [state, controller];
};

表示部分の React コンポーネントの作成

レンダリング用の React コンポーネントが状態管理まで責務を持つと、コードが肥大化し処理も複雑になるため、上記で作成した useUser を直接利用しない、HTML をレンダリングするための React コンポーネントを実装します。ここでは、React コンポーネントのプロパティとして、レンダリングする情報である状態の値(models に実装した出力データ定義)と、Controller を受け取ります。受け取った状態をもとに表示結果をレンダリングします。Controller は自ら作成せず、受け取ったものを呼び出します。

ID と年齢の入力を受け付けるテキストボックスを用意し、ボタンをクリックするとユーザ登録が実行されます。この React コンポーネントをマウントすると、一連のフォームや実行結果を出力する画面を使うことができます。

import { useRef } from "react";
import { UserController } from "../../controllers/user";
import { UserRegisterState } from "../../models/user";

type Props = {
  state: UserRegisterState;
  controller: UserController;
};

export const UserPage = ({ state, controller }: Props) => {
  return (
    <>
      <RegisterForm controller={controller} />
      <RegisterResult state={state} />
    </>
  );
};

const RegisterForm = ({ controller }: { controller: UserController }) => {
  const id = useRef({ value: "" } as HTMLInputElement);
  const age = useRef({ value: "" } as HTMLInputElement);
  const register = () =>
    controller.register({
      id: id.current.value,
      age: parseInt(age.current.value),
    });
  return (
    <>
      <input placeholder="id" ref={id} />
      <input type={"number"} placeholder="age" ref={age} />
      <button onClick={register}>Register</button>
    </>
  );
};

const RegisterResult = ({ state }: { state: UserRegisterState }) => {
  if (state.error) {
    return <p>{state.error.message}</p>;
  } else {
    return <p>{state.userID}</p>;
  }
};

ロジックと表示を接続する React コンポーネントの作成

ここまでで実装したカスタムフックと React コンポーネントを結びつける React コンポーネントを作成します。

import { UserService } from "../../services/user";
import { useUser } from "./hooks/user";
import { UserPage } from "./view/user";

type Props = {
  userService: UserService;
};

const UserComponent = ({ userService }: Props) => {
  const [state, controller] = useUser(userService, {
    userID: "",
    error: undefined,
  });
  return <UserPage state={state} controller={controller} />;
};

export default UserComponent;

UserService の作成は、前述の CLI の実装のようにモックリポジトリを使う想定です。この部分は、後述の main パッケージ の項目にて、インスタンスを渡すように実装します。

パッケージ repositories

前述で実装した UserRepositoryMock のように、entities で定義されたインターフェースに対してクラスを実装します。記憶装置やデータベースや、HTTP や RPC で接続される外部のシステムなど、永続化の対象とする実態にあわせて必要な処理を持ちます。

repositories が依存するのは entities の対応する集約の実装と、必要に応じてインポートされる外部のライブラリがほとんどであり、ビジネスロジックやアプリケーションの入出力については触れません。責務としては、永続化対象の集約のインスタンスを外部へ保存することと、外部から読み出した内容から、集約のインスタンスを作成することです。

entities にリポジトリ以外のインターフェースがあり(前述のイベントソーシングの場合など)、それを実装する必要がある場合は、同様のレベルのパッケージを定義し、その配下に実装を並べます。

パッケージ main

エントリポイントや、最上位レベルの React コンポーネントなど、アプリケーションの実行において、一番最初に呼び出される処理を実装します。また、今まで実装してきたインターフェースを介した依存について実装クラスを注入したり、環境ごとに必要な設定などを解決します。主な役割として下記を挙げられます。

  • 依存性注入 (Dependency Injection)
  • フレームワークの初期化・設定ファイル
  • データベースへの接続
  • クラウドサービスを利用するための設定
  • アプリケーション実行 (サーバ起動 や GUI アプリケーションの立ち上げ)

なお、プログラミング言語によってはパッケージではなくファイルを直接配置するなど、いくつかの形に分かれるため、それらは適切な形で実装します。今回は、CLI と React コンポーネントという 2 つの実行方法があるため、サブパッケージを一例として下記のように分けます。

  • cli
  • react

実装例 cli

main 配下に cli というパッケージを作成し、その配下にエントリポイントとなる関数や設定処理を実装します。下記の runCLI は、commander.js を利用して前述に実装した addCommandUserRegister を、指定のコマンド名で呼び出す処理です。これにより、 user register というコマンドライン引数で addCommandUserRegister を実行できるようにします。

import { program } from "commander";
import { addCommandUserRegister } from "../../app/commands/user";

const runCLI = async () => {
  try {
    addUserCmd();
    await program.parseAsync(process.argv);
  } catch (error) {
    console.error(error);
  }
};

function addUserCmd() {
  const cmd = program.command("user");
  addCommandUserRegister(cmd.command("register"));
}

export default runCLI;

エントリポイントとなる関数を実装します。TypeScript のため、JavaScript へトランスパイルして Node.js から実行するなどの方法で起動します。

import { rootPath } from "get-root-path";
import runCLI from "./cli";

export function main() {
  runCLI();
}

main();

今回は、ts-node を利用しているため、その実行用の Shell を記述します。tsconfig.json などの設定により、細かなオプションは変わってきます。

ts-node -O "{\"module\": \"CommonJS\"}" index.ts $@

下記のように実行します。

$ ./run.sh user register --id=abc --age=20

実装例 react

React では create-react-app を用いることでプロジェクトの初期ファイルが自動で作成され、その際の index.tsx がプログラムの起動場所です。今回は、そこから呼び出す React コンポーネントをここで実装します。リポジトリの実装クラスを指定してインスタンス化したり、そこから作成した services のインスタンスを下位コンポーネントへ渡します。

import UserComponent from "../../app/components/user";
import { User, UserRepository } from "../../entities/user";
import { UserService } from "../../services/user";

const Main = () => {
  const userService = new UserService(new UserRepositoryMock());
  return (
    <>
      <UserComponent userService={userService} />
    </>
  );
};

class UserRepositoryMock implements UserRepository {
  async findByID(id: string): Promise<User | undefined> {
    if (id === "abc") {
      return new User(id);
    }
    return undefined;
  }

  async add(user: User): Promise<void> {
    console.log("user add!");
  }

  async removeByID(id: string): Promise<void> {
    throw new Error("Method not implemented.");
  }
}

export default Main;

まとめ

以上のような責務の切り分けと実装方針により、ソフトウェアに求められるビジネスロジックとアプリケーションロジックを分離しつつ、ビジネスロジックの実行に至る入力から出力までの流れを整理することができました。

ビジネスロジックは環境や、ミドルウェアやフレームワークといった外部への依存がなくなることで、外的要因により故障する懸念が減り、なおかつ再利用性が高まりました。また、アプリケーションの入出力の流れが整理されたことで、CLI や GUI、サーバなどの実行方法の違いによる実装箇所は、その技術的関心事に集中できるようになりました。

ビジネスを取り巻く環境や、開発メンバの入れ替わりなど、日々状況が変化する中でも、冒頭に挙げた下記の点が保たれれば幸いです。

  • ソフトウェアが属人化せずに、理解可能な状態を保つこと
  • ビジネスとテクノロジーの変化に対して、コードに対する変更箇所が限定的であること