Build a Currency Exchange Service in Ruby on Rails
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.
Get Sulman Baig’s stories in your inbox
Join Medium for free to get updates from this writer.
🎯 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 jobsBuilding 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
endPro 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
endWhy 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
endModel 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 }
endAutomated 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
endThe 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
endScheduling 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 AMTesting 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
endModel 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
endUsage 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}"
endCommon 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)2. Rate Limiting: Implement exponential backoff for API failures:
def with_retry
retries ||= 0
yield
rescue StandardError => e
retry if (retries += 1) < 3
raise e
end3. 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.
Originally published at https://sulmanweb.com.







