mybook

Stage 5: アーキテクチャ原則 — コンポーネント設計

クラスの次はコンポーネント

「SOLID原則はクラスレベルの話だった。でも、実際のアプリはクラスが何百、何千と集まる。」

マイがホワイトボードに向かった。「今日はズームアウトして、コンポーネント(モジュール)間の設計を考える。」

「コンポーネントというのは?」

「関連するクラスをまとめたグループ。Railsだったらapp/models/全体、あるいは決済に関わるファイル群、通知に関わるファイル群など。」

凝集度: 「まとまり」の強さ

「まず凝集度。これはクラスやモジュール内の要素がどれだけ関連し合っているかの指標。」

低い凝集度(悪い例)

# 凝集度が低いクラス: 何でも屋
class UtilityHelper
  def format_date(date)
    date.strftime("%Y/%m/%d")
  end
 
  def calculate_tax(price)
    price * 0.1
  end
 
  def send_slack_message(channel, text)
    SlackClient.post(channel: channel, text: text)
  end
 
  def generate_pdf(content)
    Prawn::Document.generate { |pdf| pdf.text(content) }
  end
 
  def validate_email(email)
    email.match?(/\A[^@\s]+@[^@\s]+\z/)
  end
end

UtilityHelperを変更する理由が5つある。フォーマット変更、税率変更、Slack API変更、PDF変更、バリデーション変更。これは低い凝集度の典型。」

高い凝集度(良い例)

# 凝集度が高いクラス群: 各クラスが一つのテーマにフォーカス
module TaxCalculator
  CONSUMPTION_TAX_RATE = 0.10
  REDUCED_TAX_RATE     = 0.08
 
  def self.standard_tax(price)
    (price * CONSUMPTION_TAX_RATE).ceil
  end
 
  def self.reduced_tax(price)
    (price * REDUCED_TAX_RATE).ceil
  end
 
  def self.with_tax(price, rate: :standard)
    tax = rate == :reduced ? reduced_tax(price) : standard_tax(price)
    { price: price, tax: tax, total: price + tax }
  end
end
 
class EmailValidator
  FORMAT_REGEX = /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
 
  def initialize(email)
    @email = email.to_s.strip
  end
 
  def valid?
    format_valid? && domain_exists?
  end
 
  def invalid?
    !valid?
  end
 
  private
 
  def format_valid?
    @email.match?(FORMAT_REGEX)
  end
 
  def domain_exists?
    domain = @email.split("@").last
    Resolv::DNS.open { |dns| dns.getresources(domain, Resolv::DNS::Resource::IN::MX).any? }
  rescue Resolv::ResolvError
    false
  end
end
Loading diagram...

結合度: 「繋がり」の強さ

「凝集度と対になる概念が結合度。モジュール間がどれだけ密に繋がっているか。」

「目指すべきは高凝集・低結合。それぞれが独立して変更できる。」

強い結合(避けるべき)

# 強い結合の例: OrderがUserとProductの内部実装に直接依存
class Order
  def total_with_loyalty_discount
    user = User.find(user_id)
    items.sum do |item|
      product = Product.find(item.product_id)
      # Userとproductの内部実装に強く依存
      price = product.base_price * (1 - user.loyalty_discount_rate)
      item.quantity * price
    end
  end
end

弱い結合(良い設計)

# 弱い結合: 各クラスが明確なインターフェイスを持つ
class User
  def discount_rate_for(purchase_amount)
    case loyalty_tier
    when "gold"   then 0.15
    when "silver" then 0.10
    else               0.05
    end
  end
end
 
class Product
  def price_for_user(user)
    discount = user.discount_rate_for(base_price)
    (base_price * (1 - discount)).ceil
  end
end
 
class OrderItem
  def subtotal_for_user(user)
    product.price_for_user(user) * quantity
  end
end
 
class Order
  def total_with_loyalty_discount
    items.sum { |item| item.subtotal_for_user(user) }
  end
end

INFO

法則: デメテルの法則

「直接の友人とだけ話せ」。オブジェクトは直接知っているオブジェクトのメソッドだけを呼ぶべき。

# 悪い例(列車事故)
user.account.payment_method.card.last_four_digits
 
# 良い例
user.primary_card_last_four

パッケージ原則: コンポーネントをどうまとめるか

「次は、クラスをどうグループ化するかのルール。3つの原則がある。」

再利用・リリース等価の原則(REP)

「再利用の単位はリリースの単位でもある。」

# Gemとして切り出す単位 = 一緒に再利用される単位
# lib/payment_gateway/
#   base.rb
#   stripe.rb
#   paypal.rb
#   fake.rb
 
# payment_gateway.gemspecとしてパッケージ化
Gem::Specification.new do |spec|
  spec.name    = "payment_gateway"
  spec.version = "1.0.0"
  spec.files   = Dir["lib/**/*.rb"]
end

全再利用の原則(CRP)

「コンポーネントのクラスを使うなら、全部依存することになる。不要なクラスを同じコンポーネントに入れない。」

# 悪い例: 通知と決済が同じモジュール
module AppCore
  class EmailNotifier; end
  class StripeGateway; end  # 通知だけ使いたい場合もStripeが依存に入る
end
 
# 良い例: 独立したモジュール
module Notifications
  class EmailNotifier; end
  class SmsNotifier; end
end
 
module Payments
  class StripeGateway; end
  class PaypalGateway; end
end

閉鎖性共通の原則(CCP)

「同じ理由で変更されるクラスをまとめよ。」

# 消費税率が変わったとき、変更されるクラスは一箇所にまとまっているか?
# app/domain/pricing/
#   tax_calculator.rb    ← 税率変更で変わる
#   price_formatter.rb   ← 税率変更で変わる可能性
#   discount_engine.rb   ← 割引ロジック変更で変わる(別の理由)
 
# Railsでのディレクトリ構成例
# app/
#   models/
#   services/
#     pricing/          ← 価格計算に関するクラス群(CCP)
#       tax_calculator.rb
#       discount_engine.rb
#       price_formatter.rb
#     notifications/    ← 通知に関するクラス群(CCP)
#       email_notifier.rb
#       sms_notifier.rb

依存の方向を管理する

「大切なのは、依存の方向を一方向に保つこと。循環依存は最悪のアンチパターン。」

Loading diagram...
# 良い: 上位 → 下位の一方向依存
class OrdersController < ApplicationController
  def create
    result = OrderCreationService.new(order_params).call
    render json: result
  end
end
 
class OrderCreationService
  def call
    Order.create!(params)
    NotificationService.new(@order).send_confirmation
  end
end
 
class Order < ApplicationRecord
  # モデルはServiceやControllerを知らない
  validates :total_price, numericality: { greater_than: 0 }
end
 
# 悪い: 循環依存
class Order < ApplicationRecord
  def notify_user
    # モデルがServiceを呼ぶ循環依存!
    OrderNotificationService.new(self).send_confirmation
  end
end

WARNING

Railsのコールバック地獄

after_saveでServiceを呼び、ServiceがModelを更新し、再度after_saveが…という循環依存が起きがちです。モデルはドメインロジックに留め、副作用はコントローラーかサービスで管理しましょう。

AWS環境でのコンポーネント設計

AWSサービスもコンポーネントとして設計できる。

# CDKでのコンポーネント設計例
# lib/stacks/
#   networking-stack.ts   ← VPC, Subnet, SecurityGroup
#   database-stack.ts     ← RDS, ElastiCache
#   application-stack.ts  ← ECS, ALB
#   monitoring-stack.ts   ← CloudWatch, SNS
 
# 依存の方向: application → database → networking
# application-stack.ts
export class ApplicationStack extends Stack {
  constructor(scope, id, props: { databaseStack: DatabaseStack }) {
    super(scope, id, props);
    // databaseStackに依存するが、逆はない
    const dbEndpoint = props.databaseStack.dbEndpoint;
  }
}
# Railsでのコンポーネント境界の可視化
# bundler-auditで依存関係を確認
bundle exec bundle-audit check --update
 
# gem依存グラフの可視化
bundle viz --format png

Stage 5 のまとめ

ヒロシはディレクトリ構成を見直した。services/フォルダが何でも屋になっていたことに気づいた。

「高凝集・低結合。これが設計の理想形だったんですね。」

「そう。そして依存の方向を意識する。上位が下位に依存し、逆は禁じる。これだけでコードベースの複雑さが劇的に減る。」

概念目指す状態チェック方法
凝集度高い(一つのテーマに集中)変更の理由が1つか?
結合度低い(独立して変更できる)他を知りすぎていないか?
依存方向一方向(循環なし)循環依存になっていないか?

「次はアーキテクチャスタイル。MVC、レイヤード、クリーンアーキテクチャ。システム全体の構造を学ぼう。」

コンポーネント設計の考え方を得たヒロシは、app/services/を整理し始めた。