The European Central Bank (ECB) provides a reliable, free, and publicly available dataset of daily exchange rates. This article demonstrates how to build a robust currency conversion system in Java by fetching, parsing, and applying these rates.
Understanding the ECB Data Source
The ECB publishes daily reference exchange rates in XML format. The key endpoint is:
https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml
Important Characteristics:
- All rates are quoted against the Euro (EUR) as the base currency
- Updated daily around 4 PM CET
- Contains reference rates for ~30+ currencies
- Simple, structured XML format
Project Setup: Dependencies
We'll use Java 11+ with the following key dependencies for HTTP client and XML parsing:
Maven:
<dependencies> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.dataformat</groupId> <artifactId>jackson-dataformat-xml</artifactId> <version>2.15.2</version> </dependency> </dependencies>
Gradle:
implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2' implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.15.2'
Data Models for Parsing ECB XML
First, let's create Java classes to map the ECB XML structure:
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
import java.util.List;
public class EcbEnvelope {
@JacksonXmlProperty(localName = "Cube")
private Cube rootCube;
// Getters and setters
public Cube getRootCube() { return rootCube; }
public void setRootCube(Cube rootCube) { this.rootCube = rootCube; }
}
class Cube {
@JacksonXmlProperty(localName = "Cube")
private Cube timeCube;
// Getters and setters
public Cube getTimeCube() { return timeCube; }
public void setTimeCube(Cube timeCube) { this.timeCube = timeCube; }
}
class TimeCube {
@JacksonXmlProperty(localName = "time")
private String time;
@JacksonXmlProperty(localName = "Cube")
@JacksonXmlElementWrapper(useWrapping = false)
private List<CurrencyCube> currencyCubes;
// Getters and setters
public String getTime() { return time; }
public void setTime(String time) { this.time = time; }
public List<CurrencyCube> getCurrencyCubes() { return currencyCubes; }
public void setCurrencyCubes(List<CurrencyCube> currencyCubes) { this.currencyCubes = currencyCubes; }
}
class CurrencyCube {
@JacksonXmlProperty(localName = "currency")
private String currency;
@JacksonXmlProperty(localName = "rate")
private double rate;
// Getters and setters
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
public double getRate() { return rate; }
public void setRate(double rate) { this.rate = rate; }
}
Core Currency Converter Service
Here's the main service class that handles fetching rates and performing conversions:
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class CurrencyConverter {
private static final String ECB_DAILY_RATES_URL =
"https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml";
private final HttpClient httpClient;
private final XmlMapper xmlMapper;
private final Map<String, Double> exchangeRates;
private LocalDate lastUpdate;
public CurrencyConverter() {
this.httpClient = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
this.xmlMapper = new XmlMapper();
this.exchangeRates = new ConcurrentHashMap<>();
// Initialize with EUR as base (1:1)
exchangeRates.put("EUR", 1.0);
}
/**
* Fetches the latest exchange rates from ECB
*/
public synchronized void refreshRates() throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(ECB_DAILY_RATES_URL))
.header("Accept", "application/xml")
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("Failed to fetch ECB rates: HTTP " + response.statusCode());
}
parseRates(response.body());
}
private void parseRates(String xmlContent) throws IOException {
EcbEnvelope envelope = xmlMapper.readValue(xmlContent, EcbEnvelope.class);
TimeCube timeCube = (TimeCube) envelope.getRootCube().getTimeCube();
List<CurrencyCube> currencyCubes = timeCube.getCurrencyCubes();
// Clear existing rates (except EUR)
exchangeRates.entrySet().removeIf(entry -> !"EUR".equals(entry.getKey()));
// Add new rates
for (CurrencyCube cube : currencyCubes) {
exchangeRates.put(cube.getCurrency(), cube.getRate());
}
this.lastUpdate = LocalDate.parse(timeCube.getTime());
}
/**
* Converts an amount from one currency to another
*/
public BigDecimal convert(BigDecimal amount, String fromCurrency, String toCurrency) {
if (amount == null || fromCurrency == null || toCurrency == null) {
throw new IllegalArgumentException("Amount and currencies cannot be null");
}
String from = fromCurrency.toUpperCase();
String to = toCurrency.toUpperCase();
if (!exchangeRates.containsKey(from)) {
throw new IllegalArgumentException("Unsupported source currency: " + from);
}
if (!exchangeRates.containsKey(to)) {
throw new IllegalArgumentException("Unsupported target currency: " + to);
}
// Convert via EUR as base currency
double amountInEur = amount.doubleValue() / exchangeRates.get(from);
double convertedAmount = amountInEur * exchangeRates.get(to);
return BigDecimal.valueOf(convertedAmount)
.setScale(4, RoundingMode.HALF_UP);
}
/**
* Converts using primitive double (convenience method)
*/
public double convert(double amount, String fromCurrency, String toCurrency) {
return convert(BigDecimal.valueOf(amount), fromCurrency, toCurrency)
.doubleValue();
}
// Getters
public Map<String, Double> getExchangeRates() {
return new HashMap<>(exchangeRates);
}
public LocalDate getLastUpdate() {
return lastUpdate;
}
public boolean isRateAvailable(String currency) {
return exchangeRates.containsKey(currency.toUpperCase());
}
}
Advanced Features: Caching and Cross Rates
For better performance and additional functionality, here's an enhanced version:
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
public class AdvancedCurrencyConverter {
private final CurrencyConverter baseConverter;
private LocalDateTime lastCacheRefresh;
private final Duration cacheTtl;
private final Map<String, Map<String, Double>> crossRateCache;
public AdvancedCurrencyConverter(Duration cacheTtl) {
this.baseConverter = new CurrencyConverter();
this.cacheTtl = cacheTtl;
this.crossRateCache = new HashMap<>();
}
/**
* Gets rates with automatic cache refresh
*/
public Map<String, Double> getRatesWithCache() throws IOException, InterruptedException {
if (lastCacheRefresh == null ||
Duration.between(lastCacheRefresh, LocalDateTime.now()).compareTo(cacheTtl) > 0) {
baseConverter.refreshRates();
lastCacheRefresh = LocalDateTime.now();
crossRateCache.clear(); // Clear cross rates when base rates change
}
return baseConverter.getExchangeRates();
}
/**
* Calculates cross rate between two non-EUR currencies
*/
public double getCrossRate(String fromCurrency, String toCurrency)
throws IOException, InterruptedException {
String from = fromCurrency.toUpperCase();
String to = toCurrency.toUpperCase();
// Check cache first
if (crossRateCache.containsKey(from) && crossRateCache.get(from).containsKey(to)) {
return crossRateCache.get(from).get(to);
}
Map<String, Double> rates = getRatesWithCache();
if (!rates.containsKey(from) || !rates.containsKey(to)) {
throw new IllegalArgumentException("One or both currencies not supported");
}
// Cross rate calculation: (EUR/from) * (to/EUR) = to/from
double crossRate = (1 / rates.get(from)) * rates.get(to);
// Cache the result
crossRateCache.computeIfAbsent(from, k -> new HashMap<>())
.put(to, crossRate);
return crossRate;
}
/**
* Batch conversion for multiple amounts
*/
public Map<String, BigDecimal> convertBatch(Map<String, BigDecimal> amounts,
String fromCurrency,
String toCurrency)
throws IOException, InterruptedException {
Map<String, BigDecimal> results = new HashMap<>();
double crossRate = getCrossRate(fromCurrency, toCurrency);
for (Map.Entry<String, BigDecimal> entry : amounts.entrySet()) {
BigDecimal converted = entry.getValue().multiply(BigDecimal.valueOf(crossRate))
.setScale(4, RoundingMode.HALF_UP);
results.put(entry.getKey(), converted);
}
return results;
}
}
Usage Examples
public class CurrencyConverterExample {
public static void main(String[] args) {
try {
// Basic usage
CurrencyConverter converter = new CurrencyConverter();
converter.refreshRates();
// Single conversion
BigDecimal result = converter.convert(
new BigDecimal("100.00"), "USD", "JPY");
System.out.println("100 USD = " + result + " JPY");
// Get all available rates
Map<String, Double> rates = converter.getExchangeRates();
System.out.println("Available currencies: " + rates.keySet());
// Advanced usage with caching
AdvancedCurrencyConverter advancedConverter =
new AdvancedCurrencyConverter(Duration.ofHours(1));
// This will fetch fresh rates
double usdToGbp = advancedConverter.getCrossRate("USD", "GBP");
System.out.println("USD/GBP cross rate: " + usdToGbp);
// Batch conversion
Map<String, BigDecimal> amounts = Map.of(
"Salary", new BigDecimal("5000.00"),
"Bonus", new BigDecimal("1000.00"),
"Expense", new BigDecimal("250.50")
);
Map<String, BigDecimal> converted = advancedConverter.convertBatch(
amounts, "USD", "CAD");
converted.forEach((description, amount) ->
System.out.println(description + ": " + amount + " CAD"));
} catch (Exception e) {
e.printStackTrace();
}
}
}
Error Handling and Best Practices
- Rate Limiting: The ECB service is free but be respectful - don't poll more than once per hour.
- Fallback Strategy: Implement caching and use stale rates if the ECB service is unavailable.
- Input Validation: Always validate currency codes and amounts.
- Thread Safety: The
ConcurrentHashMapand synchronized methods ensure thread-safe operation. - Precision: Use
BigDecimalfor financial calculations to avoid floating-point precision issues. - Monitoring: Track refresh failures and log conversion activities for audit purposes.
Conclusion
This implementation provides a robust foundation for currency conversion using ECB exchange rates. The key advantages are:
- Accuracy: Uses official ECB reference rates
- Performance: Includes caching and efficient HTTP client usage
- Flexibility: Supports single conversions, batch operations, and cross rates
- Maintainability: Clean separation of concerns with proper error handling
For production use, you might want to add features like historical rate support, multiple data source fallbacks, and integration with dependency injection frameworks.