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を作るチャンスだ」田中さんが微笑んだ。
社内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
# └── GemfileStep 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
endStep 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
endINFO
社内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'
endRails Engineでより大きな共通機能を共有する
個別のクラス・モジュールを超えて、ルーティングやコントローラを含む大きな機能を共有したい場合は Rails Engine を使う。
通知エンジンの例
# 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 StackSetやTerraform 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ヶ月後にどうなったか見てみよう」田中さんが言った。
| 指標 | Before | After |
|---|---|---|
| 割引ロジックの実装箇所 | 6リポジトリ × 複数ファイル | discount_engine Gem 1箇所 |
| 割引率変更の作業 | 6プロジェクトで修正・テスト・デプロイ | Gemを1箇所修正してバージョンアップ |
| 新プロジェクトでの実装時間 | 3〜5時間(テスト含む) | gem 'discount_engine' の1行 |
| バグ発生リスク | 各実装に個別バグの可能性 | Gemのテストで一元保証 |
| 変更への信頼度 | 「全部変えたか確信が持てない」 | テストがグリーンなら全プロジェクト安全 |
「コードの再利用は時間の再利用だ」田中さんはまとめた。
「そして信頼の再利用でもある」ユイが加えた。「discount_engineのテストが通れば、どのプロジェクトでも正しく動く——その信頼感が、変更への自信になる」
「次は変更が怖くない設計——Refactorabilityを学ぼう。再利用可能なコードをさらに一歩進めて、変更することへの恐怖をなくす」
付録: Gemの公開vs内部保持の判断基準
社外公開すべきか(rubygems.orgへ):
✅ 業界共通の問題を解決している
✅ 競合優位性に関係しない汎用ロジック
✅ 社名・社内情報が含まれていない
✅ ドキュメントを整備できる時間がある
→ 公開することでコミュニティに貢献でき、フィードバックも得られる
社内限定にすべきか:
✅ ビジネスロジックが含まれる(割引率など)
✅ 競合に知られたくない実装がある
✅ 社内の命名規則・設計方針に依存している
→ プライベートGemサーバーまたはGitHubプライベートリポジトリで管理