Repositoryがあったらクリーンアーキテクチャ?──クリーンアーキテクチャの本質
1.概要
この記事はクリーンアーキテクチャ(以後CA)について筆者の以下の考えを記載している。
- CAの本質はドメインロジックをEntityとして分離すること
- RepositoryはEntityの保存とその復元が目的
- RepositoryはEntityの存在が前提
- RepositoryというクラスがDBアクセスを担当しているだけではCAと呼べない
書き物という特性上、記事全体を通して断定口調で書いてますが、ただの一意見として受け止めていただけると嬉しいです。
2.これってクリーンアーキテクチャ?
いつぞやと比べたら落ち着いたが、日夜CA論議は話題に事欠かない。
- 「Repository層を切ってクリーンアーキテクチャな開発しています」
- 「フロントエンド開発にクリーンアーキテクチャを導入しました!」
- 「クリーンアーキテクチャって意味なくね?形式に囚われすぎ」...
おそらくCAが色々と議論されるのは「たくさんのかっこいい名称の部品がイケてる図と共にある」からだ。
"図” が先に独り歩きし、本質が後回しになってしまっている。
CAと聞いて「外周にDBがあって、次にGateways、その中にUsecase、真ん中にEntity」のように考えるならこの図は忘れた方が良い。
あなたが本質的に覚えるべきなのは「真ん中にEntity」唯一これだけだ。
なぜなら、
この中で必ず存在するのは Entity (+Usecase) だけであり、他は状況に応じて必要なクラスを作るからだ。
3.クリーンアーキテクチャの本質
CAの定義を答えろ!と私は問われたらこう返すだろう。
「ドメインロジックをEntityに閉じ込めて、Entityは何にも依存しないこと」
Repositoryは?Presenterは?となるかも知れないが、それらはあくまで定義から導き出される定理でしかない。
3.1 ドメインロジックとは
定義に出てきたドメインロジックの意味から確認しよう。
ドメインロジックとはあなたが作ろうとするシステム独自のルールである。
例えば学校の部活動を管理するシステムを作りたいとする。
部活には以下のルールがある
- メンバーは3人以上
- 顧問の教師が必須
- 活動日時、活動場所は事前に申請を行う
このルールそのものがドメインロジックだ。
3.2 "閉じ込める"とは
次に"閉じ込める"について確認しよう。
これは実際のコードを見るのが早いだろう。例として部活Entityを作成しよう。
(Javaっぽい疑似コード)
public class Club {
private String clubName;
private List<Student> members;
private Teacher advisor;
// コンストラクタ
public static Club create(String clubName, List<Student> members, Teacher advisor) {
// チェック1:部員が三名以下はエラー
if (members == null || members.size() < 3) {
throw new IllegalArgumentException("部員は最低 3 名必要です");
}
// チェック2:顧問の先生がいない場合はエラー
if (advisor == null) {
throw new IllegalArgumentException("顧問の教師が必要です");
}
return new Club(clubName, members, advisor);
}
}
先ほどの1.と2.のルールがClub Entityに閉じ込められただろう。(3.は4.3.で後述)
Clubクラスを生成するにはコンストラクタ通してインスタンス化するため、1.と2.条件を満たすインスタンスしか作成できない。
このようにすれば「メンバーが2人」の部活や、「顧問の先生がいない」部活が新規に作成されることがなくなる。
これがCAの本質なのだ。
ドメインのルールをEntityに閉じ込め、ルールを違反した状態は作成不能にする。
そしてドメインのルールを閉じ込めたオブジェクトを適切に再利用すれば、ドメインルールは1箇所のコードに集約される。
つまりロジックの共通化がなされ、保守性が格段に向上するのだ。
ではこれが本質としたら、RepositoryやPresenterとは何なんだろうか?
次は本質以外の例として、Repositoryを上げながらそれらは必要に応じて作成するクラスと論ずる理由を見ていこう。
4.Repositoryとは?
Repositoryとは何だろう?
「DBに保存するためのクラス」「永続化をするためのクラス」などが聞こえてきそうだ。
もちろん正解だ。しかし目的語を入れたらもっと正確になる。
CAの文脈での目的語は「Entity」である。
再掲すると「RepositoryはEntityを永続化するためのクラス」がより正確な表現だろう。
4.1 Entityが先、Repositoryは後
「RepositoryはEntityを永続化するためのクラス」
この言葉から分かる通り、Entityが存在しなければRepositoryは存在し得ない。
Entityが主で、Repositoryが従なのだ。
だからEntityが定義で、Repositoryは定理だと私は表現するし、Repository(Gateway)は図でEntityの外側に配置されているのだ。
RepositoryとGateway
ちなみにCAで有名な同心円の図にはRepositoryという用語はない。
実を言うとこの図は具体的な設計方法を語っているのではなく、CAの設計理念をサンプルのクラス名と一緒に記載しているだけである。
この図では、DBと近い位置の緑のエリアにGatewayと記載されている。
CAでは外部の仕組みとドメインロジック(Entity)がやり取りするための変換層を「Interface Adaptor」と命名している(緑の凡例)。
Gatewayは外部サーバとの変換層としてのサンプルクラスとして記載されているだけである。
Repositoryというクラスを作ったのなら(命名が普通なら)DB用のInterface Adaptor層のクラスとなる。
ちなみにRepositoryとは、ドメイン駆動設計(通称DDD)という設計手法(兼開発手法)で出現するクラスやPoEAA本で出現する設計パターンである。
DDDだが、CAの設計理念を満たしている具体的な設計手法(兼開発手法)である。
この設計手法では、DB用のInterface Adaptor層のクラスとしてRepositoryが存在する。
そのためここではDDDのRepositoryを、CAのDB用のInterface Adaptor層のクラスとして採用したとして話を進めている。
4.2 「Entity は何にも依存するな」から逆算された層
CAには「Entity(ドメインロジック)は何にも依存するな!」という制約もある。
この制約を守ると必然的にEntityは、メモリ上での演算で完結するオブジェクトとなる。
その場合、以下のようなクラスが必要となる。
- Interface Adaptor層:ドメインロジックと外部の仕組みのやり取りを肩代わりする層
- Repository(DBとのやり取り)
- Cotroller(Webのリクエスト、レスポンスとのやり取り)
- Presenter(IoTデバイスのモニタなどUIとのやり取り)
- Application Businness Rules層:処理の一連の流れを定義する層
- Usecase(「部活EntityをDBから取得し、部員を1名追加して保存する」など)
「Entity(ドメインロジック)は何にも依存するな!」というルールを決めるとGatewayやUsecaseといったクラスは自然と導き出されるのだ。
Entity(ドメインロジックを閉じ込めた物)を中心に起き、それに依存するクラスの例として「Controller」「Presenters」「Gateway」としている。
なので、DBとのやり取りは「Gateway」ではなく「Repository」を介しても良いし、WEB以外のプログラムであるならもっと別のクラスが緑の層に配置されているはずだ。
この図は、WEB開発でCAを採用した場合の単なる例なのだ。
だから、Entity(とUsecase)以外は自身が作成するアプリが依存するものに応じて必要なAdaptorクラスを作ることになる。
4.3 サンプルコード
以下は簡易的な例だが、要点は “引数や戻り値がEntityである” ことだ。
ドメインロジックの単位(エンティティ)でDBとやり取りしているのだ。
一応Repositoryのサンプルコードも記載する。(Javaっぽいサンプルコード)
public class ClubJdbcRepository implements ClubRepository {
private final DataSource dataSource;
public ClubJdbcRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public void save(Club club) {
try (Connection conn = dataSource.getConnection()) {
// upsert 的な処理(簡易例)
String sql = "INSERT INTO clubs(id, name, advisor_id) VALUES(?,?,?) " +
"ON CONFLICT(id) DO UPDATE SET name = EXCLUDED.name, advisor_id = EXCLUDED.advisor_id";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setObject(1, club.getId().value());
ps.setString(2, club.getName());
ps.setObject(3, club.getAdvisor().getId().value());
ps.executeUpdate();
}
// メンバー更新は別テーブルで同一トランザクション内に実装
// ...(略)
conn.commit();
} catch (SQLException ex) {
throw new InfrastructureException(ex);
}
}
@Override
public Optional<Club> findById(ClubId id) {
try (Connection conn = dataSource.getConnection()) {
String sql = "SELECT c.id, c.name, c.advisor_id, s.* FROM clubs c " +
"LEFT JOIN club_members m ON m.club_id = c.id " +
"LEFT JOIN students s ON s.id = m.student_id " +
"WHERE c.id = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setObject(1, id.value());
try (ResultSet rs = ps.executeQuery()) {
// members組み立て
List<Student> members = new ArrayList<Student>();
while (rs.Next()) {
members.add(new Student(
...
));
}
// Club組み立て
return new Club(
clubName: ,
advisor: new Teacher(
...
),
members: members
)
}
}
} catch (SQLException ex) {
throw new InfrastructureException(ex);
}
}
}
Club Entityはただのオブジェクトであり、ORMの型やフレームワークには一切依存していない点を確認してほしい。
5.まとめ
この記事の論旨である「Repositoryがあったらクリーンアーキテクチャ?」の回答はどうなるであろう。
私の答えは「違う」だ。DBアクセスは分離されているが、肝心のドメインロジックが分離されているかは不明だからだ。
この記事を書いたきっかけは「(フロントエンドで)クリーンアーキテクチャを採用したら微妙だった」の意見を見たことだ。
これについて思うことは「フロントエンドではほとんどの場合で微妙では?」である。以下が理由である。
- ルール3のようなチェックを行うには大量のデータをフロントエンドに送信が必要
- フロントでチェックしてもAPI直接コールの対策としてバックエンドでも同様のチェックが必要
フロントはユーザとの入出力を行うインターフェイスである。その薄い層に分離して再利用したいほどのドメインロジックは無いはずだ。
であれば、CAのようなドメインロジックを分離するような設計方針は過剰であり、バリデーションのみで十分ではないだろうか。
最後に再掲だが、これはあくまで筆者の意見でありこれが正しいと言うつもりはない。
考え方が違うなーなどあればコメントを記載していただければと思う。最後まで読んでいただきありがとうございました!
(普段はポンコツ口調です...マサカリはポリウレタン製でお願いします...)
Discussion