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結合度: 「繋がり」の強さ
「凝集度と対になる概念が結合度。モジュール間がどれだけ密に繋がっているか。」
「目指すべきは高凝集・低結合。それぞれが独立して変更できる。」
強い結合(避けるべき)
# 強い結合の例: 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
endINFO
法則: デメテルの法則
「直接の友人とだけ話せ」。オブジェクトは直接知っているオブジェクトのメソッドだけを呼ぶべき。
# 悪い例(列車事故)
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依存の方向を管理する
「大切なのは、依存の方向を一方向に保つこと。循環依存は最悪のアンチパターン。」
# 良い: 上位 → 下位の一方向依存
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
endWARNING
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 pngStage 5 のまとめ
ヒロシはディレクトリ構成を見直した。services/フォルダが何でも屋になっていたことに気づいた。
「高凝集・低結合。これが設計の理想形だったんですね。」
「そう。そして依存の方向を意識する。上位が下位に依存し、逆は禁じる。これだけでコードベースの複雑さが劇的に減る。」
| 概念 | 目指す状態 | チェック方法 |
|---|---|---|
| 凝集度 | 高い(一つのテーマに集中) | 変更の理由が1つか? |
| 結合度 | 低い(独立して変更できる) | 他を知りすぎていないか? |
| 依存方向 | 一方向(循環なし) | 循環依存になっていないか? |
「次はアーキテクチャスタイル。MVC、レイヤード、クリーンアーキテクチャ。システム全体の構造を学ぼう。」
コンポーネント設計の考え方を得たヒロシは、app/services/を整理し始めた。