バッドプラクティスから学ぶ コマンド・クエリ責任分離(CQRS) による柔軟なシステム設計
# CQSの例:1つのクラス内にコマンドとクエリが共存 class Product attr_reader :name, :price, :stock def initialize(name, price, stock) @name = name @price = price @stock = stock end # クエリ:価格を取得する(状態を変更しない) def current_price @price end # クエリ:在庫を確認する(状態を変更しない) def in_stock? @stock > 0 end # コマンド:在庫を減らす(値を返さない) def reduce_stock(quantity) @stock -= quantity nil end end
# 同じモデルで読み取りと書き込みを行うため、最適化が困難 class Order # 複雑な集計クエリ(読み取り最適化が必要) def monthly_sales_report # 大量のデータを処理... end # トランザクション処理(書き込み最適化が必要) def process_payment # 複雑なビジネスロジック... end end
# 書き込み側:ビジネスロジックに特化 class ProductCommandService def reduce_stock(product_id, quantity) product = Product.find(product_id) product.reduce_stock(quantity) product.save # イベントを発行して読み取り側を更新 EventBus.publish(StockReducedEvent.new(product_id, quantity)) end end # 読み取り側:表示に最適化 class ProductQueryService def products_in_stock # 非正規化されたデータから高速に取得 ProductView.where("stock > 0").select(:id, :name, :price) end def low_stock_products # 集計に特化したビュー ProductStockView.where("stock < 10") end end
class UserAccount attr_reader :balance def initialize(balance = 0) @balance = balance end # 入金処理と残高確認が混ざっている def deposit(amount) @balance += amount @balance # 状態変更とクエリが混ざっている end end
class UserAccount attr_reader :balance def initialize(balance = 0) @balance = balance end # コマンド(状態変更のみ) def deposit(amount) @balance += amount end # クエリ(状態取得のみ) def current_balance @balance end end
# 書き込み側(Command側) # これはアプリケーションサービスです(ドメインサービスではありません) # アプリケーション層でコマンドを処理し、ドメインモデルを調整します class UserCommandService def register_user(command) # ドメインモデルを使用した複雑なビジネスロジック user = User.new( email: command.email, name: command.name ) # ビジネスルールの検証 raise "Invalid email" unless user.valid_email? # 永続化 user_repository.save(user) # イベントの発行 event_bus.publish( UserRegisteredEvent.new( user_id: user.id, email: user.email, registered_at: Time.now ) ) end end # 読み取り側(Query側) class UserQueryService def find_active_users # 読み取り専用の最適化されたモデル UserQuery .where(active: true) .select(:id, :email, :name, :last_login_at) .order(last_login_at: :desc) end def user_statistics # 集計に特化したビュー UserStatisticsView.first end end # 読み取り専用モデル(非正規化されたデータ) class UserQuery < ActiveRecord::Base self.table_name = 'user_queries' # 書き込み側のイベントを受けて更新される def self.handle_user_registered(event) create!( user_id: event.user_id, email: event.email, registered_at: event.registered_at, active: true ) end end
# ドメインサービスの例 class UserRegistrationPolicy def can_register?(email) # 複雑なビジネスルール !blacklisted?(email) && !duplicate?(email) && valid_domain?(email) end end
# アプローチ1: 成功/失敗のみを返す class OrderService def place_order(command) # 処理を実行 order = create_order(command) # 成功/失敗のみを返す return { success: true, order_id: order.id } rescue => e return { success: false, error: e.message } end end # アプローチ2: イベントベースの非同期処理 class OrderService def place_order(command) # コマンドIDを生成 command_id = SecureRandom.uuid # 非同期でコマンドを処理 OrderCommandHandler.perform_async(command_id, command) # コマンドIDのみを返す { command_id: command_id } end end # クライアント側でイベントを購読して結果を取得
class ShoppingCart attr_reader :items def initialize @items = [] end def add_item(item) @items << item total_price end def total_price @items.sum(&:price) end end
class ShoppingCart attr_reader :items def initialize @items = [] end # コマンド(状態変更のみ) def add_item(item) @items << item end # クエリ(状態取得のみ) def total_price @items.sum(&:price) end end
class User def update_email(new_email) self.email = new_email save && email end end
class User # コマンド(状態変更) def update_email(new_email) self.email = new_email save end # クエリ(状態取得) def current_email email end end
class Order def cancel self.status = 'cancelled' save && status end end
class Order # コマンド def cancel self.status = 'cancelled' save end # クエリ def current_status status end end
class Inventory def remove_stock(item, quantity) item.stock -= quantity item.save && item.stock end end
class Inventory # コマンド def remove_stock(item, quantity) item.stock -= quantity item.save end # クエリ def current_stock(item) item.stock end end
class BlogPost def publish self.published_at = Time.now save && published_at end end
class BlogPost # コマンド def publish self.published_at = Time.now save end # クエリ def published_date published_at end end