mybook

アプリケーションサービス — ユースケースを実装する

「このコントローラー、300行あるんだけど」

タカシが呆れた顔でコードレビューのコメントをつけた。リナが書いた OrdersControllercreate アクションが肥大化していた。

# 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つのユースケースを実行する。

Loading diagram...

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
end

WARNING

プレゼンターはコントローラー層に置く。ドメインオブジェクト自身が to_jsonas_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

レイヤーアーキテクチャの全体像

Loading diagram...

各層の依存の方向を守ることが重要だ。ドメイン層はインフラ層に依存しない。これにより、インフラを変更してもドメインロジックは変更不要になる。

リナの気づき

「コントローラーが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の変更がドメインを壊さないようにする方法を見ていこう。