1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Q&A形式で学ぶ「オブジェクト設計」(7~8章)

Last updated at Posted at 2025-09-08

はじめに

最近、「オブジェクト設計スタイルガイド」を読んでいるので、本書の中で自分が疑問に感じたポイントをQ&A形式で振り返ってみたいと思います。

この記事では、各セクションの簡単な説明の後に、[Q]で疑問に感じたこと、[A]で調べてわかったこと、そして[NOTE]で覚えておきたいことをまとめています。

なお、サンプルコードにPHPを使用していますが、言語を問わず広く有効な設計手法なので、他言語でも応用できる内容になっています。

4~6章については、すでに別の記事にまとめているので、こちらもぜひご覧ください!

それでは、7〜8章を振り返りながら、一緒に「オブジェクト設計」について学んでいきましょう!

コマンドメソッドでやることを限定し、イベントを使用して二次的なタスクを実行する

次のリストは、あまりにも多くのことを行うメソッドを示しています。

このメソッドはユーザーのパスワードを変更しますが、それに関する電子メールも送信しています。

public function changeUserPassword(
  UserId $userId,
  string $plainTextPassword
): void {
  $user = $this->repository->getById($userId);
  $hashedPassword = /* ... */;
  $user->changePassword($hashedPassword);
  $this->repository->save($user);
  $this->mailer->sendPasswordChangedEmail($userId);
}

推奨される解決方法は、パスワードを変更することと、それに関するメールを送信することを結び付けるものとしてイベントを使用することです。

// ユーザーがパスワードを変更したことを、UserPasswordChangedイベントオブジェクトで表現できる。
final class UserPasswordChanged
{
  private UserId $userId;

  public function __construct(UserId $userId)
  {
    $this->userId = $userId;
  }

  public function userId(): UserId
  {
    return $this->userId;
  }
}
public function changeUserPassword(
  UserId $userId,
  string $plainTextPassword
): void {
  $user = $this->repository->getById($userId);
  $hashedPassword = /* ... */;
  $user->changePassword($hashedPassword);
  $this->repository->save($user);
-   $this->mailer->sendPasswordChangedEmail($userId);
+
+   // パスワード変更後、UserPasswordChangedイベントを発行し、ほかのサービスが対応できるようにする。
+   $this->eventDispatcher->dispatch(
+     new UserPasswordChanged($userId)
+   );
}
final class SendEmail
{
  // ...

  // SendEmailは、UserPasswordChangedイベントのイベントリスナ。
  // イベントが通知されると、このリスナはメールを送信する。
  public function whenUserPasswordChanged(
    UserPasswordChanged $event
  ): void {
    $this->mailer->sendPasswordChangedEmail($event->userId());
  }
}

SendEmail のようなイベントリスナサービスを登録できるイベントディスパッチャが必要になります。

ほとんどのフレームワークではイベントディスパッチャが利用できますし、次のような簡単なものを自分で書くこともできます。

final class EventDispatcher
{
  private array $listeners;

  public function __construct(array $listenersByType)
  {
    foreach ($listenersByType as $eventType => $listeners) {
      Assertion::string($eventType);
      Assertion::allIsCallable($listeners);
    }

    $this->listeners = $listenersByType;
  }

  public function dispatch(object $event): void
  {
    foreach ($this->listenersFor($event::class) as $listener) {
      $listener($event);
    }
  }

  private function listenersFor(string $event): array
  {
    if (isset($this->listeners[$event])) {
      return $this->listeners[$event];
    }

    return [];
  }
}

$listener = new SendEmail(/* ... */);
$dispatcher = new EventDispatcher([
  UserPasswordChanged::class => [$listener, 'whenUserPasswordChanged']
]);

// UserPasswordChangedイベントのイベントリスナとしてSendEmailを登録したので、このイベントを発行すると、SendEmail->whenUserPasswordChanged()の呼び出しがトリガされる。
$dispatcher->dispatch(new UserPasswordChanged(/* ... */));

[NOTE]
このようにイベントを使用すると、いくつかの利点があります。

  • 元のメソッドを変更することなく、さらに多くの処理を追加できます。
  • 元のオブジェクトには二次的なタスクにのみ必要な依存性は注入されないので、より疎結合になります。
  • 必要であれば、二次的なタスクの処理はバックグラウンドプロセスで扱うことができます。

ライトモデルとリードモデルを分離する

変更可能なエンティティを、変更することが許されていないクライアントに渡してはいけません。

たとえクライアントが今のところはそれを変更していなかったとしても、ある日突然変更するようになるかもしれませんし、そうなったら何が起こったのかを見つけるのは難しいでしょう。

そのため、エンティティの設計を改善するために最初にすべきことは、ライトモデルとリードモデルを分離することです。

例として PurchaseOrder エンティティを使って、どのように分離できるかを見ていきましょう。

final class PurchaseOrder
{
  private int $purchaseOrderId;
  private int $productId;
  private int $orderedQuantity;
  private bool $wasReceived;

  public function __construct()
  {
  }

  public static function place(
    int $purchaseOrderId,
    int $productId,
    int $orderedQuantity
  ): PurchaseOrder {
    $purchaseOrder = new PurchaseOrder();

    $purchaseOrder->productId = $productId;
    $purchaseOrder->orderedQuantity = $orderedQuantity;
    $purchaseOrder->wasReceived = false;

    return $purchaseOrder;
  }

  public function markAsReceived(): void
  {
    $this->wasReceived = true;
  }

  public function purchaseOrderId(): int { /* ... */}
  public function productId(): int { /* ... */ }
  public function orderedQuantity(): int { /* ... */ }
  public function wasReceived(): bool { /* ... */ }
}

現在の実装では、PurchaseOrder エンティティは、エンティティを作成・操作するメソッドと、エンティティから情報を取得するメソッドを公開しています。

次に、この企業が持っている製品の在庫数量の詳細を示すJSONエンコードされたデータ構造をレンダリングするコントローラを見てみましょう。

final class StockReportController
{
  private PurchaseOrderRepository $repository;

  public function __construct(PurchaseOrderRepository $repository)
  {
    $this->repository = $repository;
  }

  public function execute(Request $request): Response
  {
    $allPurchaseOrders = $this->repository->findAll();

    $stockReport = [];

    foreach ($allPurchaseOrders as $purchaseOrder) {
      // まだ製品が届いていないので、在庫数量に追加してはいけない。
      if (!$purchaseOrder->wasReceived()) {
        continue;
      }

      if (!isset($stockReport[$purchaseOrder->productId()])) {
        // 初めてこの製品をデータ構造に追加する。
        $stockReport[$purchaseOrder->productId()] = 0;
      }

      // 注文した(そして受け取った)数量を在庫数量に追加する。
      $stockReport[$purchaseOrder->productId()] += $purchaseOrder->orderedQuantity();
    }

    return new JsonResponse($stockReport);
  }
}

このコントローラは PurchaseOrder に何の変更も加えません。

言い換えれば、エンティティの書き込み部分には興味がなく、読み取り部分だけに興味があるのです。

クライアントが必要とする以上の振る舞いを公開することは望ましくないという事実に加え、ある製品の在庫量を調べるために、常にすべての発注書をループすることはあまり効率的ではありません。

これに対する解決策として、発注書に関する情報を取得するために使用できる新しいオブジェクトを作成することにします。

final class PurchaseOrderForStockReport
{
  private int $productId;
  private int $orderedQuantity;
  private bool $wasReceived;

  public function __construct(
    int $productId,
    int $orderedQuantity,
    bool $wasReceived
  ) {
    $this->productId = $productId;
    $this->orderedQuantity = $orderedQuantity;
    $this->wasReceived = $wasReceived;
  }

  public function productId(): int { /* ... */ }
  public function orderedQuantity(): int { /* ... */ }
  public function wasReceived(): bool { /* ... */ }
}

以下のようにすることで、新しい PurchaseOrderForStockReport オブジェクトを通じて、必要な情報のみを簡単に取得できるようになりました。

final class PurchaseOrder
{
  private int $purchaseOrderId;
  private int $productId;
  private int $orderedQuantity;
  private bool $wasReceived;

  // ...

  public function forStockReport(): PurchaseOrderForStockReport
  {
    return new PurchaseOrderForStockReport(
      $this->productId,
      $this->orderedQuantity,
      $this->wasReceived
    );
  }
}

final class StockReportController
{
  private PurchaseOrderRepository $repository;

  public function __construct(PurchaseOrderRepository $repository)
  {
    $this->repository = $repository;
  }

  public function execute(Request $request): Response
  {
    // 今はまだPurchaseOrderエンティティをロードしている。
    $allPurchaseOrders = $this->repository->findAll();

-     $stockReport = [];
-
-     foreach ($allPurchaseOrders as $purchaseOrder) {
-       if (!$purchaseOrder->wasReceived()) {
-         continue;
-       }
- 
-       if (!isset($stockReport[$purchaseOrder->productId()])) {
-         $stockReport[$purchaseOrder->productId()] = 0;
-       }
-
-       $stockReport[$purchaseOrder->productId()] += $purchaseOrder->orderedQuantity();
-     }
+     // すぐにPurchaseOrderForStockReportインスタンスに変換する。
+     $forStockReport = array_map(
+       function (PurchaseOrder $purchaseOrder) {
+         return $purchaseOrder->forStockReport();
+       },
+       $allPurchaseOrders
+     );

    // ...
  }
}

[NOTE]
先に示した解決策はまだ最適とは言えません。

なぜなら、ライトモデルにアクセスしないようにするという当初の目的を達成できていません。

ほかにも PurchaseOrderForStockReport オブジェクトがあるにもかかわらず、ユーザーにデータを表示する前に、それらをループして別のデータ構造を構築する必要があるのです。

データソースである、アプリケーションが発注書を保存するデータベースから直接リードモデル(StockReport)を作成しましょう。

final class StockReportSqlRepository implements StockReportRepository
{
  private PDO $connection;

  public function __construct(PDO $connection) {
    $this->connection = $connection;
  }

  public function getStockReport(): StockReport
  {
    $result = $this->connection->query(
      'SELECT product_id, SUM(ordered_quantity) AS quantity_in_stock
       FROM purchase_orders
       WHERE was_received = 1
       GROUP BY product_id'
    );

    $data = $result->fetchAll(PDO::FETCH_ASSOC);

    return new StockReport($data);
  }
}

final class StockReportController
{
  private StockReportSqlRepository $repository;

  public function __construct(StockReportSqlRepository $repository)
  {
    $this->repository = $repository;
  }

  public function execute(Request $request): Response
  {
-     $allPurchaseOrders = $this->repository->findAll();
-
-     $forStockReport = array_map(
-       function (PurchaseOrder $purchaseOrder) {
-         return $purchaseOrder->forStockReport();
-       },
-       $allPurchaseOrders
-     );
-
-     // ...
+     $stockReport = $this->repository->getStockReport();
+
+     // asArray()は、先ほど手動で作成したものと同じような配列を返すことが期待される。
+     return new JsonResponse($stockReport->asArray());
  }
}

ライトモデルのデータソースから直接リードモデルを作成することは、実行時のパフォーマンスという点で通常かなり効率的です。

また、開発コストやメンテナンスコストの面でも効率的です。

おわりに

今回は、「オブジェクト設計スタイルガイド」の7~8章を題材に、自分が疑問に感じたポイントを紹介しました。

イベント駆動やモデル分離といった設計の工夫を取り入れることで、保守性が高く拡張しやすいコードを実現しましょう!

なお、続きとなる9~10章の記事も公開しているので、ぜひあわせてご覧ください!

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?