Business Domain Repository Pattern(BDRパターン)実践ガイド

English

はじめに

プログラマーは長い間、リレーショナル思考とオブジェクト指向思考の間の境界に悩まされてきました。この問題は「オブジェクト・リレーショナル・インピーダンス・ミスマッチ」と呼ばれ、リレーショナルデータベースの表形式データとオブジェクト指向プログラミングの階層的オブジェクト構造との間の根本的な非互換性を指しています。

従来のORMはSQLを抽象化して見えなくすることを試みました。この抽象化により、開発者が常に感じる境界が生まれました。私たちはデータベースの力を活用しながら、オブジェクト指向の原則を維持したいのです。この2つの欲求をどう調和させるかは常に課題でした。

BDRパターンはこの境界を溶解します。

BDRパターンでは、SQLとOOPが握手します。それぞれが最も得意なことを行いながら、調和して動作します。

境界によって生じる摩擦が解消されます。もはや強制的な抽象化や、一方のパラダイムがもう一方の存在を無視する必要はありません。

エグゼクティブサマリー

BDRパターンは、オブジェクト指向プログラミングとSQLが調和して動作する新しいパラダイムを提示します。「SQL基盤によるOOP自律性」を実現し、以下を可能にします:

コア価値提案:

なぜこれが重要なのか

開発における一般的なシナリオ

複雑なビジネスロジックがコントローラー全体に散らばっている。一つの変更で複数のメソッドの修正が必要になり、テストには多数のモックオブジェクトが必要になります。

ORMを使用する際、以下のような特徴に遭遇します:

BDRパターンは異なるアプローチを提案します。

SQLとOOPの両方の強みを活用し、それぞれが得意分野で輝けるようにします。

BDRパターンのアプローチ

public function showOrderDetails(string $id): Response
{
    $order = $this->orderRepo->getOrder($id);
    return $this->render('order.html.twig', ['order' => $order]);
}

// ビジネスロジックはファクトリーに
// SQLは最適化されたクエリファイルに
// テストは独立したレイヤーで

シンプルな構造により、保守性と可読性が向上します。

問題と解決策

従来のアプローチの問題

class OrderController
{
    public function show(string $id): Response
    {
        $order = $this->orderRepo->findById($id); // 単純なデータ

        // ビジネスロジックがコントローラーに散乱 - テストの悪夢!
        $items = $this->inventoryService->checkStock($order->items);
        $tax = $this->taxCalculator->calculate($items, $order->region);
        $shipping = $this->shippingService->calculate($items, $order->region);
        $canFulfill = $this->validateOrder($items, $order->status);

        // このコントローラーのテストには6つ以上の依存関係のモックが必要!

        return $this->render('order.html.twig', compact('order', 'tax', 'shipping', 'canFulfill'));
    }
}

BDRパターンソリューション

class OrderController
{
    public function show(string $id): Response
    {
        // リポジトリが完全なドメインオブジェクトを返却
        $order = $this->orderRepo->getOrder($id);

        // コントローラーはレンダリングのみ - ビジネスロジックなし
        return $this->render('order.html.twig', ['order' => $order]);
    }
}

オブジェクトの自律性

BDRパターンは重要なことを達成します:SQL基盤による真のオブジェクト自律性

オブジェクトの自律性とSQL効率のバランスは従来困難とされていました。BDRパターンはこのバランスを実現します。ドメインオブジェクトは独自の振る舞いとデータを持つ自己完結型でありながら、その作成はSQLクエリによって効率的に支えられています。

読み取り専用でイミュータブルなドメインオブジェクト

重要なことに、BDRパターンのドメインオブジェクトは読み取り専用(Read-Only)でイミュータブル(不変)です。 これらはデータベースのある時点のスナップショットを表現します。これらのオブジェクトは:

このイミュータビリティは意図的なものであり、重要な利点をもたらします:

// 読み取り側の問いにDIの力を活用
final readonly class UserDomainObject
{
    public function __construct(
        public string $id,
        public string $name,
        public string $role,
        // ファクトリーから注入されたサービス
        private PermissionService $permissionService,
    ) {}

    // 注入されたサービスによる読み取り側の問い
    public function canEdit(Document $document): bool
    {
        // ORMエンティティでは不可能 - 外部サービスに依存
        // テスト環境:FakePermissionService(誰でも編集可能)
        // 本番環境:RealPermissionService(複雑な権限チェック)
        return $this->permissionService->canEdit($this, $document);
    }

    // 注意:save()、update()、セッターメソッドは存在しない
    // このオブジェクトは読み取り専用のスナップショット
}

BDRパターンでは、オブジェクトは単なるデータコンテナではなく、振る舞いを持つ読み取り側のドメインオブジェクトです。現在の projection やユーザー体験に関する問いに答えますが、状態変更前の最終判断は Command モデルが行います。

実装ガイド

1. リポジトリインターフェースの定義

interface OrderRepositoryInterface
{
    #[DbQuery('order_detail', factory: OrderDomainFactory::class)]
    public function getOrder(string $id): OrderDomainObject;

    #[DbQuery('active_orders', factory: OrderDomainFactory::class)]
    /** @return array<OrderDomainObject> */
    public function getActiveOrders(): array;
}

2. SQLクエリ(order_detail.sql)

SELECT
    o.id,
    o.customer_id,
    o.region,
    o.status,
    o.created_at,
    JSON_ARRAYAGG(
        JSON_OBJECT(
            'product_id', oi.product_id,
            'name', p.name,
            'quantity', oi.quantity,
            'price', oi.price,
            'current_stock', p.stock
        )
    ) as items
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.product_id
WHERE o.id = :id
GROUP BY o.id

3. ドメインファクトリーの実装

final class OrderDomainFactory
{
    public function __construct(
        private TaxCalculator $taxCalculator,
        private ShippingService $shippingService,
        private InventoryService $inventoryService,
        private BusinessRuleEngine $ruleEngine,
    ) {}

    public function factory(
        string $id,
        string $customer_id,
        string $region,
        string $status,
        string $items_json
    ): OrderDomainObject {
        $items = json_decode($items_json, true);

        // ビジネスロジックをファクトリーに集約
        $validatedItems = $this->inventoryService->validateStock($items);
        $subtotal = array_sum(array_map(fn($item) => $item['price'] * $item['quantity'], $items));
        $tax = $this->taxCalculator->calculate($validatedItems, $region);
        $shipping = $this->shippingService->calculate($validatedItems, $region);

        return new OrderDomainObject(
            id: $id,
            customerId: $customer_id,
            region: $region,
            status: $status,
            items: $validatedItems,
            subtotal: $subtotal,
            tax: $tax,
            shipping: $shipping,
            total: $subtotal + $tax + $shipping,
            canFulfill: count($validatedItems) === count($items) && $status === 'pending',
            insufficientStockItems: $this->getInsufficientStockItems($items, $validatedItems),
            ruleEngine: $this->ruleEngine,
        );
    }

    private function getInsufficientStockItems(array $original, array $validated): array
    {
        // 在庫不足商品を特定するビジネスロジック
        return array_filter($original, fn($item) =>
            !in_array($item['product_id'], array_column($validated, 'product_id'))
        );
    }
}

4. 豊富なドメインオブジェクト

final readonly class OrderDomainObject
{
    public function __construct(
        public string $id,
        public string $customerId,
        public string $region,
        public string $status,
        public array $items,                    // 在庫検証済み商品
        public float $subtotal,
        public float $tax,                      // 地域別計算済み税額
        public float $shipping,                 // 計算済み送料
        public float $total,                    // 総合計
        public bool $canFulfill,                // 読み取り側ルールの結果
        public array $insufficientStockItems,   // 在庫不足商品リスト
        // 注入された読み取り側ルールエンジン - ORMでは困難
        private BusinessRuleEngine $ruleEngine,
    ) {}

    // 読み取り側ドメインオブジェクトの振る舞い
    public function getDisplayTotal(): string
    {
        return '$' . number_format($this->total, 2);
    }

    public function hasInsufficientStock(): bool
    {
        return count($this->insufficientStockItems) > 0;
    }

    public function getTaxRate(): float
    {
        return $this->subtotal > 0 ? ($this->tax / $this->subtotal) * 100 : 0;
    }

    public function isPending(): bool
    {
        return $this->status === 'pending';
    }

    public function canShowProcessAction(): bool
    {
        return $this->canFulfill && $this->isPending();
    }

    // 注入されたサービスによる読み取り側の優先度分類
    public function getBusinessPriority(): string
    {
        // ORMエンティティでは不可能 - 外部サービスに依存
        // テスト環境:緩い閾値(例:$100以上で高優先度)
        // 本番環境:厳しい閾値(例:$10,000以上で高優先度)
        // 繁忙期:異なる閾値
        // VIP顧客:特別ルール適用
        return $this->ruleEngine->calculatePriority($this);
    }
}

3層テスト戦略:シンプルで信頼性の高いテスト

BDRパターンの重要な利点の一つは、テストがシンプルで信頼性が高くなることです。

一般的なテストの課題

テストにおける一般的な課題には以下があります:

BDRパターンはより良い方法を提供します。

なぜテストがシンプルになるのか

各レイヤーが独立しているため、それぞれを個別にテストすれば、組み合わせは自然に動作します:

  1. SQLクエリ:入力に対して正しいデータを返すか?
  2. ファクトリー:データを正しくドメインオブジェクトに変換するか?
  3. ドメインオブジェクト:読み取り側の振る舞いを正しく実装しているか?

これらが個別に正しければ、組み合わせは必然的に正しくなります。論理的構造です。

1. SQLレイヤーテスト

class OrderQueryTest extends DatabaseTestCase
{
    public function testOrderDetailQuery(): void
    {
        // データフィクスチャーを準備
        $this->insertOrder('order-1', 'customer-1', 'tokyo', 'pending');
        $this->insertOrderItem('order-1', 'product-1', 2, 1000);

        // クエリ実行
        $result = $this->executeQuery('order_detail.sql', ['id' => 'order-1']);

        // 結果検証
        $this->assertEquals('order-1', $result[0]['id']);
        $this->assertJson($result[0]['items']);
    }
}

2. ファクトリーレイヤーテスト

class OrderDomainFactoryTest extends TestCase
{
    public function testCreatesRichDomainObject(): void
    {
        // フェイク実装を使用(AIツールによる解析可能)
        $taxCalculator = new FakeTaxCalculator(['tokyo' => 0.08]);
        $shippingService = new FakeShippingService(['tokyo' => 500]);
        $inventoryService = new FakeInventoryService(['product-1' => 10]);
        $ruleEngine = new FakeBusinessRuleEngine();

        $factory = new OrderDomainFactory($taxCalculator, $shippingService, $inventoryService, $ruleEngine);

        // ファクトリーをテスト
        $order = $factory->factory(
            'order-1',
            'customer-1',
            'tokyo',
            'pending',
            '[{"product_id": "product-1", "quantity": 2, "price": 1000}]'
        );

        // ビジネスロジック結果を検証
        $this->assertEquals(2000, $order->subtotal);
        $this->assertEquals(160, $order->tax);      // 8%
        $this->assertEquals(500, $order->shipping);
        $this->assertEquals(2660, $order->total);
        $this->assertTrue($order->canFulfill);
    }
}

3. ドメインオブジェクトテスト

class OrderDomainObjectTest extends TestCase
{
    public function testDomainObjectBehavior(): void
    {
        $ruleEngine = new FakeBusinessRuleEngine();
        $order = new OrderDomainObject(
            id: 'order-1',
            customerId: 'customer-1',
            region: 'tokyo',
            status: 'pending',
            items: [['product_id' => 'p1', 'quantity' => 2]],
            subtotal: 2000,
            tax: 160,
            shipping: 500,
            total: 2660,
            canFulfill: true,
            insufficientStockItems: [],
            ruleEngine: $ruleEngine,
        );

        // 振る舞いをテスト
        $this->assertEquals('$2,660.00', $order->getDisplayTotal());
        $this->assertEquals(8.0, $order->getTaxRate());
        $this->assertTrue($order->canShowProcessAction());
        $this->assertFalse($order->hasInsufficientStock());
    }
}

各レイヤーが独立してテストされるため、統合問題は極めて稀です。これにより、複雑で脆弱な統合テストの必要性が排除されます。

実用的なパターン

ポリモーフィックドメインオブジェクト

final class UserDomainFactory
{
    public function factory(string $id, string $email, string $subscription_type): UserInterface
    {
        // ビジネスルールに基づく動的オブジェクト作成
        return match ($subscription_type) {
            'free' => new FreeUser(
                id: $id,
                email: $email,
                maxProjects: 3,
                adsEnabled: true,
            ),
            'premium' => new PremiumUser(
                id: $id,
                email: $email,
                maxProjects: 100,
                prioritySupport: true,
            ),
            'enterprise' => new EnterpriseUser(
                id: $id,
                email: $email,
                dedicatedSupport: true,
                ssoEnabled: true,
            ),
        };
    }
}

外部API統合

final class ProductDomainFactory
{
    public function __construct(
        private ExchangeRateService $exchangeRate,  // 外部API
        private ReviewService $reviewService,       // 外部API
    ) {}

    public function factory(string $id, string $name, float $price_usd): ProductDomainObject
    {
        // 外部サービスからのデータで充実化
        $priceJpy = $this->exchangeRate->convert($price_usd, 'USD', 'JPY');
        $reviews = $this->reviewService->getReviewSummary($id);

        return new ProductDomainObject(
            id: $id,
            name: $name,
            priceUsd: $price_usd,
            priceJpy: $priceJpy,
            reviewAverage: $reviews->average,
            reviewCount: $reviews->count,
            isPopular: $reviews->average >= 4.0 && $reviews->count >= 10,
        );
    }
}

キャッシュ戦略

final class CachedUserDomainFactory
{
    public function __construct(
        private CacheInterface $cache,
        private PermissionService $permissionService,
    ) {}

    public function factory(string $id, int $role_id): UserDomainObject
    {
        // 高コストな操作をキャッシュ
        $cacheKey = "permissions_role_{$role_id}";
        $permissions = $this->cache->remember($cacheKey, 3600,
            fn() => $this->permissionService->getPermissions($role_id)
        );

        return new UserDomainObject(
            id: $id,
            permissions: $permissions,
            canEdit: in_array('edit', $permissions),
            canDelete: in_array('delete', $permissions),
        );
    }
}

既存プロジェクトからの移行

ステップ1:ビジネスロジックの特定

// Before: ロジックがコントローラーに散在
class ProductController
{
    public function show($id)
    {
        $product = $this->repo->find($id);

        // このビジネスロジックを特定
        $product->finalPrice = $this->calculatePrice($product);
        $product->inStock = $this->inventory->check($product->id);
        $product->reviews = $this->reviewService->get($product->id);

        return view('product', compact('product'));
    }
}

ステップ2:ドメインファクトリーの作成

// After: ロジックをファクトリーに移動
final class ProductDomainFactory
{
    public function factory($id, $basePrice, $categoryId): ProductDomainObject
    {
        return new ProductDomainObject(
            id: $id,
            finalPrice: $this->calculatePrice($basePrice, $categoryId),
            inStock: $this->inventory->check($id),
            reviews: $this->reviewService->get($id),
        );
    }
}

ステップ3:段階的移行

  1. 新機能から始める - 新機能をBDRパターンで実装
  2. 高トラフィックエンドポイントを優先 - パフォーマンス向上の影響が大きい
  3. 既存テストカバレッジを活用 - 既存テストを活用しながら移行
  4. チーム内での知識共有 - ファクトリーパターンの利点を共有

AI時代への対応:透明性の実現

BDRパターンのもう一つの利点は、AIツールに対して透明なコードベースを作り出すことです。

従来のORMの複雑な抽象化レイヤーは、AIにとってブラックボックスでした:

BDRパターンでは、すべてが明示的です:

-- order_detail.sql - AIが読み理解できる
SELECT
    o.id,
    o.region,
    JSON_ARRAYAGG(
        JSON_OBJECT(
            'product_id', oi.product_id,
            'quantity', oi.quantity,
            'price', oi.price
        )
    ) as items
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE o.id = :id
// ファクトリー - AIが依存関係とロジックを完全理解
public function __construct(
    private TaxCalculator $taxCalculator,      // 明示的依存関係
    private ShippingService $shippingService,  // 明示的依存関係
) {}

この透明性により、AIアシスタントがコードベースを深く理解し、より正確な提案と自動化を提供できるようになります。

まとめ

BDRパターンはドメイン協調の一つの形を提示します。異なるパラダイムの橋渡しをするだけでなく、異なるメディア間の境界を溶解します。

SQL(宣言的、集合ベース)とOOP(命令的、オブジェクトベース)。異なる特性を持つこれらの技術をどう組み合わせるかは長年の課題でした。

BDRパターンはこの課題への一つのアプローチを提供します。

従来のORMがSQLを抽象化することで作り出した境界。BDRパターンはこれらを溶解し、新しい調和を作り出します。

達成される成果は:

SQLとOOPが調和して動作します。

BDRパターンでは、それぞれが自身の領域で優秀さを発揮しながら、共により大きなものを構築します。

FAQ

Q: 変更されたオブジェクトをどうやってDBに書き戻す(保存する)のですか?

A: BDR の読み取りオブジェクトを保存するのではなく、明示的な書き込み経路を使います。 BDRパターンのオブジェクトは、画面、帳票、API レスポンス、ユースケースに合わせた読み取り側の projection です。オブジェクト自身は保存しません。

  1. 現在の状態や表示可能な操作を示すために、必要なら BDR の Query モデルを読む
  2. 状態変更には Command またはアプリケーションの書き込みユースケースを呼ぶ
  3. その書き込み経路で書き込み側の不変条件を検証し、UPDATE、INSERT、DELETE、または別の書き込み手段で結果を永続化する
// Query側(BDRパターン): 今の用途に必要なprojection
$order = $this->orderRepo->getOrder($id);
if ($order->canShowProcessAction()) {
    // アプリケーションは操作を提示できる。ただし最終判断はCommand側が行う。
    $this->processOrder->execute($id, new DateTimeImmutable());
}

// processOrder は必要に応じて明示的な書き込みSQLを使う:
// UPDATE orders SET status = 'processed', processed_at = :timestamp WHERE id = :id

canShowProcessAction() は表示のための読み取り側の派生判断です。ProcessOrder は書き込み側の不変条件をあらためて検証しなければなりません。BDR は Command モデルを定義しません。Ray.MediaQuery は DML を実行できますが、それを書き込み手段として選ぶかどうかはアプリケーション側の設計です。

これはCQRS(Command Query Responsibility Segregation:コマンド・クエリ責任分離)の区別に従います:

Q: これはCQRSパターンですか?

A: BDRパターンは CQRS の Query 側に適用できます。ただし、より正確には rich read model のパターンです。 Read Repository と Write Repository を別の場所に置く、という話が中心ではありません。重要なのは、関心とモデルを分けることです。

出発点は単純です。読み取りと書き込みでは、最適なモデルが違います。

書き込み側には、ドメインの整合性を守るモデルが必要です。そこでは意図、振る舞い、不変条件、失敗理由を扱います。「この業務上の行為は実行してよいか?」に答えるモデルです。

読み取り側では、画面、帳票、API レスポンスのために、非正規化されたフラットなデータが欲しいことがよくあります。「今表示するにはどんな形が役に立つか?」に答えるモデルです。同じ Repository や Entity モデルで両方を満たそうとするから無理が生じます。

CQRS はしばしば、データベースを分ける、インフラを分ける、Repository の置き場所を分ける、といった物理的なアーキテクチャとして誤解されます。それらは有用な実装上の選択肢になることはありますが、本質ではありません。本質は、Command は業務の意思決定であり、Query は表示のための構造である、という分離です。

BDR は薄い DTO に限定されません。読み取りモデルは、派生・表示のロジックである限り、振る舞いを持てます。合計、ラベル、表示可否、読み取り側の優先度分類など、現在の projection に関する問いに答える振る舞いです。状態変更の不変条件は Command 側に置きます。

SQL はそもそもこの Query 側の性質を持っています。SELECT は JOIN、集約、計算を使って、必要な形へ結果を projection できます。その構造を永続的な正規ドメインモデルであるかのように扱う必要はありません。BDR では、SQL ファイルがその projection を定義し、ファクトリやドメインオブジェクトが PHP の型として表面を与えます。

Query モデルは使い捨てであってもかまいません。画面、帳票、API レスポンスが変われば、別の SELECT と小さな読み取りモデルを作ればよい。それは DRY 違反ではなく、CQRS の要点です。関心が違うものには、違うモデルを与えます。

Q: ファクトリーで外部APIを呼ぶと、リスト取得時に遅くなりませんか?

A: その通りです、適切な戦略なしでは。 これは本質的にN+1問題の変形です。以下は緩和のための戦略です:

1. バッチリクエスト

final class ProductDomainFactory
{
    private array $priceCache = [];

    public function factory(string $id, string $name): ProductDomainObject
    {
        // 価格はファクトリー呼び出し前にバッチで取得済み
        $price = $this->priceCache[$id] ?? $this->priceService->getPrice($id);
        return new ProductDomainObject($id, $name, $price);
    }

    public function warmPriceCache(array $productIds): void
    {
        // すべての価格を1回のAPI呼び出しで取得
        $this->priceCache = $this->priceService->getPrices($productIds);
    }
}

2. 遅延ロード

final readonly class ProductDomainObject
{
    public function __construct(
        private string $id,
        private PriceProvider $priceProvider,
    ) {}

    public function getCurrentPrice(): float
    {
        // 実際に必要な時だけ取得する。キャッシュはreadonlyオブジェクトではなくprovider側で管理する。
        return $this->priceProvider->getPrice($this->id);
    }
}

3. 戦略的データロード

// リスト表示:高コストなデータをロードしない
#[DbQuery('product_list_simple', factory: ProductListFactory::class)]
public function getProductList(): array;

// 詳細表示:外部データを含むすべてをロード
#[DbQuery('product_detail', factory: ProductDetailFactory::class)]
public function getProduct(string $id): ProductDomainObject;

重要なのは、いつどのようにデータをロードするかについて意図的であることです。ファクトリーパターンは、この戦略を完全にコントロールする力を与えます。

BEAR.Sunday 連携

BEAR.Sunday は、アプリケーションの操作を URI で参照できる ResourceObject として表す PHP アプリケーションフレームワークです。#[Embed] は、それらのリソース間の関係を宣言します。

この境界は BDR と相性が良いです。Repository は 何を問い合わせるかを宣言したまま、独立したリソースリクエストを いつ どう実行するかは BEAR.Sunday と BEAR.Async がアプリケーション境界で決めます。Repository interface と SQL file は変わりません。

参考文献