オニオンアーキテクチャでドメイン知識がSQLに入るのを防ぐ方法

オニオンアーキテクチャ でコードを構成する際に、頭を悩ませていたのがSQLにドメイン知識が入ってしまうことでした。

データをフィルタしたい場合に、SQLで取得した後Array.filterでフィルタを掛けるのはメモリも食うしパフォーマンスも悪いので現実的ではありません。

そのため、いままではしかたなくSQLのWHERE句にフィルタ条件を書いていたのですが、WHERE句に書く条件を定数としてドメイン層に定義してみればどうか、と思いついたので以下にメモを残します。

例:過去1年分の購入額が100万円以上の人を得意客として抽出する

課題

SQLで SELECT * FROM customers JOIN order ON customers.id = orders.customer_id WHERE (SELECT SUM(total) FROM orders WHERE created_at > "2024-01-24" GROUP BY cutomer_id) > 1000000 と書きたいが、そうすると「1年間の購入額が100万円以上」というドメイン知識をインフラ層に置くことになってしまう。

対策

  1. ドメイン知識の値を定数としてドメイン層に書き、それを受け取ってSQLを発行するリポジトリの型も同時に定義する。

  2. アプリケーション層でインフラ層のコードを呼び出す際にドメイン層の定数を引数に渡す。

コードに起こすと以下のようになります。


domain.ts
type CustomerId = Branded('CustomerId', number);
type Customer = {
id: CutomerId;
}
const ValuedCustomerCriteria = {
period: 'one_year',
totalAmount: 1000000,
} as const;
type ValuedCustomerCriteriaType = typeof ValuedCustomerCriteria;
type CustomerRepository = {
selectValuedCustomer: (x: ValuedCustomerCriteriaType) => Promise<Customer[]>
}

usecase.ts
import { CustomerRepository, ValuedCustomerCriteria } from './domain.ts';
const campaignForValuedCustomers = async (repo: CustomerRepository) => {
return repo.selectValuedCustomer(ValuedCustomerCriteria);
}

infra.ts
import { db } from './db';
import { CustomerRepository, ValuedCustomerCriteriaType } from './domain.ts';
export const repo: CustomerRepository = {
selectValuedCustomer: async (criteria: ValuedCustomerCriteriaType) => {
const date = calcDate(criteria.period); // one_year, two_years などから日付を得る関数
return db.sql("SELECT * FROM customers JOIN orders ON cutomers.id = orders.customer_id WHERE (SELECT SUM(total) FROM orders WHERE created_at > '?' GROUP BY cutomer_id) > ?", date, criteria.totalAmount).execute();
}
}

「購入額の合計」という部分はSQLに残ってしまっていますが、selectValuedCustomer関数はValuedCustomerCriteria 無しでは機能しないので、ドメイン層に得意客についてのドメイン知識があるということはわかるのではないかと思います。

この問題について、他に良い解決方法があればぜひ教えて下さい。

Vite@5以降 + TypeScriptでパスエイリアスを設定する方法
TanStack Router - useLocationフックの意外な振る舞いについて