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.listandself.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)