Patterns of Enterprise Application Architecture から学ぶ - Chapter 2
はじめに
こんにちは。ソフトウェアエンジニアのttokutakeと申します。
これはPatterns of Enterprise Application Architecureという本を読んでみて、自分が理解した内容を要約して書き起こしていくシリーズの2回目の記事です。
全18回 全8回のシリーズとなる予定です。
間違っている部分などがありましたら、ご指摘いただけますと幸いです。
注意事項
対象読者は主にWebアプリケーションのエンジニアです。
本の内容をそのまま記事に記載しているわけではありません。
内容のすべてを記載すると情報量が多いように感じたので、省略している部分がそれなりにあります。
自分自身の見解を述べている箇所もあります。
Chapter 2. Organizing Domain Logic
ドメインロジックを実装する主要な3パターンを紹介していきます。
コード例に関してですが、それぞれのパターンのメリット・デメリットがわかりやすいような例にはなっていないと思います。より良い例を考えついたらのちほどアップデートさせていただく可能性がございます。
Transaction Script
Transaction Scriptはもっともシンプルなアプローチです。簡単に言ってしまえば、「素直に手続き的に処理を記述する」というものです。
入力を受け取る
バリデーションや計算処理をする
データをデータベースに保存する
他のシステムの処理を呼び出す
必要な値を加工して呼び出し元に返す
Transaction Scriptの利点には以下のようなものがあります。
アプローチ自体は素直で非常に理解しやすい
Row Data GatewayやTable Data Gatewayなどのシンプルなデータソースレイヤーとの相性が良い
これらデータソースレイヤーのパターンについてはのちの記事で詳しく解説をする予定です
データベーストランザクションを設定しやすい
ただしドメインロジックが複雑になればなるほど、コードの重複の発見が困難であったり、排除が難しくなるため、コードの重複の起こりやすさに大きな欠点があります。
Transaction Scriptのコード例を載せます。前回の記事 と同様に TypeScript と Deno による実装です。
DBにはある海賊団の乗組員の名前と懸賞金のデータが入ったテーブルが存在しているとします。
// crews
id | name | bounty
----+-------+------------
1 | Luffy | 1500000000
2 | Zoro | 320000000
データソースレイヤーはTable Data Gatewayで実装しています。レコードを表すオブジェクトの配列RecordSetを返す "findAll()" を実装しています。
// data_source.ts
import { client } from "./postgres_client.ts";
export interface Row {
id: number;
name: string;
bounty: bigint;
}
export type RecordSet = Row[];
export class TableDataGateway {
static async findAll(): Promise<RecordSet> {
const sql = `
SELECT id, name, bounty
FROM crews
`;
const result = await client.queryObject<Row>(sql);
return result.rows;
}
}
ドメインレイヤーでは手続き的な処理で "listStrawHatPirates()" が実装されています。"Crew" と "Pirate" がプレゼンテーションレイヤーで必要なデータを持つだけの素朴なデータ構造となっています。
// domain.ts
import { Row, TableDataGateway } from "./data_source.ts";
export interface Crew {
name: string;
isDanger: boolean;
}
export interface Pirate {
totalBounty: bigint;
crews: Crew[];
}
export class TransactionScript {
static async listStrawHatPirates(): Promise<Pirate> {
const rows = await TableDataGateway.findAll();
const totalBounty = rows.reduce(
(sum: bigint, r: Row) => sum + r.bounty,
BigInt(0),
);
const crews = rows.map(
(r: Row) => ({ name: r.name, isDanger: r.bounty >= 1_000_000_000 }),
);
return {
totalBounty,
crews,
};
}
}
プレゼンテーションレイヤーでは "listStrawHatPirates()" を呼び出して、必要なデータを用いてHTMLを返します。
// presentation.ts
import { Crew, TransactionScript } from "./domain.ts";
export class Presentation {
static async handler(_request: Request): Promise<Response> {
const pirate = await TransactionScript.listStrawHatPirates();
const content = pirate.crews
.map((crew: Crew) => {
return `<li>${crew.name}${crew.isDanger ? " (Danger)" : ""}</li>`;
})
.join("");
const html = `
<html>
<title>Organizing Domain Logic</title>
<body>
<div>Total Bounty: ${pirate.totalBounty}</div>
<ul>
${content}
</ul>
</body>
</html>
`;
return new Response(html, {
status: 200,
headers: { "content-type": "text/html; charset=utf-8" },
});
}
}
最後にWebサーバーを起動するメインファイルです。
// main.ts
import { serve } from "./deps.ts";
import { Presentation } from "./lib/presentation.ts";
const port = 8080;
console.log(`HTTP webserver running. Access it at: http://localhost:8080/`);
await serve(Presentation.handler, { port });
ここで提示していないファイルも含めて、すべてのコードを確認したい方は こちら をご参照ください。
このWebアプリケーションにブラウザからアクセスすると以下のように表示されます。
Domain Model
Domain Modelはオブジェクト指向で複雑なロジックを扱うためのパターンです。
Domain Modelの構築していく第一歩は「自分たちのビジネスで用いる名詞を用いていく」ことです。例えば、ECサイトでは「商品」や「ショッピングカート」、「配送」などの名詞がDomain Modelとして定義する候補となりそうです。
Domain Modelを使う利点は デザインパターン のような洗練されたテクニックで複雑なパターンを扱っていけることです。
ただしDomain Modelに慣れるにはそれなりに苦労があるのが欠点です。またDomain Modelが豊かな表現を持つようになればなるほど、データソースレイヤーのデータとのマッピングが複雑になりやすいです。
Domain Modelのコード例を載せます。Transaction Scriptで提示したコード例から、ドメインレイヤーとプレゼンテーションレイヤーの実装以外に変更はありません。
ドメインレイヤーでは "Crew" というclassや "StrawHatPirates" というclassで簡単なデータ構造とビジネスロジックを表現しています。
// domain.ts
import { RecordSet, Row, TableDataGateway } from "./data_source.ts";
export class Crew {
constructor(
public name: string,
public bounty: bigint,
) {}
isDanger() {
return this.bounty >= 1_000_000_000;
}
}
export class StrawHatPirates {
crews: Crew[];
private constructor(recordSet: RecordSet) {
this.crews = recordSet.map((r: Row) => new Crew(r.name, r.bounty));
}
totalBounty(): bigint {
return this.crews.reduce(
(sum: bigint, c: Crew) => sum + c.bounty,
BigInt(0),
);
}
static async build() {
const recordSet = await TableDataGateway.findAll();
return new this(recordSet);
}
}
プレゼンテーションレイヤーでは上記のDomain Modelを用いてHTMLを返します。
// presentation.ts
import { Crew, StrawHatPirates } from "./domain.ts";
export class Presentation {
static async handler(_request: Request): Promise<Response> {
const pirate = await StrawHatPirates.build();
const content = pirate.crews
.map((crew: Crew) => {
return `<li>${crew.name}${crew.isDanger() ? " (Danger)" : ""}</li>`;
})
.join("");
const html = `
<html>
<title>Organizing Domain Logic</title>
<body>
<div>Total Bounty: ${pirate.totalBounty()}</div>
<ul>
${content}
</ul>
</body>
</html>
`;
return new Response(html, {
status: 200,
headers: { "content-type": "text/html; charset=utf-8" },
});
}
}
ここで提示していないファイルも含めて、すべてのコードを確認したい方は こちら をご参照ください。
Table Module
Table Moduleは一見するとDomain Modelに似ていますが、決定的な違いがあります。ここではリレーショナルデータベースを用いて説明します。Domain Modelはテーブルの1レコードに対して1インスタンスという対応ですが、Table Moduleはレコードの集合(Record Set)に対して1インスタンスという対応です。
Table Moduleの最大の利点は多くのGUI環境はSQLの結果であるRecord Setと親和性が高いということです。この点に関しては、自分はあまりなじみがないためあいまいに説明してしまいますが、ひと昔前のデスクトップアプリケーションはテーブルのデータを操作するためのただのインターフェースであることが多かったようです。そのため、このような利点が強調されているのだと思います。現在においてはそのような単純なアプリケーションが作られることはあまりないので、この利点が発揮される場面はまれだと思われます。
Table Moduleのコード例を載せます。Transaction Scriptで提示したコード例から、ドメインレイヤーとプレゼンテーションレイヤーの実装以外に変更はありません。
ドメインレイヤーではTable Moduleの実装がされています。 1インスタンスが1テーブルに対応しているため、"isDanger()" では引数にレコードのIDを受け取るような実装になっています。
// domain.ts
import { RecordSet, Row } from "./data_source.ts";
export class TableModule {
crews: RecordSet;
constructor(recordSet: RecordSet) {
this.crews = recordSet;
}
totalBounty() {
return this.crews.reduce(
(sum: bigint, r: Row) => sum + r.bounty,
BigInt(0),
);
}
isDanger(id: number) {
const crew = this.crews.find((c: Row) => c.id == id);
if (!crew) {
throw Error("Crew is not found.");
}
return crew.bounty >= 1_000_000_000;
}
}
プレゼンテーションレイヤーでは、まずTable Data GatewayからRecordSetを取得して、それを用いてTable Moduleを作成しています。そして、Table Moduleから必要なデータを取得してHTMLを返します。
// presentation.ts
import { Row, TableDataGateway } from "./data_source.ts";
import { TableModule } from "./domain.ts";
export class Presentation {
static async handler(_request: Request): Promise<Response> {
const recordSet = await TableDataGateway.findAll();
const tableModule = new TableModule(recordSet);
const content = tableModule.crews
.map((crew: Row) => {
return `<li>${crew.name}${
tableModule.isDanger(crew.id) ? " (Danger)" : ""
}</li>`;
})
.join("");
const html = `
<html>
<title>Organizing Domain Logic</title>
<body>
<div>Total Bounty: ${tableModule.totalBounty()}</div>
<ul>
${content}
</ul>
</body>
</html>
`;
return new Response(html, {
status: 200,
headers: { "content-type": "text/html; charset=utf-8" },
});
}
}
ここで提示していないファイルも含めて、すべてのコードを確認したい方は こちら をご参照ください。
3つの選択肢
3つのパターンのどれを選択すべきかを判断するのは容易ではありません。それぞれのパターンのドメインロジックの複雑性に対する修正の難易度の関係は以下のようなイメージです。
状況に応じて、どれを選択するかは自身で判断する必要があります。
ただし、初期の選択を長い間ためらう必要もないかと個人的には考えています。特に現代のWebアプリケーション開発においては、自動テストやCIの技術が非常に充実してきていますので、修正や実装の変更が難しいと感じた段階でリファクタリングをしていくのが良いと思います。
また3つのパターンは相互排他の関係ではありません。一部の箇所をTransaction Scriptで実装して、残りをDomain Modelで実装するようなこともよく行われます。
サービスレイヤー
Domain ModelやTable Moduleで表現しきれない処理を置く場所として、サービスレイヤーを用いることがよくあります。また、データベーストランザクションやセキュリティに関する処理を置く場所としても使いやすいレイヤーです。
セキュリティについては具体的な記述や例が見当たらなかったので、実際にどういったものを指しているかを自分はよくわかっていません。大変申し訳ないのですが、先々のChapterで判明することがあれば改めて説明をしようと思います。
最小のケースとしては、複数のDomain Modelの操作を組み合わせるだけというものです。これは Facadeパターン とも呼ばれるものです。
逆にサービスレイヤーの処理がTransaction Scriptになっていて、複雑なビジネスロジックを処理しているというケースもあります。
サービスレイヤーを用いたコード例を載せます。Transaction Scriptで提示したコード例から、ドメインレイヤーとプレゼンテーションレイヤーの実装以外に変更はありません。
ドメインレイヤーは "Crew" というclassが定義されているだけです。
// domain.ts
export class Crew {
constructor(
public name: string,
private bounty: bigint,
) {}
isDanger() {
return this.bounty >= 1_000_000_000;
}
}
サービスレイヤーでは海賊団の情報を返す "list()" が定義されています。"list()" の実装はTransaction Scriptになっています。
// service.ts
import { Row, TableDataGateway } from "./data_source.ts";
import { Crew } from "./domain.ts";
export interface Pirate {
crews: Crew[];
totalBounty: bigint;
}
export class StrawHatPiratesService {
static async list(): Promise<Pirate> {
const recordSet = await TableDataGateway.findAll();
const crews = recordSet.map((r: Row) => new Crew(r.name, r.bounty));
const totalBounty = recordSet.reduce(
(sum: bigint, r: Row) => sum + r.bounty,
BigInt(0),
);
return { crews, totalBounty };
}
}
プレゼンテーションレイヤーはサービスレイヤーの実装を用いてHTMLを返します。
// presentation.ts
import { Crew } from "./domain.ts";
import { StrawHatPiratesService } from "./service.ts";
export class Presentation {
static async handler(_request: Request): Promise<Response> {
const pirate = await StrawHatPiratesService.list();
const content = pirate.crews
.map((crew: Crew) => {
return `<li>${crew.name}${crew.isDanger() ? " (Danger)" : ""}</li>`;
})
.join("");
const html = `
<html>
<title>Organizing Domain Logic</title>
<body>
<div>Total Bounty: ${pirate.totalBounty}</div>
<ul>
${content}
</ul>
</body>
</html>
`;
return new Response(html, {
status: 200,
headers: { "content-type": "text/html; charset=utf-8" },
});
}
}
ここで提示していないファイルも含めて、すべてのコードを確認したい方は こちら をご参照ください。
サービスレイヤーを使うべき場面についてですが、使う必要がなければ使わないほうが良いというのが筆者の主張です。サービスレイヤーを追加せずに、適切なDomain Modelに適切な表現や処理を追加してやりたいことが実現できるのであれば、そのほうが良いということでしょう。ただし、この主張は絶対に守らないといけないものではなく、それぞれの状況や好みで判断していけばよいものだとも主張していました。
さいごに
今回はChapter 2. Organizing Domain Loginについての紹介をしました。
説明が不足していたり、わかりにくいようなところがありましたら、お気軽にご連絡いただければと思います。
次回はChapter 3. Mapping to Relational Databasesを紹介します。どうぞよろしくお願いします。