Patterns of Enterprise Application Architecture から学ぶ - Chapter 1
はじめに
こんにちは。ソフトウェアエンジニアのttokutakeと申します。
これはPatterns of Enterprise Application Architecureという本を読んでみて、自分が理解した内容を要約して書き起こしていくシリーズの最初の記事です。
全18回 全8回のシリーズとなる予定です。
間違っている部分などがありましたら、ご指摘いただけますと幸いです。
注意事項
対象読者は主にWebアプリケーションのエンジニアです。
本の内容をそのまま記事に記載しているわけではありません。
内容のすべてを記載すると情報量が多いように感じたので、省略している部分がそれなりにあります。
自分自身の見解を述べている箇所もあります。
本の構成
大きくPart 1とPart 2の2部構成となっています。
Part 1は様々な問題の全体像や解決策がざっくり解説されているようです。
Part 1はChapter 1からChapter 8までです。
Part 2はPart 1でもちらほら登場するパターンの詳細が解説されているようです。
Part 2はChapter 9からChapter 18までです。
Introduction
Introductionでは主に期待値調整や読者が注意すべきことが記載されています。
今後使う言葉の定義を詳細に説明していたり、筆者が参考にした古典的なパターンについても言及されています。
これらについては長くなるので省略させていただきます。必要になったら今後の記事で都度補足できたらと思います。
ここでは、これから本の内容を理解する上で意識しておいたほうがよさそうだと自分が思ったことを抜粋しておきます。
自分が遭遇する状況にそのまま使えるようなパターンは存在しません。
盲目的に適用しようとしたら悲惨な結末を迎えることになります。
パターンを覚える利点の一つは共通言語を持つことによるコミュニケーションの簡略化です。
「ここはxxパターンをベースに考えよう」と言えば、(お互いに理解していれば)細かい部分をいちいち説明しなくてもさくっと相手に伝わります。
この本のコード例(さらに、自分がこの記事のシリーズで提示するコード例)は理解しやすくするために極力シンプルにしているので、現実では対応しないといけないようなことも省かれています。
パターンは目的ではなく、あくまでスタート地点です。
自分の状況に合うように完成させていくのはあなたの仕事です。
Chapter 1. Layering
ソフトウェアの機能を分離するときに、Layeringはもっともよく使われるテクニックです。よく知られているMVCモデルやOSI参照モデルなどもLayeringの一種と考えられます。
上位レイヤーは下位レイヤーに依存する形になります。大抵の場合は上位レイヤーは直接の子レイヤーを利用しますが、さらに下位のレイヤー(孫レイヤーなど)についての詳細を知らなくてよいというものがあります。これは利点の一つです。
明確に注意すべきこととして、上位レイヤーの機能を下位レイヤーから利用するようなことはしてはいけません。これは依存関係の把握を難しくしますし、循環参照の問題を引き起こしやすいです。
3つの主要なレイヤー
Layeringは導入しやすいテクニックです。しかし、どんなレイヤーを用意して、各レイヤーはどのような責務を担うのかを考えるのは一筋縄ではいかない作業となります。これは欠点の一つです。
とはいえ、まったく指針がないわけではありません。もっとも基本的な型として3つの主要なレイヤーを軸として考える方法があります。
プレゼンテーションレイヤー
HTTPリクエストをハンドリングする
HTMLを表示する
JSONを返す
etc.
ドメインレイヤー
コアとなるビジネスロジックの処理をする
データソースレイヤー
データベースと通信する
メッセージングシステムと通信する
外部サービスからWeb APIでリソースを取得する
etc.
ここまで一気に説明してしまいましたが、ここで実際にWebアプリケーションのコードを例示してみようと思います。TypeScript と Deno を利用しています。
DBにはある海賊団の乗組員の名前と懸賞金のデータが入ったテーブルが存在しているとします。
// crews
id | name | bounty
----+-------+------------
1 | Luffy | 1500000000
2 | Zoro | 320000000
まずはデータソースレイヤーです。ここでは単純にDBのテーブルからすべてのレコードを取得しています。
// data_source.ts
import { client } from "./postgres_client.ts";
export interface Row {
id: number;
name: string;
bounty: bigint;
}
export class DataSource {
static async list(isAsc: boolean): Promise<Row[]> {
const sql = `
SELECT id, name, bounty
FROM crews
ORDER BY id ${isAsc ? "ASC" : "DESC"}
`;
const result = await client.queryObject<Row>(sql);
return result.rows;
}
}
ドメインレイヤーはデータソースレイヤーのコードを呼び出してから、ビジネス特有の計算をします。大抵のビジネスではここで複雑な計算をすることになります。今回の例では、乗組員が危険かどうかを判定する "isDanger()" や "listStrawHatPirates()" という処理が実装されています。
// domain.ts
import { DataSource, Row } from "./data_source.ts"; // データソースレイヤーに依存
export class Crew {
constructor(
private id: number,
public name: string,
public bounty: bigint,
) {}
isDanger() {
return this.bounty >= 1_000_000_000;
}
}
export interface Pirate {
totalBounty: bigint;
crews: Crew[];
}
export class Domain {
static async listStrawHatPirates(isAsc: boolean): Promise<Pirate> {
const rows = await DataSource.list(isAsc);
const crews = rows.map(({ id, name, bounty }: Row) =>
new Crew(id, name, bounty)
);
const totalBounty = crews.reduce(
(sum: bigint, c: Crew) => sum + c.bounty,
BigInt(0),
);
return {
totalBounty,
crews,
};
}
}
プレゼンテーションレイヤーでは以下のような処理をしています。
HTTPリクエストからクエリパラメータを取り出してドメインレイヤーに渡すパラメータを作成する
ドメインレイヤーのコードを呼び出してデータを取得する
ユーザーに表示するHTMLを返す
乗組員の名前と危険かどうかを示す表示を一覧で表示します
// presentation.ts
export class Presentation {
static async handler(request: Request): Promise<Response> {
const url = new URL(request.url);
const isAsc = url.searchParams.get("direction") != "desc";
const pirate = await Domain.listStrawHatPirates(isAsc);
const content = pirate.crews
.map((crew: Crew) => {
return `<li>${crew.name}${crew.isDanger() ? " (Danger)" : ""}</li>`;
})
.join("");
const html = `
<html>
<title>Layering</title>
<body>
<div>Total Bounty: ${pirate.totalBounty}</div>
<ul>
${content}
</ul>
<div>
<a href="?direction=asc">List by ascending order</a>
<a href="?direction=desc">List by descending order</a>
</div>
</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アプリケーションにブラウザからアクセスすると以下のように表示されます。
MVCと主要3レイヤーの関係
さらに理解を深めるために、よく知られているMVCと主要3レイヤーの関係を図示してみます。
図のような関係にならない状況もあると思いますが、MVCでシンプルに実装すると
ControllerとViewはプレゼンテーションとドメインの処理を含む
Modelはドメインとデータソースの処理を含む
といった感じの実装になることが多いかと思います。つまり、MVCではドメインレイヤーを明示的に分離していないことがわかります。
ちなみにドメインレイヤーを分離していないことが一概に悪いということはありません。ドメインレイヤーは定義があいまいになりやすい傾向があります。対してMVCは「こういうものをここに書けばいいのね、ふーん」というなんとなくの理解がしやすいです。Webサービスの初期など実装スピードが求められるような場面では、このような特徴のおかげで勢いで実装しやすいという利点にもなります。
ドメインレイヤー
ここまでの説明でなんとなく想像がついたと思いますが、ドメインレイヤーにどのような処理を含めるべきかを考えるのは最も難しい作業です。
ドメインレイヤーを適切に実装できていない簡単な例を示してみます。
先ほどのプレゼンテーションレイヤーのコードで "乗組員が危険かどうか" の判定処理を利用していた箇所を抜粋したものです。
const content = crews
.map((crew: Crew) => {
return `<li>${crew.name}${crew.isDanger() ? ' (Danger)' : ''}</li>`
})
.join('');
以下のように "isDanger()" というメソッドを使うのではなく、判定する式を直接プレゼンテーションレイヤーに書いてしまうのは、ビジネスロジックがプレゼンテーションレイヤーに表出してしまっているわかりやすい例となります。
const content = crews
.map((crew: Crew) => {
return `<li>${crew.name}${crew.reward >= 1_000_000_000 ? ' (Danger)' : ''}</li>`
})
.join('');
またドメインレイヤーが適切に実装できているかどうかを判断するための良いヒントがあります。プレゼンテーションレイヤーのいろいろな箇所で計算処理が重複し始めているなら、ビジネスロジックがプレゼンテーションレイヤーに表出していることを疑ってみるのがよさそうです。
依存性逆転
先ほど例示したコード例のソースコードの依存関係は以下のようになっています。
しかし、Webアプリケーションにおいて一番重要なものはドメインレイヤーにあるビジネスロジックであり、その他のレイヤーがドメインレイヤーに依存する形、すなわち、ドメインレイヤーを中心とした構成が好ましいのではないかという話があります。
そのような思想に根付いたアーキテクチャはいろいろありますが、この本では ヘキサゴナルアーキテクチャ というものが紹介されています。
主要3レイヤーでそれを実現する場合、以下のような感じになりそうです。
このような依存関係を実現するように、先ほどのコードを変更したものを例示してみます。
まずはドメインレイヤーです。他のレイヤーに依存しないような実装になっています。データソースレイヤーに提供してもらいたいものを指定するために、ドメインレイヤーで "IDataSource" というインターフェースを定義するようにして、 "constructor" でデータソースレイヤーのクラスを渡せるようにしています。これによりドメインレイヤーとデータソースレイヤーの依存関係を逆転させることができます。
// domain.ts
export class Crew {
constructor(
private id: number,
public name: string,
public bounty: bigint,
) {}
isDanger() {
return this.bounty >= 1_000_000_000;
}
}
export interface IDataSource {
list: (isAsc: boolean) => Promise<Crew[]>;
}
export interface Pirate {
totalBounty: bigint;
crews: Crew[];
}
export class Domain {
dataSource: IDataSource;
constructor(dataSource: IDataSource) {
this.dataSource = dataSource;
}
async listStrawHatPirates(isAsc: boolean): Promise<Pirate> {
const crews = await this.dataSource.list(isAsc);
const totalBounty = crews.reduce(
(sum: bigint, c: Crew) => sum + c.bounty,
BigInt(0),
);
return {
totalBounty,
crews,
};
}
}
データソースレイヤーはドメインレイヤーにある "Crew" と "IDataSource" に依存する形で実装されています。
// data_source.ts
import { client } from "./postgres_client.ts";
import { Crew, IDataSource } from "./domain.ts"; // ドメインレイヤーに依存
interface Row {
id: number;
name: string;
bounty: bigint;
}
export const DataSource: IDataSource = class {
static async list(isAsc: boolean): Promise<Crew[]> {
const sql = `
SELECT id, name, bounty
FROM crews
ORDER BY id ${isAsc ? "ASC" : "DESC"}
`;
const result = await client.queryObject<Row>(sql);
return result.rows.map(({ id, name, bounty }: Row) =>
new Crew(id, name, bounty)
);
}
};
プレゼンテーションレイヤーは先ほどのコード例と比較して依存関係の変化はありません。ただし、ドメインレイヤーのインスタンスを "constructor" に渡せるように変更されています。
// presentation.ts
import { Crew, Domain } from "./domain.ts"; // ドメインレイヤーに依存
export class Presentation {
constructor(private domain: Domain) {
this.handler = this.handler.bind(this);
}
async handler(request: Request): Promise<Response> {
const url = new URL(request.url);
const isAsc = url.searchParams.get("direction") != "desc";
const pirate = await this.domain.listStrawHatPirates(isAsc);
const content = pirate.crews
.map((crew: Crew) => {
return `<li>${crew.name}${crew.isDanger() ? " (Danger)" : ""}</li>`;
})
.join("");
const html = `
<html>
<title>Layering</title>
<body>
<div>Total Bounty: ${pirate.totalBounty}</div>
<ul>
${content}
</ul>
<div>
<a href="?direction=asc">List by ascending order</a>
<a href="?direction=desc">List by descending order</a>
</div>
</body>
</html>
`;
return new Response(html, {
status: 200,
headers: { "content-type": "text/html; charset=utf-8" },
});
}
}
メインファイルで「どのデータソースを利用するか」という部分を指定するようにして、ドメインレイヤーとプレゼンテーションレイヤーを利用できるようにします。
// main.ts
import { serve } from "./deps.ts";
import { DataSource } from "./lib/data_source.ts";
import { Domain } from "./lib/domain.ts";
import { Presentation } from "./lib/presentation.ts";
const port = 8080;
const domain = new Domain(DataSource);
const presentation = new Presentation(domain);
console.log(`HTTP webserver running. Access it at: http://localhost:8080/`);
await serve(presentation.handler, { port });
このようにすることで、ドメインレイヤーを中心とするソースコードの依存関係を実現できました。
このような依存関係を実現することで、以下のような恩恵が得られます。
ドメインレイヤーやプレゼンテーションレイヤーのユニットテストで、実際にPostgreSQLを利用しなくてもテストできるようになる
データソースレイヤーの実装が変化しても、ドメインレイヤーで指定したインターフェースが満たされている限りはドメインレイヤーのコードを変更する必要がなくなる
データソースレイヤーに依存しないようにしたドメインレイヤーのテストは以下のようにできます。
// domain_test.ts
import { assertEquals } from "../dev_deps.ts";
import { Crew, Domain, IDataSource } from "./domain.ts";
Deno.test("Domain", async (t) => {
const crews = [
new Crew(1, "Sanji", BigInt(330_000_000)),
new Crew(2, "Chopper", BigInt(100)),
];
const TestDataSource: IDataSource = class {
static list(_isAsc: boolean): Promise<Crew[]> {
return Promise.resolve(crews);
}
};
const domain = new Domain(TestDataSource);
await t.step("listStrawHatPirates", async () => {
const expected = {
totalBounty: BigInt(330_000_100),
crews,
};
const isAsc = true;
assertEquals(await domain.listStrawHatPirates(isAsc), expected);
});
});
ここで提示していないファイルも含めて、すべてのコードを確認したい方は こちら をご参照ください。
さいごに
今回はChapter 1. Layeringについての紹介をしました。
説明が不足していたり、わかりにくいようなところがありましたら、お気軽にご連絡いただければと思います。
次回はChapter 2. Organizing Domain Logicを紹介します。どうぞよろしくお願いします。
この記事が気に入ったらサポートをしてみませんか?