DEV Community

Cover image for Build a Currency Exchange Service in Ruby on Rails
Sulman Baig
Sulman Baig

Posted on • Originally published at sulmanweb.com

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.

🎯 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Pro Tip 💡
I'm using class methods (self.list and self.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
Enter fullscreen mode Exit fullscreen mode

Why This Approach Works 🎯

  1. Value objects provide a clean interface
  2. Empty arrays as fallbacks prevent nil checking
  3. Response parsing is encapsulated
  4. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls to Avoid ⚠️

  1. Don't store sensitive API keys in your codebase. Use Rails credentials:
EDITOR="code --wait" bin/rails credentials:edit
Enter fullscreen mode Exit fullscreen mode
  1. Don't update rates synchronously during user requests. Always use background jobs.
  2. Don't forget to handle API rate limits. Currency Layer has different limits for different plans.

Production Considerations 🚀

  1. Error Monitoring: Add Sentry or similar error tracking:
Sentry.capture_exception(e) if defined?(Sentry)
Enter fullscreen mode Exit fullscreen mode
  1. 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
Enter fullscreen mode Exit fullscreen mode
  1. Logging: Add structured logging for debugging:
Rails.logger.info(
  event: 'currency_rate_update',
  currency: conversion.to,
  rate: conversion.rate
)
Enter fullscreen mode Exit fullscreen mode

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)