🏗️

DDDにCQRSを導入する前に知っておきたいこと〜誤解しやすい4つのポイントと理想的な設計〜

に公開

はじめに

DDDで設計されたプロジェクトにCQRSを導入しようとすると、さまざまな疑問が出てきます。「ユースケースとCommandの違いは何か」「QueryServiceはどの層に置くのか」「リポジトリはどうなるのか」といった点です。

この記事では、DDD × CQRSの導入時に誤解しやすいポイントを整理し、理想的なディレクトリ構成とコードサンプルをもとに解説します。


CQRSとは何か(1分でおさらい)

Command Query Responsibility Segregation(コマンドクエリ責務分離)

CQRSの核心は「情報を更新するモデルと、情報を読み取るモデルを分けられる」という考え方です。ただし、多くのシステムでCQRSは不必要なリスクと複雑性を加えることにも注意が必要です。

— Martin Fowler, CQRS

種別 役割 状態変化
Command データを変更する処理 あり
Query データを読み取る処理 なし

この2つの経路を明確に分離するアーキテクチャパターンです。


CQRSのルーツ:CQS原則

CQRSはBertrand Meyerが提唱したCQS(Command Query Separation)原則をアーキテクチャレベルに拡張したものです。CQSは個々のメソッドを「値を返す(Query)」と「状態を変える(Command)」に分ける原則です。CQRSはこの考え方をモデル全体の設計に適用します。

CQSの基本的な考え方は、オブジェクトのメソッドを2つのカテゴリに明確に分類することです。

  • Query:結果を返し、システムの状態を変更しない(副作用なし)
  • Command:システムの状態を変更するが、値を返さない

— Martin Fowler, CommandQuerySeparation


誤解1:「CQRSを導入するとユースケースがなくなる」

❌ よくある誤解

CQRSを入れる
  → ユースケースをCommandとQueryに置き換える
  → UseCaseクラスは不要になる

✅ 正しい理解

ユースケースという概念はなくなりません。 ユースケースを「書き込み系(Command)」か「読み込み系(Query)」かに振り分けるだけです。

業務要件(ユースケース)
        ↓ 分類する

  データを変える?
  ┌──────┴──────┐
  Yes            No
  ↓              ↓
Command        Query
(注文する)   (注文一覧を見る)

ユースケースが先にあって、CQRSはその実装方針の話です。


誤解2:「ユースケースの名前をCommand/Queryに変える必要がある」

❌ よくある誤解(命名)

place_order_usecase.go → place_order_command.go に名前を変える
get_order_list_usecase.go → get_order_list_query.go に名前を変える

✅ 正しい理解(命名)

名前はどちらでも問題ありません。本質は名前ではなく、中の実装ルールです。

// 名前はUseCaseのままでもCQRSとして正しい
type GetOrderListUseCase struct {
	queryService OrderQueryService
}

func (uc *GetOrderListUseCase) Execute(ctx context.Context) ([]OrderListDTO, error) {
	// Domainモデルを経由しない(Query的な実装)
	return uc.queryService.FindAll(ctx)
}

重要なのは、チームで命名規則を統一することです。 混在が一番の混乱を生みます。

❌ 混在している状態(最悪)
├── place_order_usecase.go   // なんでusecaseなの?
├── cancel_order_command.go  // なんでcommandなの?
└── get_order_list_query.go  // なんでqueryなの?

✅ usecase に統一
├── place_order_usecase.go
├── cancel_order_usecase.go
└── get_order_list_usecase.go

✅ command/query に統一
command/
├── place_order.go
└── cancel_order.go
query/
├── get_order_list.go
└── get_order_detail.go

誤解3:「Query側もRepositoryを使う」

DDDにおけるRepositoryは、集約単位でデータを扱います。つまり、Repositoryが返すデータは常にその集約の範囲内に限定されます。

RepositoryとQueryServiceの使い分けは、次の判断フローで決められます。

  1. 集約をまたぐか → Yes → QueryServiceを使います
  2. 集約内で完結するか → Yes → Repositoryでも可です(ただしDTOへの変換が複雑になるならQueryServiceを検討します)

たとえば、「注文情報+顧客名」のように複数の集約にまたがるデータが必要なケースでは、Repositoryでは非効率になります。こうしたケースではQueryServiceを使うのが適切です。

❌ よくある実装

// QueryでもRepositoryを使って集約を取得し、DTOに詰め替える
func (uc *GetOrderListUseCase) Execute(ctx context.Context) ([]OrderListDTO, error) {
	orders, err := uc.orderRepository.FindAll(ctx) // ← 集約を全件取得
	if err != nil {
		return nil, err
	}
	result := make([]OrderListDTO, 0, len(orders))
	for _, order := range orders {
		// 顧客名を取得するために別のRepositoryを呼ぶ必要がある
		customer, err := uc.customerRepository.FindByID(ctx, order.CustomerID())
		if err != nil {
			return nil, err // ← N+1問題が発生する
		}
		result = append(result, OrderListDTO{
			OrderID:      order.ID().String(),
			CustomerName: customer.Name(), // ← 別集約の情報を個別に取得...
			TotalAmount:  order.TotalAmount(),
		})
	}
	return result, nil
}

集約の境界をまたぐQueryでRepositoryを使うと、次の問題が発生します。

  • N+1問題: 注文の件数だけ顧客テーブルへのクエリが発生します
  • 集約の境界をまたぐ非効率さ: SQLなら1回のJOINで済むデータを、集約の境界を守って複数回に分けて取得しています

✅ CQRSの正しいアプローチ

Repository(集約を返す)QueryService(DTOを返す) を使い分けます。

// ✅ Command側:Repositoryを使う(従来通り)
func (uc *PlaceOrderUseCase) Execute(ctx context.Context, input PlaceOrderInput) error {
	order, err := order.Place(input.CustomerID, input.Items)
	if err != nil {
		return err
	}
	return uc.orderRepository.Save(ctx, order) // ← 集約の永続化
}

// ✅ Query側:QueryServiceを使う(CQRSの新しいやり方)
func (uc *GetOrderListUseCase) Execute(ctx context.Context) ([]OrderListDTO, error) {
	return uc.queryService.FindAll(ctx) // ← DTOを直接返す
}
Repository QueryService
返すもの 集約(DomainModel) DTO(画面用データ)
役割 集約の保存・復元 画面に必要な形でデータ取得
JOINの制約 集約の境界を守る 自由にJOINしてOK

なぜQuery側はドメイン層を経由しないのか

「すべてのデータ取得はドメインモデルを通すべき」と考える方もいますが、ドメインモデルの役割はビジネスルールの強制と整合性の保証です。読み取り処理はデータを変更しないため、ビジネスルールを通す必要がありません。集約の境界も書き込みの整合性のための仕組みであり、読み取りには制約にしかなりません。CQRSはこの判断をアーキテクチャレベルで明示するパターンです。

The thin read layer can even go directly to the database, bypassing the domain model entirely.

— Greg Young, CQRS Documents


誤解4:「QueryServiceはインフラ層に置く」

❌ よくある誤解(配置)

パターンA:QueryServiceはSQLを書くのでインフラ層に置く
  → アプリケーション層からインフラ層を直接参照する
  → 依存関係が壊れる 🚨

パターンB:Query側はドメインを経由しないので、Application層に直接SQLを書く
  → アプリケーション層がDBの実装詳細に依存する
  → DBの変更がアプリケーション層に波及する 🚨

✅ 正しい理解(配置)

これはRepositoryパターンとまったく同じ構造です。

【Repository】                    【QueryService】

domain/order/                     application/order/
└── repository.go                 └── query_service.go
      (インターフェース)                (インターフェース定義)

infrastructure/order/             infrastructure/order/
└── mysql_repository.go           └── mysql_query_service.go
      (実装:SQLを書く)                (実装:SQLを書く)

インターフェースはアプリケーション層に、実装はインフラ層に。SQLを書くクラスは必ずインフラ層です。

QueryServiceのinterface配置の判断基準

QueryServiceのinterfaceを共有で定義するか、利用側で定義するかは次の基準で判断します。

条件 方針
1つのUseCaseからしか使わない そのUseCase内でprivate interfaceとして定義します
複数のUseCaseが共有する query_service.goとして共有interfaceにします

上記のコードサンプルではFindAllFindByIDを複数のUseCaseが使うため、共有interfaceとして定義しています。

なぜRepositoryはdomain層、QueryServiceはapplication層か

  • Repositoryはドメインロジックが直接依存します。集約の復元・保存はドメインの関心事であり、ドメイン層のコードがRepositoryインターフェースを参照するため、domain層に配置します。
  • QueryServiceはドメインモデルを経由せずDTOを直接返します。ドメインロジックがQueryServiceに依存することはないため、domain層に置く必要がありません。利用するのはアプリケーション層のUseCaseだけなので、application層に配置します。
// ✅ application層:インターフェースを定義(SQLは知らない)
type OrderQueryService interface {
	FindAll(ctx context.Context) ([]OrderListDTO, error)
	FindByID(ctx context.Context, id string) (*OrderDetailDTO, error)
}

// ✅ application層:インターフェースにだけ依存
type GetOrderListUseCase struct {
	queryService OrderQueryService // ← インターフェース
}

func (uc *GetOrderListUseCase) Execute(ctx context.Context) ([]OrderListDTO, error) {
	return uc.queryService.FindAll(ctx)
}

// ✅ infrastructure層:SQLの詳細はここだけ
type MySQLOrderQueryService struct {
	db *sql.DB
}

func (s *MySQLOrderQueryService) FindAll(ctx context.Context) ([]OrderListDTO, error) {
	rows, err := s.db.QueryContext(ctx, `
		SELECT o.id, c.name, o.total, COUNT(i.id)
		FROM orders o
		JOIN customers c ON o.customer_id = c.id
		JOIN order_items i ON o.id = i.order_id
		GROUP BY o.id, c.name, o.total`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	// rows をスキャンして []OrderListDTO を返す
	return scanOrderList(rows)
}

理想的なディレクトリ構成

internal/
├── presentation/                  # ハンドラ層(Handler, Controller等)
│   └── order/
│       └── handler.go

├── application/                   # アプリケーション層
│   └── order/
│       ├── place_order.go         # Command系(Domainを通す)
│       ├── cancel_order.go        # Command系
│       ├── get_order_list.go      # Query系(Domainを通さない)
│       ├── get_order_detail.go    # Query系
│       ├── query_service.go       # QueryServiceインターフェース ← ここに置く!
│       └── dto.go                 # DTO定義

├── domain/                        # ドメイン層(DDDの核心)
│   └── order/
│       ├── order.go               # 集約ルート
│       ├── order_item.go          # エンティティ
│       ├── order_id.go            # 値オブジェクト
│       └── repository.go          # Repository I/F(Reader / Writer に分離)

└── infrastructure/                # インフラ層
    └── order/
        ├── mysql_repository.go    # Repository実装(集約の永続化)
        └── mysql_query_service.go # QueryService実装(SQLはここだけ)

コードサンプル全体像

Domain層

// domain/order/order.go(集約ルート)
package order

type Order struct {
	id         OrderID
	customerID CustomerID
	items      []OrderItem
	status     OrderStatus
}

func Place(customerID CustomerID, items []OrderItem) (*Order, error) {
	if len(items) == 0 {
		return nil, errors.New("注文には1つ以上の商品が必要です")
	}
	return &Order{
		id:         NewOrderID(),
		customerID: customerID,
		items:      items,
		status:     StatusPending,
	}, nil
}

func (o *Order) Cancel() error {
	if o.status != StatusPending {
		return errors.New("この注文はキャンセルできません")
	}
	o.status = StatusCancelled
	return nil
}

// domain/order/repository.go(インターフェース)
// Reader / Writer に分離する(Go Proverbs: "The bigger the interface, the weaker the abstraction")
type OrderReader interface {
	FindByID(ctx context.Context, id OrderID) (*Order, error)
}

type OrderWriter interface {
	Save(ctx context.Context, order *Order) error
}

Application層

// application/order/query_service.go(インターフェース)
package order

type OrderQueryService interface {
	FindAll(ctx context.Context) ([]OrderListDTO, error)
	FindByID(ctx context.Context, id string) (*OrderDetailDTO, error)
}

// application/order/dto.go(DTO定義)
type OrderListDTO struct {
	OrderID      string
	CustomerName string
	TotalAmount  int
	ItemCount    int
	Status       string
	CreatedAt    time.Time
}

// application/order/place_order.go(Command系)
type PlaceOrderUseCase struct {
	orderWriter order.OrderWriter // ← 書き込みだけに依存
}

func (uc *PlaceOrderUseCase) Execute(ctx context.Context, input PlaceOrderInput) error {
	items := make([]order.OrderItem, 0, len(input.Items))
	for _, i := range input.Items {
		items = append(items, order.NewOrderItem(i.ProductID, i.Quantity, i.Price))
	}
	o, err := order.Place(order.CustomerID(input.CustomerID), items)
	if err != nil {
		return err
	}
	return uc.orderWriter.Save(ctx, o)
}

// application/order/get_order_list.go(Query系)
type GetOrderListUseCase struct {
	queryService OrderQueryService // ← Application I/Fに依存
}

func (uc *GetOrderListUseCase) Execute(ctx context.Context) ([]OrderListDTO, error) {
	// DomainModelを経由しない!
	return uc.queryService.FindAll(ctx)
}

Infrastructure層

// infrastructure/order/mysql_repository.go
package order

type MySQLOrderRepository struct {
	db *sql.DB
}

func (r *MySQLOrderRepository) FindByID(ctx context.Context, id order.OrderID) (*order.Order, error) {
	row := r.db.QueryRowContext(ctx, "SELECT * FROM orders WHERE id = ?", id.String())
	// ← 集約として復元
	return reconstruct(row)
}

func (r *MySQLOrderRepository) Save(ctx context.Context, o *order.Order) error {
	_, err := r.db.ExecContext(ctx, "INSERT INTO orders ...", /* 集約の永続化 */)
	return err
}

// infrastructure/order/mysql_query_service.go
type MySQLOrderQueryService struct {
	db *sql.DB
}

func (s *MySQLOrderQueryService) FindAll(ctx context.Context) ([]apporder.OrderListDTO, error) {
	// 自由にJOINしてDTOを直接返す!集約の制約を受けない
	rows, err := s.db.QueryContext(ctx, `
		SELECT
			o.id, c.name, o.total_amount,
			COUNT(i.id), o.status, o.created_at
		FROM orders o
		JOIN customers c ON o.customer_id = c.id
		JOIN order_items i ON o.id = i.order_id
		GROUP BY o.id, c.name, o.total_amount, o.status, o.created_at`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	return scanOrderList(rows)
}

Presentation層

Handler層では、UseCaseのinterfaceを利用側で定義します。UseCaseはstructを公開するだけで、interfaceを定義しません。Go のimplicit interfaceにより、HandlerはUseCaseの具体型を知らなくてもinterfaceを満たすstructを受け取れます。

// presentation/order/handler.go
package order

// Handlerが必要なメソッドだけinterfaceとして定義する
type orderListFetcher interface {
	Execute(ctx context.Context) ([]apporder.OrderListDTO, error)
}

type orderPlacer interface {
	Execute(ctx context.Context, input apporder.PlaceOrderInput) error
}

type OrderHandler struct {
	lister orderListFetcher
	placer orderPlacer
}

依存関係のまとめ図

Command系とQuery系では依存の経路が異なります。

【Command系】
Presentation
    ↓ 依存
Application(UseCase)
    ↓ 依存
Domain(集約 + Repository I/F)
    ↑ 依存(implements)
Infrastructure(Repository実装)

【Query系】
Presentation
    ↓ 依存
Application(UseCase + QueryService I/F + DTO)
    ↑ 依存(implements)
Infrastructure(QueryService実装)

Command系はドメイン層を経由するため4層すべてが関わります。Query系はドメインモデルを経由しないため、Application層とInfrastructure層の2層で完結します。ただし、どちらの経路でも Infrastructure層が上位層のインターフェースを実装する(依存性逆転) という原則は共通です。


段階的な導入推奨

一気に全部やらず、スモールスタートが鍵です。

Step 1: UseCaseをCommand系・Query系に分類する
        → 各UseCaseにCommand/Queryの分類コメントを追加し、
          分類結果をADR等のドキュメントに記録する

Step 2: Query側でRepositoryを使わず、QueryServiceを導入
        → 目安:Query側でJOINや集約横断のデータ取得が必要になったとき
        → DBは共通、Event Sourcingなし

        💡 Queryの応答にドメインロジックの計算結果が必要な場合:
        (a) 非正規化:Command側で計算結果をDBに保存し、Query側は読むだけ
        (b) ロジックの複製:Query側にSQL等で計算ロジックを持つ(整合性リスクあり)
        (c) Domain Serviceへの依存:Query側からDomain Serviceを呼ぶ
            → CQRSの分離度は下がるが、ロジックの一元管理を優先する現実的な選択肢

Step 3: 必要になったらReadモデル用DBを分離(オプション)
        → パフォーマンス要件が出てきたとき

まとめ

誤解 正しい理解
ユースケースがなくなる ユースケースの概念は残る。Command/Queryに分類するだけ
名前をCommand/Queryに変える必要がある チームで統一すればどちらでもOK。混在がNG
Query側も常にRepositoryを使う 集約をまたぐQueryはQueryServiceを使う。単純な1件取得はRepositoryでも可
QueryServiceはインフラ層に置く I/Fはアプリケーション層、実装がインフラ層

「DB分離」や「Event Sourcing」をセットにしない軽量CQRSから始めると、むしろ「どこに何を書くか」のルールが明確になり、チームの混乱が減ります。


参考文献

内容 出典
CQRSの定義と注意点 Martin Fowler, CQRS(2011)
CQS原則(CQRSのルーツ) Martin Fowler, CommandQuerySeparation
DDDの概要 Martin Fowler, DomainDrivenDesign
Microsoft実装例(簡略化CQRS+DDD) Microsoft Learn, マイクロサービスでの簡略化された CQRS および DDD パターンの適用
GitHubで編集を提案

Discussion