Working with exchange rates in Rails applications can be tricky. After implementing currency handling in several production apps, I've developed a robust pattern that I'll share with you today. We'll build a service that fetches live exchange rates from Currency Layer API and integrates smoothly with Rails.
🎯 What We'll Cover:
- Setting up a reusable currency exchange service
- Implementing background rate updates
- Handling API integration gracefully
- Testing our implementation
Project Setup
First, let's add the necessary gems to our Gemfile
:
gem 'httparty' # For API requests
gem 'solid_queue' # For background jobs
Building the Currency Exchange Service
I prefer using Plain Old Ruby Objects (POROs) for API integrations. Here's why - they're easy to test, maintain, and modify. Let's build our service:
# app/services/currency_exchange.rb
class CurrencyExchange
include HTTParty
base_uri 'https://api.currencylayer.com'
def initialize
@options = {
query: {
access_key: Rails.application.credentials.currency_layer.api_key
}
}
end
def self.list
new.list
end
def self.live
new.live
end
end
Pro Tip 💡
I'm using class methods (self.list
andself.live
) as convenience wrappers around instance methods. This gives us flexibility - we can instantiate the class directly when we need to customize behavior, or use the class methods for quick access.
Handling API Responses
Let's build clean value objects for our data:
class CurrencyExchange
# ...
GlobalCurrency = Struct.new(:code, :name)
Conversion = Struct.new(:from, :to, :rate)
def list
res = self.class.get('/list', @options)
return res.parsed_response['currencies'].map { |code, name|
GlobalCurrency.new(code, name)
} if res.success?
[]
end
def live
res = self.class.get('/live', @options)
return [] unless res.success?
res.parsed_response['quotes'].map do |code, rate|
Conversion.new(code[0..2], code[3..], rate.to_f.round(4))
end
end
end
Why This Approach Works 🎯
- Value objects provide a clean interface
- Empty arrays as fallbacks prevent nil checking
- Response parsing is encapsulated
- Rate rounding handles floating-point precision
Database Integration
We need to store our currency data. Here's our migration:
class CreateCurrencies < ActiveRecord::Migration[7.2]
def change
create_table :currencies do |t|
t.string :code, null: false
t.decimal :amount, precision: 14, scale: 4, default: 1.0
t.string :name, null: false
t.datetime :converted_at
t.datetime :deleted_at
t.timestamps
end
add_index :currencies, :code, unique: true
add_index :currencies, :name
add_index :currencies, :deleted_at
end
end
Model Implementation
class Currency < ApplicationRecord
acts_as_paranoid # Soft deletes
has_many :accounts, dependent: :nullify
validates :name, presence: true
validates :code, presence: true, uniqueness: true
validates :amount, presence: true,
numericality: { greater_than_or_equal_to: 0.0 }
end
Automated Rate Updates
Here's where it gets interesting. We'll use a background job to update rates:
class UpdateCurrencyRatesJob < ApplicationJob
def perform
Currencies::UpdateRatesService.call
end
end
The actual update service:
module Currencies
class UpdateRatesService < ApplicationService
def call
CurrencyExchange.live.each do |conversion|
update_rate(conversion)
end
end
private
def update_rate(conversion)
Currency.find_by(code: conversion.to)&.update(
amount: conversion.rate,
converted_at: Time.current
)
end
end
end
Scheduling Updates
Configure your scheduler in config/recurring.yml
:
staging:
update_currency_rates:
class: UpdateCurrencyRatesJob
queue: background
schedule: '0 1 */2 * *' # Every 2 days at 1 AM
Testing Strategy
Here's how I test this setup:
RSpec.describe CurrencyExchange do
describe '.list' do
it 'returns structured currency data' do
VCR.use_cassette('currency_layer_list') do # https://github.com/vcr/vcr
currencies = described_class.list
expect(currencies).to all(be_a(described_class::GlobalCurrency))
end
end
it 'handles API failures gracefully' do
allow(described_class).to receive(:get).and_return(
double(success?: false)
)
expect(described_class.list).to eq([])
end
end
end
Model Tests
RSpec.describe Currency do
describe 'validations' do
it 'requires a valid exchange rate' do
currency = build(:currency, amount: -1)
expect(currency).not_to be_valid
end
it 'enforces unique currency codes' do
create(:currency, code: 'USD')
duplicate = build(:currency, code: 'USD')
expect(duplicate).not_to be_valid
end
end
end
Usage in Your Application
Here's how you'd use this in your app:
# Fetch available currencies
currencies = CurrencyExchange.list
puts "Available currencies: #{currencies.map(&:code).join(', ')}"
# Get current rates
rates = CurrencyExchange.live
rates.each do |conversion|
puts "1 #{conversion.from} = #{conversion.rate} #{conversion.to}"
end
Common Pitfalls to Avoid ⚠️
- Don't store sensitive API keys in your codebase. Use Rails credentials:
EDITOR="code --wait" bin/rails credentials:edit
- Don't update rates synchronously during user requests. Always use background jobs.
- Don't forget to handle API rate limits. Currency Layer has different limits for different plans.
Production Considerations 🚀
- Error Monitoring: Add Sentry or similar error tracking:
Sentry.capture_exception(e) if defined?(Sentry)
- Rate Limiting: Implement exponential backoff for API failures:
def with_retry
retries ||= 0
yield
rescue StandardError => e
retry if (retries += 1) < 3
raise e
end
- Logging: Add structured logging for debugging:
Rails.logger.info(
event: 'currency_rate_update',
currency: conversion.to,
rate: conversion.rate
)
Remember: Currency exchange rates are critical financial data. Always validate your implementation thoroughly and consider using paid API tiers for production use.
Top comments (0)