アプリケーションサービス — ユースケースを実装する
「このコントローラー、300行あるんだけど」
タカシが呆れた顔でコードレビューのコメントをつけた。リナが書いた OrdersController の create アクションが肥大化していた。
# before: 300行のコントローラー(抜粋)
class OrdersController < ApplicationController
before_action :authenticate_user!
def create
# パラメータのバリデーション
unless params[:items].present?
render json: { error: '商品を選択してください' }, status: :unprocessable_entity
return
end
# 商品情報の取得
items = params[:items].map do |item|
product = Product.find_by(id: item[:product_id])
unless product
render json: { error: "商品が見つかりません: #{item[:product_id]}" }, status: :not_found
return
end
{ product: product, quantity: item[:quantity].to_i }
end
# 在庫確認
items.each do |item|
stock = Stock.find_by(product_id: item[:product].id)
if stock.nil? || stock.quantity < item[:quantity]
render json: { error: "#{item[:product].name}の在庫が不足しています" }, status: :conflict
return
end
end
# 割引計算
total = items.sum { |i| i[:product].price * i[:quantity] }
discount = if current_user.gold_member?
total * 0.1
elsif params[:coupon_code].present?
# クーポン計算...
else
0
end
# 注文作成
Order.transaction do
order = Order.create!(
user: current_user,
status: 'pending',
total: total - discount
)
items.each do |item|
OrderItem.create!(
order: order,
product: item[:product],
quantity: item[:quantity],
price: item[:product].price
)
Stock.find_by(product_id: item[:product].id)
.decrement!(:quantity, item[:quantity])
end
OrderMailer.confirmation(order).deliver_later
current_user.increment!(:loyalty_points, (total * 0.01).to_i)
end
render json: { order_id: order.id }, status: :created
rescue => e
render json: { error: e.message }, status: :internal_server_error
end
endパラメータ検証、商品取得、在庫確認、割引計算、注文作成、在庫更新、メール送信、ポイント付与——全てがコントローラーに詰め込まれている。
テストが書けない。ビジネスロジックが変わるたびにコントローラーを変更しなければならない。
アプリケーションサービスとは
アプリケーションサービス(Application Service)は、ユーザーのユースケースを実現するための調整役だ。ドメイン層のオブジェクトを組み合わせて、1つのユースケースを実行する。
INFO
アプリケーションサービスのルール: ビジネスロジックを持たない。ドメイン層のオブジェクトにビジネスロジックを委譲し、自身は「調整」と「フロー制御」のみを担う。コントローラーはHTTPの関心事のみ担当し、ビジネスロジックは一切持たない。
コマンドオブジェクト(入力の型)
ユースケースへの入力を明示的な型で表現する。
module OrderContext
module Commands
# 注文作成コマンド
class PlaceOrderCommand
attr_reader :customer_id, :delivery_address, :items, :campaign_code
def initialize(customer_id:, delivery_address:, items:, campaign_code: nil)
@customer_id = customer_id
@delivery_address = delivery_address # DeliveryAddress値オブジェクト
@items = items.freeze # [{product_id:, quantity:}, ...]
@campaign_code = campaign_code
freeze
end
def valid?
customer_id.present? && delivery_address.present? && items.present?
end
def invalid?
!valid?
end
end
# 注文確定コマンド
class ConfirmOrderCommand
attr_reader :order_id, :customer_id
def initialize(order_id:, customer_id:)
@order_id = order_id
@customer_id = customer_id
freeze
end
def valid?
order_id.present? && customer_id.present?
end
end
# 注文キャンセルコマンド
class CancelOrderCommand
attr_reader :order_id, :customer_id, :reason
def initialize(order_id:, customer_id:, reason:)
@order_id = order_id
@customer_id = customer_id
@reason = reason
freeze
end
def valid?
order_id.present? && customer_id.present? && reason.present?
end
end
end
end結果オブジェクト(出力の型)
ユースケースの成功・失敗を型で表現する。例外に頼らず、結果を値として返す。
module OrderContext
class UseCaseResult
attr_reader :success, :data, :reason, :message
def self.success(**data)
new(success: true, data: data)
end
def self.failure(reason:, message:)
new(success: false, reason: reason, message: message)
end
def initialize(success:, data: {}, reason: nil, message: nil)
@success = success
@data = data
@reason = reason
@message = message
freeze
end
def success?
@success
end
def failure?
!@success
end
def order
data[:order]
end
def discount_result
data[:discount_result]
end
end
end注文確定ユースケースの実装
module OrderContext
class ConfirmOrderUseCase
def initialize(
order_repository:,
stock_reservation_service:,
event_bus: EventBus
)
@order_repository = order_repository
@stock_reservation_service = stock_reservation_service
@event_bus = event_bus
end
def call(command)
raise InvalidCommand, "不正なコマンド" unless command.valid?
# 1. 集約の取得
order = @order_repository.find(command.order_id)
# 2. 権限チェック(アプリケーション層の責務)
raise Unauthorized, "この操作は許可されていません" unless order.customer_id == command.customer_id
# 3. ドメインオブジェクトへの操作委譲(ビジネスロジックはここにない)
order.confirm!
# 4. 在庫引き当て(別集約への操作はユースケース層で調整)
@stock_reservation_service.reserve_for_order(order)
# 5. 永続化(トランザクション管理)
ApplicationRecord.transaction do
@order_repository.save(order)
record_outbox_events(order.domain_events)
end
# 6. イベント発行(トランザクション外)
publish_and_clear_events(order)
# 7. 成功結果を返す
UseCaseResult.success(order: order)
rescue InvalidCommand => e
UseCaseResult.failure(reason: :invalid_command, message: e.message)
rescue OrderNotFound
UseCaseResult.failure(reason: :not_found, message: "注文が見つかりません")
rescue Unauthorized => e
UseCaseResult.failure(reason: :unauthorized, message: e.message)
rescue InvalidStateTransition => e
UseCaseResult.failure(reason: :invalid_state, message: e.message)
rescue EmptyOrder => e
UseCaseResult.failure(reason: :empty_order, message: e.message)
rescue InventoryContext::InsufficientStock => e
UseCaseResult.failure(reason: :insufficient_stock, message: e.message)
rescue OrderPersistenceError => e
UseCaseResult.failure(reason: :persistence_error, message: "注文の保存に失敗しました")
end
private
def record_outbox_events(events)
events.each do |event|
EventOutbox.create!(
event_id: event.event_id,
event_type: event.event_type,
payload: event.to_json
)
end
end
def publish_and_clear_events(order)
order.domain_events.each { |event| @event_bus.publish(event) }
order.clear_events
end
end
end注文作成ユースケース(より複雑な例)
module OrderContext
class PlaceOrderUseCase
def initialize(
order_repository:,
customer_repository:,
catalog_adapter:,
stock_reservation_service:,
discount_service:,
event_bus: EventBus
)
@order_repository = order_repository
@customer_repository = customer_repository
@catalog_adapter = catalog_adapter
@stock_reservation_service = stock_reservation_service
@discount_service = discount_service
@event_bus = event_bus
end
def call(command)
return UseCaseResult.failure(
reason: :invalid_command,
message: "必須パラメータが不足しています"
) if command.invalid?
# 1. 顧客情報の取得
customer = @customer_repository.find(command.customer_id)
# 2. 商品情報をカタログから取得(ACL経由)
orderable_products = command.items.map do |item|
product = @catalog_adapter.find_orderable_product(item[:product_id])
raise ProductNotAvailable, "商品が注文できません" unless product.available?
{ product: product, quantity: item[:quantity] }
end
# 3. 注文の組み立て(ドメインオブジェクトへの委譲)
order = build_order(command, orderable_products)
# 4. 割引計算(ドメインサービスへの委譲)
campaign_code = load_campaign_code(command.campaign_code)
discount_result = @discount_service.calculate(
order: order,
customer: customer,
campaign_code: campaign_code
)
# 5. 割引を注文に適用
order.apply_pricing(
discount_result: discount_result
)
# 6. 在庫の仮押さえ
@stock_reservation_service.reserve_for_order(order)
# 7. 永続化
ApplicationRecord.transaction do
@order_repository.save(order)
record_outbox_events(order.domain_events)
end
publish_and_clear_events(order)
UseCaseResult.success(
order: order,
discount_result: discount_result
)
rescue CustomerNotFound
UseCaseResult.failure(reason: :customer_not_found, message: "顧客が見つかりません")
rescue ProductNotAvailable => e
UseCaseResult.failure(reason: :product_not_available, message: e.message)
rescue CatalogAdapter::ProductNotFound => e
UseCaseResult.failure(reason: :product_not_found, message: e.message)
rescue InventoryContext::InsufficientStock => e
UseCaseResult.failure(reason: :insufficient_stock, message: e.message)
end
private
def build_order(command, orderable_products)
order = Order.new(
id: SecureRandom.uuid,
customer_id: command.customer_id,
delivery_address: command.delivery_address
)
orderable_products.each do |item|
order.add_item(
product_id: item[:product].id,
product_name: item[:product].name,
unit_price: item[:product].unit_price,
quantity: item[:quantity]
)
end
order
end
def load_campaign_code(code_string)
return nil unless code_string.present?
PromotionContext::CampaignCodeRepository.new.find_active(code_string)
end
def record_outbox_events(events)
events.each do |event|
EventOutbox.create!(event_id: event.event_id, event_type: event.event_type, payload: event.to_json)
end
end
def publish_and_clear_events(order)
order.domain_events.each { |event| @event_bus.publish(event) }
order.clear_events
end
end
endコントローラーは薄く保つ
アプリケーションサービスを使うことで、コントローラーは劇的に薄くなる。
class Api::V1::OrdersController < ApplicationController
before_action :authenticate_customer!
# 注文作成: コントローラーはHTTPの関心事のみ
def create
command = OrderContext::Commands::PlaceOrderCommand.new(
customer_id: current_customer.id,
delivery_address: build_delivery_address(order_params[:delivery_address]),
items: order_params[:items],
campaign_code: order_params[:campaign_code]
)
result = place_order_use_case.call(command)
if result.success?
render json: {
order: OrderPresenter.new(result.order).to_h,
discount: DiscountPresenter.new(result.discount_result).to_h
}, status: :created
else
render json: error_response(result), status: http_status_for(result.reason)
end
end
# 注文確定
def confirm
command = OrderContext::Commands::ConfirmOrderCommand.new(
order_id: params[:id],
customer_id: current_customer.id
)
result = confirm_order_use_case.call(command)
if result.success?
render json: { order: OrderPresenter.new(result.order).to_h }, status: :ok
else
render json: error_response(result), status: http_status_for(result.reason)
end
end
# 注文キャンセル
def cancel
command = OrderContext::Commands::CancelOrderCommand.new(
order_id: params[:id],
customer_id: current_customer.id,
reason: params[:reason] || 'お客様都合によるキャンセル'
)
result = cancel_order_use_case.call(command)
if result.success?
render json: { order: OrderPresenter.new(result.order).to_h }, status: :ok
else
render json: error_response(result), status: http_status_for(result.reason)
end
end
private
def order_params
params.require(:order).permit(
:campaign_code,
delivery_address: [:postal_code, :prefecture, :city, :street, :building, :recipient_name],
items: [:product_id, :quantity]
)
end
def build_delivery_address(addr_params)
OrderContext::DeliveryAddress.new(
postal_code: addr_params[:postal_code],
prefecture: addr_params[:prefecture],
city: addr_params[:city],
street: addr_params[:street],
building: addr_params[:building],
recipient_name: addr_params[:recipient_name]
)
rescue ArgumentError => e
raise ActionController::ParameterMissing, e.message
end
def place_order_use_case
@place_order_use_case ||= OrderContext::PlaceOrderUseCase.new(
order_repository: Container.order_repository,
customer_repository: Container.customer_repository,
catalog_adapter: Container.catalog_adapter,
stock_reservation_service: Container.stock_reservation_service,
discount_service: Container.discount_service,
event_bus: EventBus
)
end
def confirm_order_use_case
@confirm_order_use_case ||= OrderContext::ConfirmOrderUseCase.new(
order_repository: Container.order_repository,
stock_reservation_service: Container.stock_reservation_service,
event_bus: EventBus
)
end
def cancel_order_use_case
@cancel_order_use_case ||= OrderContext::CancelOrderUseCase.new(
order_repository: Container.order_repository,
stock_reservation_service: Container.stock_reservation_service,
event_bus: EventBus
)
end
def error_response(result)
{ error: { code: result.reason, message: result.message } }
end
def http_status_for(reason)
case reason
when :not_found, :product_not_found, :customer_not_found then :not_found
when :unauthorized then :forbidden
when :invalid_state, :empty_order, :invalid_command then :unprocessable_entity
when :insufficient_stock then :conflict
when :product_not_available then :gone
else :internal_server_error
end
end
endコントローラーは約70行。HTTP層の関心事(パラメータの解析、レスポンスの形成、認証、HTTPステータスコード)のみを担い、ビジネスロジックは一切持たない。
プレゼンター(出力変換)
ドメインオブジェクトをAPIレスポンスに変換する専用クラス。
class OrderPresenter
def initialize(order)
@order = order
end
def to_h
{
id: @order.id,
status: @order.status.to_s,
total_amount: present_money(@order.total_amount),
item_count: @order.item_count,
items: @order.order_items.map { |item| present_item(item) },
delivery_address: present_address(@order.delivery_address),
confirmed_at: @order.confirmed_at&.iso8601,
cancelled_at: @order.cancelled_at&.iso8601,
cancellation_reason: @order.cancellation_reason
}
end
private
def present_item(item)
{
product_id: item.product_id,
product_name: item.product_name,
unit_price: present_money(item.unit_price),
quantity: item.quantity,
subtotal: present_money(item.subtotal)
}
end
def present_address(address)
return nil unless address
{
postal_code: address.formatted_postal_code,
full_address: address.full_address,
recipient_name: address.recipient_name
}
end
def present_money(money)
return nil unless money
{
amount: money.to_i,
currency: money.currency.to_s,
display: money.to_s
}
end
end
class DiscountPresenter
def initialize(discount_result)
@result = discount_result
end
def to_h
return {} unless @result
{
original_amount: present_money(@result.subtotal),
discounts: @result.discounts.map do |discount|
{
type: discount.type,
amount: present_money(discount.amount),
description: discount.description
}
end,
total_discount: present_money(@result.total_discount_amount),
shipping_fee: present_money(@result.shipping_fee),
free_shipping: @result.free_shipping?,
final_amount: present_money(@result.final_amount)
}
end
private
def present_money(money)
{ amount: money.to_i, currency: money.currency.to_s, display: money.to_s }
end
endWARNING
プレゼンターはコントローラー層に置く。ドメインオブジェクト自身が to_json や as_json を実装してしまうと、表示の関心事(どのフィールドを見せるか、日付のフォーマットはどうするか)がドメイン層に漏れる。複数のAPIバージョンがある場合も、プレゼンターを変えるだけで対応できる。
ユースケースのテスト
インメモリリポジトリを使った高速テスト。
RSpec.describe OrderContext::ConfirmOrderUseCase do
let(:order_repository) { OrderContext::InMemoryOrderRepository.new }
let(:stock_service) { instance_double(OrderContext::StockReservationService, reserve_for_order: nil) }
let(:event_bus) { instance_double(EventBus, publish: nil) }
let(:use_case) do
described_class.new(
order_repository: order_repository,
stock_reservation_service: stock_service,
event_bus: event_bus
)
end
let(:customer_id) { 'customer-1' }
let(:order) do
o = OrderContext::Order.new(
id: 'order-1',
customer_id: customer_id,
delivery_address: build_address
)
o.add_item(
product_id: 'product-1',
product_name: 'りんご',
unit_price: SharedKernel::Money.new(amount: 500, currency: :jpy),
quantity: 2
)
order_repository.save(o)
o
end
describe '正常系: 注文の確定' do
it '成功を返す' do
command = OrderContext::Commands::ConfirmOrderCommand.new(
order_id: order.id, customer_id: customer_id
)
result = use_case.call(command)
expect(result).to be_success
end
it '注文が確定状態になる' do
command = OrderContext::Commands::ConfirmOrderCommand.new(
order_id: order.id, customer_id: customer_id
)
use_case.call(command)
saved = order_repository.find(order.id)
expect(saved.status).to eq(OrderContext::OrderStatus::CONFIRMED)
end
it 'OrderConfirmedイベントが発行される' do
command = OrderContext::Commands::ConfirmOrderCommand.new(
order_id: order.id, customer_id: customer_id
)
use_case.call(command)
expect(event_bus).to have_received(:publish).with(
an_instance_of(OrderContext::OrderConfirmed)
)
end
end
describe '異常系' do
it '存在しない注文はnot_foundで失敗' do
command = OrderContext::Commands::ConfirmOrderCommand.new(
order_id: 'non-existent', customer_id: customer_id
)
result = use_case.call(command)
expect(result).to be_failure
expect(result.reason).to eq(:not_found)
end
it '他の顧客の注文はunauthorizedで失敗' do
command = OrderContext::Commands::ConfirmOrderCommand.new(
order_id: order.id, customer_id: 'other-customer'
)
result = use_case.call(command)
expect(result).to be_failure
expect(result.reason).to eq(:unauthorized)
end
it '在庫不足はinsufficient_stockで失敗' do
allow(stock_service).to receive(:reserve_for_order)
.and_raise(InventoryContext::InsufficientStock.new(
product_id: 'product-1', requested: 2, available: 1
))
command = OrderContext::Commands::ConfirmOrderCommand.new(
order_id: order.id, customer_id: customer_id
)
result = use_case.call(command)
expect(result).to be_failure
expect(result.reason).to eq(:insufficient_stock)
end
end
def build_address
OrderContext::DeliveryAddress.new(
postal_code: '1500001',
prefecture: '東京都',
city: '渋谷区',
street: '神宮前1-1-1',
recipient_name: 'テスト 太郎'
)
end
endレイヤーアーキテクチャの全体像
各層の依存の方向を守ることが重要だ。ドメイン層はインフラ層に依存しない。これにより、インフラを変更してもドメインロジックは変更不要になる。
リナの気づき
「コントローラーが20行になった」
以前は OrdersController#create が300行だったが、アプリケーションサービスを導入してから約70行になった。ビジネスロジックのテストも書きやすくなり、コントローラーのテストはHTTPのテストだけに集中できるようになった。
さらに、ユースケースを他のインターフェースから呼び出せるようになった。
# WebAPI(コントローラー)から
result = ConfirmOrderUseCase.new(...).call(command_from_http)
# バックグラウンドジョブから
result = ConfirmOrderUseCase.new(...).call(command_from_job)
# CLIから(管理コマンド)
result = ConfirmOrderUseCase.new(...).call(command_from_cli)
# すべて同じユースケースを使う。インターフェースが変わってもロジックは変わらないまとめ
- アプリケーションサービス(UseCase) = ユースケースを実現する調整役、ビジネスロジックは持たない
- コントローラー = HTTP層のみ(パラメータ解析・レスポンス形成・認証)
- コマンドオブジェクト = ユースケースへの入力を型で表現
- 結果オブジェクト = 成功/失敗を型で表現(例外を乱用しない)
- プレゼンター = ドメインオブジェクトをAPIレスポンスに変換
次の章では、コンテキスト間の関係をより詳しく見る「コンテキストマップの実践」を学ぶ。外部APIの変更がドメインを壊さないようにする方法を見ていこう。