mybook

Reusability 実践 — 共通ライブラリとGem化

社内の重複コードを発見する

3週間後、ユイは社内の別プロジェクトを調べる機会を得た。会社には4つのRailsアプリがある——EC サイト、管理パネル、モバイル API、そして社内ツール。

「もしかして、割引ロジックが全部のアプリに書かれてる?」

# 会社の複数リポジトリを横断してコードを検索
rg "premium.*0.8\|0.8.*premium" --type rb
 
# 結果(衝撃的)
ec-site/app/services/discount_service.rb:12:    price * 0.8
ec-site/app/models/order.rb:89:    total * 0.8
admin-panel/app/helpers/price_helper.rb:34:    price * 0.8
admin-panel/app/services/pricing.rb:56:    amount * 0.8
mobile-api/app/services/pricing_service.rb:23:    price * 0.8
internal-tools/lib/discount_calculator.rb:8:    price * 0.8

「6箇所!」ユイは報告した。「しかも、割引率が変わったらすべてのリポジトリで修正が必要になる」

「これは社内Gemを作るチャンスだ」田中さんが微笑んだ。

Loading diagram...

社内Gemを作る: ステップバイステップ

Step 1: Gemのスケルトンを生成する

# Bundlerを使ってGemの雛形を生成
bundle gem discount_engine --no-ext --mit --test=rspec
 
# 生成されたファイル構造
# discount_engine/
# ├── lib/
# │   ├── discount_engine.rb           # エントリーポイント
# │   └── discount_engine/
# │       ├── version.rb               # バージョン管理
# │       ├── calculator.rb            # 計算ロジック
# │       └── result.rb                # 結果オブジェクト
# ├── spec/
# │   ├── spec_helper.rb
# │   └── discount_engine/
# │       ├── calculator_spec.rb
# │       └── result_spec.rb
# ├── discount_engine.gemspec          # Gemの仕様
# ├── README.md
# ├── CHANGELOG.md
# └── Gemfile

Step 2: コアのロジックを実装する

# lib/discount_engine/calculator.rb
module DiscountEngine
  class Calculator
    # 会員ティアと割引設定
    TIERS = {
      premium:  { discount_rate: 0.20, label: 'プレミアム', priority: 1 },
      gold:     { discount_rate: 0.15, label: 'ゴールド',   priority: 2 },
      silver:   { discount_rate: 0.10, label: 'シルバー',   priority: 3 },
      standard: { discount_rate: 0.00, label: '一般',       priority: 4 }
    }.freeze
 
    def initialize(user_tier:)
      @user_tier = validate_tier!(user_tier.to_sym)
    end
 
    # 価格を受け取り、割引結果オブジェクトを返す
    def calculate(original_price)
      validate_price!(original_price)
 
      discount_amount = (original_price * discount_rate).round(2)
 
      Result.new(
        original_price: original_price,
        discount_amount: discount_amount,
        discount_rate: discount_rate,
        tier: @user_tier,
        tier_label: tier_config[:label]
      )
    end
 
    def discount_rate
      tier_config[:discount_rate]
    end
 
    def discountable?
      discount_rate > 0
    end
 
    private
 
    def tier_config
      TIERS.fetch(@user_tier, TIERS[:standard])
    end
 
    def validate_tier!(tier)
      return tier if TIERS.key?(tier)
      raise ArgumentError, "Invalid tier: #{tier}. Must be one of #{TIERS.keys}"
    end
 
    def validate_price!(price)
      raise ArgumentError, "Price must be positive, got: #{price}" if price < 0
    end
  end
end
# lib/discount_engine/result.rb
module DiscountEngine
  # Ruby 3.2+ の Data クラスを使ったイミュータブルな結果オブジェクト
  Result = Data.define(
    :original_price,
    :discount_amount,
    :discount_rate,
    :tier,
    :tier_label
  ) do
    def final_price
      original_price - discount_amount
    end
 
    def discounted?
      discount_amount > 0
    end
 
    def savings_percentage
      return 0 unless discounted?
      (discount_amount / original_price * 100).round(1)
    end
 
    def to_h
      super.merge(
        final_price: final_price,
        discounted: discounted?,
        savings_percentage: savings_percentage
      )
    end
 
    def to_s
      if discounted?
        "#{tier_label}割引: #{original_price}円 → #{final_price}円(#{discount_amount}円引き)"
      else
        "割引なし: #{original_price}円"
      end
    end
  end
end
# lib/discount_engine.rb (エントリーポイント)
require_relative 'discount_engine/version'
require_relative 'discount_engine/result'
require_relative 'discount_engine/calculator'
 
module DiscountEngine
  class Error < StandardError; end
  class InvalidTierError < Error; end
  class InvalidPriceError < Error; end
 
  # 便利なショートカット
  def self.calculate(original_price:, user_tier:)
    Calculator.new(user_tier: user_tier).calculate(original_price)
  end
end

Step 3: テストを書く(これが品質保証)

# spec/discount_engine/calculator_spec.rb
RSpec.describe DiscountEngine::Calculator do
  describe '#calculate' do
    context 'プレミアム会員の場合' do
      subject(:calculator) { described_class.new(user_tier: :premium) }
 
      it '20%割引を適用する' do
        result = calculator.calculate(1000)
 
        expect(result.discount_amount).to eq(200.0)
        expect(result.final_price).to eq(800.0)
        expect(result.discounted?).to be true
        expect(result.savings_percentage).to eq(20.0)
      end
 
      it '端数を正しく処理する(100円未満は四捨五入)' do
        result = calculator.calculate(999)
        expect(result.discount_amount).to eq(199.8)  # 999 * 0.2 = 199.8
        expect(result.final_price).to eq(799.2)
      end
    end
 
    context 'ゴールド会員の場合' do
      subject(:calculator) { described_class.new(user_tier: :gold) }
 
      it '15%割引を適用する' do
        result = calculator.calculate(1000)
        expect(result.discount_amount).to eq(150.0)
        expect(result.final_price).to eq(850.0)
      end
    end
 
    context '一般会員の場合' do
      subject(:calculator) { described_class.new(user_tier: :standard) }
 
      it '割引なし' do
        result = calculator.calculate(1000)
        expect(result.discounted?).to be false
        expect(result.final_price).to eq(1000.0)
        expect(result.discount_amount).to eq(0)
      end
    end
 
    context '不正な入力の場合' do
      it '無効なティアで ArgumentError を発生させる' do
        expect {
          described_class.new(user_tier: :invalid_tier)
        }.to raise_error(ArgumentError, /Invalid tier/)
      end
 
      it '負の価格で ArgumentError を発生させる' do
        calculator = described_class.new(user_tier: :premium)
        expect {
          calculator.calculate(-100)
        }.to raise_error(ArgumentError, /Price must be positive/)
      end
    end
  end
 
  describe '#discountable?' do
    it 'プレミアム会員はtrue' do
      expect(described_class.new(user_tier: :premium).discountable?).to be true
    end
 
    it '一般会員はfalse' do
      expect(described_class.new(user_tier: :standard).discountable?).to be false
    end
  end
end

INFO

社内Gemにテストを含めることが最重要です。テストがあるGemは、バグを発見したとき修正してバージョンアップすれば全プロジェクトに反映されます。テストがないGemは「バグがあるかもしれないけど確認できない」ブラックボックスになります。

Step 4: Gemspecを整備する

# discount_engine.gemspec
require_relative 'lib/discount_engine/version'
 
Gem::Specification.new do |spec|
  spec.name          = 'discount_engine'
  spec.version       = DiscountEngine::VERSION
  spec.authors       = ['Your Team']
  spec.email         = ['tech@example.com']
 
  spec.summary       = '社内共通割引計算エンジン'
  spec.description   = 'プレミアム・ゴールド・シルバー各会員ティアの割引計算を統一的に処理するGem'
  spec.homepage      = 'https://github.com/your-org/discount_engine'
 
  spec.files = Dir['lib/**/*', 'README.md', 'CHANGELOG.md']
  spec.require_paths = ['lib']
 
  spec.required_ruby_version = '>= 3.0.0'
 
  # ランタイム依存
  spec.add_dependency 'activesupport', '>= 7.0'
 
  # 開発時依存
  spec.add_development_dependency 'rspec', '~> 3.12'
  spec.add_development_dependency 'rubocop', '~> 1.50'
end

Rails Engineでより大きな共通機能を共有する

個別のクラス・モジュールを超えて、ルーティングやコントローラを含む大きな機能を共有したい場合は Rails Engine を使う。

Loading diagram...

通知エンジンの例

# Rails Engineの生成
rails plugin new notification_engine --mountable --full
 
# 生成構造
# notification_engine/
# ├── app/
# │   ├── models/notification_engine/
# │   │   └── notification.rb
# │   ├── controllers/notification_engine/
# │   │   └── notifications_controller.rb
# │   └── mailers/notification_engine/
# │       └── user_mailer.rb
# ├── config/
# │   └── routes.rb
# ├── db/
# │   └── migrate/
# └── lib/
#     └── notification_engine.rb
# engines/notification_engine/app/models/notification_engine/notification.rb
module NotificationEngine
  class Notification < ApplicationRecord
    self.table_name = 'notification_engine_notifications'
 
    belongs_to :notifiable, polymorphic: true
 
    CHANNELS = %w[email push_notification in_app].freeze
 
    validates :channel, inclusion: { in: CHANNELS }
    validates :notifiable, presence: true
 
    scope :unread, -> { where(read_at: nil) }
    scope :recent, -> { order(created_at: :desc) }
 
    def mark_as_read!
      update!(read_at: Time.current)
    end
 
    def read?
      read_at.present?
    end
  end
end
# engines/notification_engine/lib/notification_engine.rb
module NotificationEngine
  class Configuration
    attr_accessor :email_from, :push_service, :default_channel
 
    def initialize
      @email_from = 'noreply@example.com'
      @push_service = :fcm
      @default_channel = :email
    end
  end
 
  def self.configuration
    @configuration ||= Configuration.new
  end
 
  def self.configure
    yield(configuration)
  end
 
  # メインアプリからの使用エントリーポイント
  def self.notify(recipient:, event:, data: {}, channels: nil)
    channels ||= [configuration.default_channel]
    Dispatcher.new(recipient: recipient, event: event, data: data).dispatch_to(channels)
  end
end
 
# メインアプリでの設定(config/initializers/notification_engine.rb)
NotificationEngine.configure do |config|
  config.email_from = 'support@your-store.com'
  config.push_service = :fcm
  config.default_channel = :email
end
 
# 使用例(どのアプリでも同じように使える)
NotificationEngine.notify(
  recipient: user,
  event: 'order.completed',
  data: { order_id: order.id, total: order.total }
)

プライベートGemのホスティング戦略

社内Gemをどこに置くか、選択肢がある。

# Gemfile での参照方法
 
# オプション1: GitHubのプライベートリポジトリから(最もシンプル)
gem 'discount_engine',
    git: 'https://github.com/your-org/discount_engine.git',
    tag: 'v1.2.0'
 
# オプション2: Gemfury(プライベートGemホスティングサービス)
source 'https://gem.fury.io/your-org/' do
  gem 'discount_engine', '~> 1.2'
  gem 'notification_engine', '~> 2.0'
end
 
# オプション3: Gemサーバー自前運用(Nexus Repository等)
source 'https://gems.internal.your-company.com' do
  gem 'discount_engine', '~> 1.2'
end
 
# オプション4: モノレポ内のパス参照(最初の段階に最適)
gem 'discount_engine', path: '../discount_engine'
ホスティング方法利点欠点
GitHubリポジトリ設定不要、PRレビューが使えるbundleが遅い、認証が必要
Gemfury高速、安定有料
自前Gemサーバー完全制御運用コスト
パス参照(開発中)即時反映本番では使えない

バージョニングで安全に更新する

セマンティックバージョニング(SemVer)で変更の影響範囲を伝える。

# lib/discount_engine/version.rb
module DiscountEngine
  # セマンティックバージョニング: MAJOR.MINOR.PATCH
  # MAJOR: 後方互換性のない変更(API破壊的変更)
  # MINOR: 後方互換性のある機能追加
  # PATCH: バグ修正(後方互換性あり)
  VERSION = '1.3.0'
end
# CHANGELOG.md で変更履歴を管理
 
## [2.0.0] - 2024-06-01
### Breaking Changes
- `Calculator.new(tier:)``Calculator.new(user_tier:)` に変更
  (移行方法: https://github.com/your-org/discount_engine/wiki/v2-migration)
 
## [1.3.0] - 2024-04-15
### Added
- 学生割引ティア(10%)を追加
### Changed
- プレミアム割引を20%から22%に変更(営業部要件)
 
## [1.2.3] - 2024-03-01
### Fixed
- 1円未満の端数処理バグを修正(round(2)の欠如)
  (影響: ¥999のプレミアム割引が¥199.8→¥200.0と誤計算されていた)
# 各プロジェクトのGemfileでバージョン制約をかける
# ~> 1.2 は 1.2.x を使う(MINORバージョンアップは拒否)
gem 'discount_engine', '~> 1.2'
 
# これにより:
# 1.2.0 → 1.2.3 の自動更新: OK(バグ修正のみ)
# 1.2.x → 1.3.0 の更新: bundle update が必要(機能追加)
# 1.x.x → 2.0.0 の更新: 明示的な更新が必要(破壊的変更)

WARNING

GemのAPIを変更する場合は必ずMajorバージョンを上げてください。Gemに依存するプロジェクトが gem 'discount_engine', '~> 1.0' と制約をかけているため、予期しない破壊的変更を防げます。変更履歴(CHANGELOG)は次のバージョンに更新される前に必ず書きましょう。

AWSでの共通コンポーネント再利用

インフラレベルでも再利用性は重要だ。Railsの社内Gemと同様に、AWSではCloudFormation StackSetTerraform Moduleで共通パターンを再利用する。

# AWS CloudFormation — 共通VPCテンプレート(再利用可能な雛形)
# templates/shared/vpc.yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: '共通VPCテンプレート — 全プロジェクト共通'
 
Parameters:
  ProjectName:
    Type: String
    Description: 'プロジェクト名(リソース名のプレフィックスに使用)'
  VpcCidr:
    Type: String
    Default: '10.0.0.0/16'
    Description: 'VPCのCIDRブロック'
  Environment:
    Type: String
    AllowedValues: [production, staging, development]
 
Resources:
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VpcCidr
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: !Sub '${ProjectName}-${Environment}-vpc'
        - Key: Project
          Value: !Ref ProjectName
        - Key: Environment
          Value: !Ref Environment
 
  # パブリックサブネット(2AZ)
  PublicSubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: !Select [0, !Cidr [!Ref VpcCidr, 8, 8]]
      AvailabilityZone: !Select [0, !GetAZs '']
      MapPublicIpOnLaunch: true
 
  PublicSubnetB:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: !Select [1, !Cidr [!Ref VpcCidr, 8, 8]]
      AvailabilityZone: !Select [1, !GetAZs '']
 
  # プライベートサブネット(2AZ)
  PrivateSubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: !Select [2, !Cidr [!Ref VpcCidr, 8, 8]]
      AvailabilityZone: !Select [0, !GetAZs '']
 
  PrivateSubnetB:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: !Select [3, !Cidr [!Ref VpcCidr, 8, 8]]
      AvailabilityZone: !Select [1, !GetAZs '']
 
Outputs:
  VpcId:
    Value: !Ref VPC
    Export:
      Name: !Sub '${ProjectName}-${Environment}-VpcId'
  PublicSubnetIds:
    Value: !Join [',', [!Ref PublicSubnetA, !Ref PublicSubnetB]]
    Export:
      Name: !Sub '${ProjectName}-${Environment}-PublicSubnetIds'
  PrivateSubnetIds:
    Value: !Join [',', [!Ref PrivateSubnetA, !Ref PrivateSubnetB]]
    Export:
      Name: !Sub '${ProjectName}-${Environment}-PrivateSubnetIds'
# 各プロジェクトでVPCテンプレートを再利用する
# templates/ec-site/main.yaml
Resources:
  VpcStack:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: 'https://s3.amazonaws.com/your-templates/shared/vpc.yaml'
      Parameters:
        ProjectName: ec-site
        Environment: !Ref Environment
        VpcCidr: '10.1.0.0/16'
 
  # ECSはVPCのOutputsをImportValueで参照(再利用)
  ECSCluster:
    Type: AWS::ECS::Cluster
    Properties:
      VpcId: !ImportValue !Sub 'ec-site-${Environment}-VpcId'

Railsの社内Gemと同じ考え方だ。「共通パターンを1箇所にまとめ、変更は1箇所だけ」。VPCの設定が変わっても、テンプレートを1箇所変えれば全プロジェクトに反映できる。

再利用性の成果

「2ヶ月後にどうなったか見てみよう」田中さんが言った。

指標BeforeAfter
割引ロジックの実装箇所6リポジトリ × 複数ファイルdiscount_engine Gem 1箇所
割引率変更の作業6プロジェクトで修正・テスト・デプロイGemを1箇所修正してバージョンアップ
新プロジェクトでの実装時間3〜5時間(テスト含む)gem 'discount_engine' の1行
バグ発生リスク各実装に個別バグの可能性Gemのテストで一元保証
変更への信頼度「全部変えたか確信が持てない」テストがグリーンなら全プロジェクト安全

「コードの再利用は時間の再利用だ」田中さんはまとめた。

「そして信頼の再利用でもある」ユイが加えた。「discount_engineのテストが通れば、どのプロジェクトでも正しく動く——その信頼感が、変更への自信になる」

「次は変更が怖くない設計——Refactorabilityを学ぼう。再利用可能なコードをさらに一歩進めて、変更することへの恐怖をなくす」

付録: Gemの公開vs内部保持の判断基準

社外公開すべきか(rubygems.orgへ):
  ✅ 業界共通の問題を解決している
  ✅ 競合優位性に関係しない汎用ロジック
  ✅ 社名・社内情報が含まれていない
  ✅ ドキュメントを整備できる時間がある
  → 公開することでコミュニティに貢献でき、フィードバックも得られる

社内限定にすべきか:
  ✅ ビジネスロジックが含まれる(割引率など)
  ✅ 競合に知られたくない実装がある
  ✅ 社内の命名規則・設計方針に依存している
  → プライベートGemサーバーまたはGitHubプライベートリポジトリで管理