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の使い分けは、次の判断フローで決められます。
- 集約をまたぐか → Yes → QueryServiceを使います
- 集約内で完結するか → 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にします |
上記のコードサンプルではFindAllとFindByIDを複数の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 パターンの適用 |
Discussion