Build a Rate-Limited Currency Exchange API
Build a Spring Boot API with Redis caching, rate limiting, and API key auth.
Introduction
30 Second Summary
Every public API you use daily has invisible guardrails controlling how many requests you can make per minute. These rate limiters protect servers from abuse and ensure fair access for all consumers.
In this project, you will build a currency exchange rate API that authenticates callers with API keys, caches frequent lookups in Redis, and enforces per-key rate limits. You will see exactly how production APIs manage traffic behind the scenes.
What You'll Build
A Spring Boot REST API that serves exchange rate data from PostgreSQL, caches responses in Redis with a configurable TTL, and uses Redis INCR/EXPIRE to enforce fixed-window rate limits per API key.
By the end of this project, you'll have:
- A Spring Boot REST API with endpoints that serve currency exchange rates from a PostgreSQL database.
- A Redis caching layer with TTL-based expiry that measurably speeds up repeated requests.
- A fixed-window rate limiter using Redis INCR and EXPIRE that enforces per-API-key request quotas with proper rate-limit response headers.
- Secret Mission: Add tiered access control where free-tier keys get limited currency access and 10 requests per minute, while paid-tier keys unlock all currencies with 100 requests per minute.
Are there any prerequisites?
This project assumes basic Java knowledge and familiarity with REST APIs. You should already have JDK 21, VS Code, and Docker installed on your machine.
Set Up the Project and Infrastructure
Every API needs a foundation. Before you write any business logic, you need a project skeleton with the right dependencies and a running database layer to store data.
In this step, you will generate a Spring Boot project, spin up PostgreSQL and Redis containers using Docker Compose, and wire everything together with the right configuration.
In this step, get ready to:
- Generate a Spring Boot project with the required dependencies.
- Start PostgreSQL and Redis containers with Docker Compose.
- Configure the application to connect to both databases and verify it starts.
Generate the Spring Boot project
Spring Initializr is a web tool that generates a ready-to-run Spring Boot project with your chosen dependencies baked in. You will use it to scaffold the project instead of building the Maven configuration by hand.
- Open your browser and navigate to start.spring.io.
- Under Project, select Maven.
- Under Language, select Java.
- Under Spring Boot, select 3.5.15.
- In the Group field, leave the default com.example.
- In the Artifact field, enter exchange-rate-api.
- Under Java, select 21.
Now add the four dependencies your project needs.
- Click ADD DEPENDENCIES (or press Ctrl+B).
- Search for Spring Web and select it.
- Search for Spring Data JPA and select it.
- Search for Spring Data Redis and select it.
- Search for PostgreSQL Driver and select it.
What are these dependencies?
Spring Web lets you build REST endpoints. Spring Data JPA provides an ORM layer for PostgreSQL. Spring Data Redis connects your app to Redis for caching and rate limiting. PostgreSQL Driver is the JDBC driver that JPA uses to talk to your database.
- Click GENERATE to download the project as a zip file.
- Locate the downloaded exchange-rate-api.zip file and extract it to your Desktop.
Now open the extracted project in VS Code.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the exchange-rate-api folder, and click Open.
You should see the project structure in the Explorer sidebar, including folders like src/main/java and files like pom.xml.
What is the Maven Wrapper?
The generated project includes mvnw (macOS/Linux) and mvnw.cmd (Windows) files. These are the Maven Wrapper, which lets you run Maven commands without installing Maven globally on your machine.
Don't see the project structure?
Make sure you opened the exchange-rate-api folder itself, not the folder containing it. The pom.xml file should be at the top level in the Explorer sidebar.
Help me figure out what went wrong
Create the Docker Compose file and start containers
Your API needs two backing services: PostgreSQL to store exchange rate data, and Redis for caching and rate limiting. Docker Compose lets you define and run both containers with a single file.
- In VS Code's Explorer sidebar, right-click in the empty space below the file list.
- Click New File.
- Name the file compose.yaml and press Enter.
- Paste the following configuration into compose.yaml:
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: exchangeapi
POSTGRES_USER: apiuser
POSTGRES_PASSWORD: apipass
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
postgres_data:
What does this file do?
- postgres:16-alpine runs PostgreSQL 16 in a lightweight Alpine container. The environment variables create a database called exchangeapi with credentials apiuser/apipass.
- redis:7-alpine runs Redis 7 on port 6379. You will use this for both caching and rate limiting later.
- The postgres_data volume persists your database data between container restarts, so you do not lose seeded data.
- Save compose.yaml.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved and move on to start the containers.
ⓧ I'd like to double check the full code
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: exchangeapi
POSTGRES_USER: apiuser
POSTGRES_PASSWORD: apipass
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
postgres_data:
Now start both containers from your terminal.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Start the PostgreSQL and Redis containers by running this command:
docker compose up -d
- Verify both containers are running by running:
docker compose ps
You should see two containers listed with a status of running: one for postgres and one for redis.
Containers not starting?
Make sure Docker Desktop is running before you execute the command. If you see a port conflict error, another service may already be using port 5432 or 6379. Stop the conflicting service or change the host port in compose.yaml.
Help me troubleshoot my Docker containers
Configure the application properties
Spring Boot uses application.properties to configure database connections, caching, and other behaviors. You need to tell the app how to reach the PostgreSQL and Redis containers you just started.
- In the Explorer sidebar, expand src > main > resources.
- Open the existing application.properties file.
- Replace its contents with the following configuration:
spring.application.name=exchange-rate-api
spring.datasource.url=jdbc:postgresql://localhost:5432/exchangeapi
spring.datasource.username=apiuser
spring.datasource.password=apipass
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.cache.type=redis
spring.cache.redis.time-to-live=60000
What does each section configure?
- spring.datasource.* connects the app to your PostgreSQL container using the same credentials from compose.yaml.
- spring.jpa.hibernate.ddl-auto=update tells Hibernate to create or update database tables automatically based on your Java entities.
- spring.jpa.show-sql=true logs every SQL query so you can verify database interactions.
- spring.data.redis.* points the app to your Redis container on port 6379.
- spring.cache.type=redis tells Spring to use Redis as the caching backend. spring.cache.redis.time-to-live=60000 sets cached entries to expire after 60 seconds.
- Save application.properties.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before running the app.
ⓧ I'd like to double check the full code
spring.application.name=exchange-rate-api
spring.datasource.url=jdbc:postgresql://localhost:5432/exchangeapi
spring.datasource.username=apiuser
spring.datasource.password=apipass
spring.datasource.driver-class-name=org.postgresql.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.cache.type=redis
spring.cache.redis.time-to-live=60000
Now verify the application starts correctly.
- In your terminal, start the app by running:
macOS
./mvnw spring-boot:run
Windows
.\mvnw spring-boot:run
What happens on first run?
The Maven Wrapper downloads Maven and all project dependencies the first time you run this command. This may take a minute or two depending on your internet speed.
You should see the Spring Boot banner followed by log output ending with a line like Started ExchangeRateApiApplication. This confirms your app is connected to both PostgreSQL and Redis.
- Press Ctrl+C in the terminal to stop the application.
App fails to start?
If you see a connection refused error, make sure your Docker containers are still running with docker compose ps. If they stopped, run docker compose up -d again.
If you see a credentials error, double-check that the username and password in application.properties match the values in compose.yaml exactly: apiuser and apipass.
Help me debug my Spring Boot startup error
Your project skeleton is ready with both databases running. Next up, you will build the data layer with JPA entities and seed the database with currencies and exchange rates.
Build the Data Layer and Seed Data
In Step 1, you set up your Spring Boot project with Docker containers running PostgreSQL and Redis.
Now it's time to define the data your API will serve. Spring Boot uses JPA entities to map Java classes directly to database tables. Before you can serve exchange rates, the application needs to know what a currency looks like, how rates are structured, and how to store API keys for authentication.
In this step, get ready to:
- Create JPA entities for currencies, exchange rates, and API keys.
- Define Spring Data JPA repositories for database access.
- Seed the database with sample data on application startup.
Create the JPA entities
JPA entities are Java classes annotated so that Spring Boot can automatically create and manage database tables from them. You'll create three entities representing the core data of your exchange rate API.
- In VS Code, create a new file at src/main/java/com/example/exchangerateapi/model/Currency.java.
- Paste the following code into the file:
package com.example.exchangerateapi.model;
import jakarta.persistence.*;
@Entity
@Table(name = "currencies")
public class Currency {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 3)
private String code;
@Column(nullable = false)
private String name;
public Currency() {}
public Currency(String code, String name) {
this.code = code;
this.name = name;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getCode() { return code; }
public void setCode(String code) { this.code = code; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
}
What does this code do?
This class maps to a currencies table in PostgreSQL.
- @Entity tells JPA this class represents a database table.
- @Table(name = "currencies") sets the exact table name.
- @Id and @GeneratedValue mark the primary key as auto-incrementing.
- @Column constraints enforce that currency codes are unique, non-null, and max 3 characters (like USD or EUR).
- Create the next file at src/main/java/com/example/exchangerateapi/model/ExchangeRate.java.
- Paste the following code:
package com.example.exchangerateapi.model;
import jakarta.persistence.*;
import java.io.Serializable;
import java.time.LocalDateTime;
@Entity
@Table(name = "exchange_rates")
public class ExchangeRate implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 3)
private String baseCurrency;
@Column(nullable = false, length = 3)
private String targetCurrency;
@Column(nullable = false)
private Double rate;
@Column(nullable = false)
private LocalDateTime lastUpdated;
public ExchangeRate() {}
public ExchangeRate(String baseCurrency, String targetCurrency, Double rate) {
this.baseCurrency = baseCurrency;
this.targetCurrency = targetCurrency;
this.rate = rate;
this.lastUpdated = LocalDateTime.now();
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getBaseCurrency() { return baseCurrency; }
public void setBaseCurrency(String baseCurrency) { this.baseCurrency = baseCurrency; }
public String getTargetCurrency() { return targetCurrency; }
public void setTargetCurrency(String targetCurrency) { this.targetCurrency = targetCurrency; }
public Double getRate() { return rate; }
public void setRate(Double rate) { this.rate = rate; }
public LocalDateTime getLastUpdated() { return lastUpdated; }
public void setLastUpdated(LocalDateTime lastUpdated) { this.lastUpdated = lastUpdated; }
}
Why does ExchangeRate implement Serializable?
Later in this project, you'll cache exchange rates in Redis. Redis needs to serialize Java objects to store them. Without Serializable, the caching layer throws a serialization error when it tries to store an ExchangeRate object.
- Create the last entity file at src/main/java/com/example/exchangerateapi/model/ApiKey.java.
- Paste the following code:
package com.example.exchangerateapi.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "api_keys")
public class ApiKey {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, name = "api_key")
private String key;
@Column(nullable = false)
private String tier;
@Column(nullable = false)
private LocalDateTime createdAt;
public ApiKey() {}
public ApiKey(String key, String tier) {
this.key = key;
this.tier = tier;
this.createdAt = LocalDateTime.now();
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getKey() { return key; }
public void setKey(String key) { this.key = key; }
public String getTier() { return tier; }
public void setTier(String tier) { this.tier = tier; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
What does the ApiKey entity do?
This entity stores API keys that clients use to authenticate requests. Each key has a tier field (free or paid) that you'll use later to control rate limits and data access.
The name = "api_key" annotation on the key field tells JPA to name the database column api_key instead of key, which is a reserved word in SQL.
Define the Spring Data JPA repositories
Repositories are interfaces that Spring Data JPA implements automatically. You define method signatures, and Spring generates the SQL queries for you.
- Create a new file at src/main/java/com/example/exchangerateapi/repository/CurrencyRepository.java.
- Paste the following code:
package com.example.exchangerateapi.repository;
import com.example.exchangerateapi.model.Currency;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CurrencyRepository extends JpaRepository<Currency, Long> {
}
- Create src/main/java/com/example/exchangerateapi/repository/ExchangeRateRepository.java.
- Paste the following code:
package com.example.exchangerateapi.repository;
import com.example.exchangerateapi.model.ExchangeRate;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface ExchangeRateRepository extends JpaRepository<ExchangeRate, Long> {
Optional<ExchangeRate> findByBaseCurrencyAndTargetCurrency(String baseCurrency, String targetCurrency);
List<ExchangeRate> findByBaseCurrency(String baseCurrency);
}
- Create src/main/java/com/example/exchangerateapi/repository/ApiKeyRepository.java.
- Paste the following code:
package com.example.exchangerateapi.repository;
import com.example.exchangerateapi.model.ApiKey;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ApiKeyRepository extends JpaRepository<ApiKey, Long> {
Optional<ApiKey> findByKey(String key);
}
How do these repositories work?
By extending JpaRepository, each interface automatically gets methods like save(), findAll(), count(), and deleteById() without writing any SQL.
Custom methods like findByBaseCurrencyAndTargetCurrency use Spring Data's query derivation. Spring parses the method name and generates the correct SQL WHERE clause automatically.
- Save all six files.
You won't see a visible change yet. The next substep creates a data seeder that populates these tables when the app starts.
Seeing red underlines in VS Code?
Import errors are common before the project compiles for the first time. Make sure each file's package declaration matches its folder path exactly. For example, a file in model/ must declare package com.example.exchangerateapi.model;.
Help me fix import errors in my JPA entity files.
✔️ Awesome, I've got everything!
Great. Make sure you've saved all six files before moving on.
ⓧ I'd like to double check the full code
Here are all the entity and repository files for comparison:
package com.example.exchangerateapi.model;
import jakarta.persistence.*;
@Entity
@Table(name = "currencies")
public class Currency {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 3)
private String code;
@Column(nullable = false)
private String name;
public Currency() {}
public Currency(String code, String name) {
this.code = code;
this.name = name;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getCode() { return code; }
public void setCode(String code) { this.code = code; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
}
package com.example.exchangerateapi.model;
import jakarta.persistence.*;
import java.io.Serializable;
import java.time.LocalDateTime;
@Entity
@Table(name = "exchange_rates")
public class ExchangeRate implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 3)
private String baseCurrency;
@Column(nullable = false, length = 3)
private String targetCurrency;
@Column(nullable = false)
private Double rate;
@Column(nullable = false)
private LocalDateTime lastUpdated;
public ExchangeRate() {}
public ExchangeRate(String baseCurrency, String targetCurrency, Double rate) {
this.baseCurrency = baseCurrency;
this.targetCurrency = targetCurrency;
this.rate = rate;
this.lastUpdated = LocalDateTime.now();
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getBaseCurrency() { return baseCurrency; }
public void setBaseCurrency(String baseCurrency) { this.baseCurrency = baseCurrency; }
public String getTargetCurrency() { return targetCurrency; }
public void setTargetCurrency(String targetCurrency) { this.targetCurrency = targetCurrency; }
public Double getRate() { return rate; }
public void setRate(Double rate) { this.rate = rate; }
public LocalDateTime getLastUpdated() { return lastUpdated; }
public void setLastUpdated(LocalDateTime lastUpdated) { this.lastUpdated = lastUpdated; }
}
package com.example.exchangerateapi.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "api_keys")
public class ApiKey {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, name = "api_key")
private String key;
@Column(nullable = false)
private String tier;
@Column(nullable = false)
private LocalDateTime createdAt;
public ApiKey() {}
public ApiKey(String key, String tier) {
this.key = key;
this.tier = tier;
this.createdAt = LocalDateTime.now();
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getKey() { return key; }
public void setKey(String key) { this.key = key; }
public String getTier() { return tier; }
public void setTier(String tier) { this.tier = tier; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
package com.example.exchangerateapi.repository;
import com.example.exchangerateapi.model.Currency;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CurrencyRepository extends JpaRepository<Currency, Long> {
}
package com.example.exchangerateapi.repository;
import com.example.exchangerateapi.model.ExchangeRate;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface ExchangeRateRepository extends JpaRepository<ExchangeRate, Long> {
Optional<ExchangeRate> findByBaseCurrencyAndTargetCurrency(String baseCurrency, String targetCurrency);
List<ExchangeRate> findByBaseCurrency(String baseCurrency);
}
package com.example.exchangerateapi.repository;
import com.example.exchangerateapi.model.ApiKey;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ApiKeyRepository extends JpaRepository<ApiKey, Long> {
Optional<ApiKey> findByKey(String key);
}
Create the data seeder
An API with empty tables isn't very useful. You'll create a CommandLineRunner component that populates the database with currencies, exchange rates, and API keys every time the application starts with an empty database.
- Create a new file at src/main/java/com/example/exchangerateapi/DataSeeder.java.
- Paste the following code:
package com.example.exchangerateapi;
import com.example.exchangerateapi.model.ApiKey;
import com.example.exchangerateapi.model.Currency;
import com.example.exchangerateapi.model.ExchangeRate;
import com.example.exchangerateapi.repository.ApiKeyRepository;
import com.example.exchangerateapi.repository.CurrencyRepository;
import com.example.exchangerateapi.repository.ExchangeRateRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class DataSeeder implements CommandLineRunner {
private final CurrencyRepository currencyRepository;
private final ExchangeRateRepository exchangeRateRepository;
private final ApiKeyRepository apiKeyRepository;
public DataSeeder(CurrencyRepository currencyRepository,
ExchangeRateRepository exchangeRateRepository,
ApiKeyRepository apiKeyRepository) {
this.currencyRepository = currencyRepository;
this.exchangeRateRepository = exchangeRateRepository;
this.apiKeyRepository = apiKeyRepository;
}
@Override
public void run(String... args) {
if (currencyRepository.count() > 0) {
return;
}
currencyRepository.save(new Currency("USD", "US Dollar"));
currencyRepository.save(new Currency("EUR", "Euro"));
currencyRepository.save(new Currency("GBP", "British Pound"));
currencyRepository.save(new Currency("JPY", "Japanese Yen"));
currencyRepository.save(new Currency("AUD", "Australian Dollar"));
currencyRepository.save(new Currency("CAD", "Canadian Dollar"));
currencyRepository.save(new Currency("CHF", "Swiss Franc"));
currencyRepository.save(new Currency("CNY", "Chinese Yuan"));
exchangeRateRepository.save(new ExchangeRate("USD", "EUR", 0.92));
exchangeRateRepository.save(new ExchangeRate("USD", "GBP", 0.79));
exchangeRateRepository.save(new ExchangeRate("USD", "JPY", 157.50));
exchangeRateRepository.save(new ExchangeRate("USD", "AUD", 1.53));
exchangeRateRepository.save(new ExchangeRate("USD", "CAD", 1.37));
exchangeRateRepository.save(new ExchangeRate("USD", "CHF", 0.89));
exchangeRateRepository.save(new ExchangeRate("USD", "CNY", 7.24));
exchangeRateRepository.save(new ExchangeRate("EUR", "USD", 1.09));
exchangeRateRepository.save(new ExchangeRate("EUR", "GBP", 0.86));
exchangeRateRepository.save(new ExchangeRate("GBP", "USD", 1.27));
apiKeyRepository.save(new ApiKey("free-test-key-001", "free"));
apiKeyRepository.save(new ApiKey("paid-test-key-001", "paid"));
}
}
What does this code do?
- @Component registers this class as a Spring-managed bean, and CommandLineRunner tells Spring to execute the run() method on every application startup.
- The if (currencyRepository.count() > 0) guard prevents duplicate data. If the tables already have rows (from a previous run), the seeder skips everything.
- It seeds 8 currencies, 10 exchange rates between common pairs, and 2 API keys (one free-tier, one paid-tier) for testing.
- Save DataSeeder.java.
✔️ Awesome, I've got everything!
Great. Make sure DataSeeder.java is saved before moving on to verification.
ⓧ I'd like to double check the full code
package com.example.exchangerateapi;
import com.example.exchangerateapi.model.ApiKey;
import com.example.exchangerateapi.model.Currency;
import com.example.exchangerateapi.model.ExchangeRate;
import com.example.exchangerateapi.repository.ApiKeyRepository;
import com.example.exchangerateapi.repository.CurrencyRepository;
import com.example.exchangerateapi.repository.ExchangeRateRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class DataSeeder implements CommandLineRunner {
private final CurrencyRepository currencyRepository;
private final ExchangeRateRepository exchangeRateRepository;
private final ApiKeyRepository apiKeyRepository;
public DataSeeder(CurrencyRepository currencyRepository,
ExchangeRateRepository exchangeRateRepository,
ApiKeyRepository apiKeyRepository) {
this.currencyRepository = currencyRepository;
this.exchangeRateRepository = exchangeRateRepository;
this.apiKeyRepository = apiKeyRepository;
}
@Override
public void run(String... args) {
if (currencyRepository.count() > 0) {
return;
}
currencyRepository.save(new Currency("USD", "US Dollar"));
currencyRepository.save(new Currency("EUR", "Euro"));
currencyRepository.save(new Currency("GBP", "British Pound"));
currencyRepository.save(new Currency("JPY", "Japanese Yen"));
currencyRepository.save(new Currency("AUD", "Australian Dollar"));
currencyRepository.save(new Currency("CAD", "Canadian Dollar"));
currencyRepository.save(new Currency("CHF", "Swiss Franc"));
currencyRepository.save(new Currency("CNY", "Chinese Yuan"));
exchangeRateRepository.save(new ExchangeRate("USD", "EUR", 0.92));
exchangeRateRepository.save(new ExchangeRate("USD", "GBP", 0.79));
exchangeRateRepository.save(new ExchangeRate("USD", "JPY", 157.50));
exchangeRateRepository.save(new ExchangeRate("USD", "AUD", 1.53));
exchangeRateRepository.save(new ExchangeRate("USD", "CAD", 1.37));
exchangeRateRepository.save(new ExchangeRate("USD", "CHF", 0.89));
exchangeRateRepository.save(new ExchangeRate("USD", "CNY", 7.24));
exchangeRateRepository.save(new ExchangeRate("EUR", "USD", 1.09));
exchangeRateRepository.save(new ExchangeRate("EUR", "GBP", 0.86));
exchangeRateRepository.save(new ExchangeRate("GBP", "USD", 1.27));
apiKeyRepository.save(new ApiKey("free-test-key-001", "free"));
apiKeyRepository.save(new ApiKey("paid-test-key-001", "paid"));
}
}
Verify the seed data
Time to confirm that your entities, repositories, and seeder all work together. You'll start the application and check that Hibernate created the tables and the seeder populated them with data.
- Stop the application if it's still running from Step 1 by pressing Ctrl+C in the terminal.
- Start the application again by running:
./mvnw spring-boot:run
In the terminal output, you should see multiple Hibernate SQL statements starting with Hibernate: insert into. These confirm the seeder is inserting data into PostgreSQL.
- Open a second terminal in VS Code by clicking Terminal in the top menu bar, then New Terminal.
- Query the database directly through the PostgreSQL container by running:
docker compose exec postgres psql -U apiuser -d exchangeapi -c "SELECT code, name FROM currencies;"
You should see a table with 8 rows listing currency codes and names (USD, EUR, GBP, JPY, AUD, CAD, CHF, CNY).
- Verify the API keys were seeded by running:
docker compose exec postgres psql -U apiuser -d exchangeapi -c "SELECT api_key, tier FROM api_keys;"
You should see two rows: free-test-key-001 with tier free, and paid-test-key-001 with tier paid.
App fails to start or no data appears?
- Check that your Docker containers are running with docker compose ps. Both postgres and redis should show status Up.
- If you see a compilation error, double-check that every file's package declaration matches its directory path. Files in model/ must use package com.example.exchangerateapi.model;.
- If the app starts but the query returns 0 rows, the seeder may have run on a previous startup. Run docker compose down -v and then docker compose up -d to reset the database, then restart the app.
Help me debug why my Spring Boot app won't start or seed data into PostgreSQL.
Your data layer is solid. You have entities mapped to database tables, repositories for querying them, and a seeder that populates test data automatically. Next up, you'll build the REST API that serves this data to clients.
Create the REST API with API Key Authentication
You have a database full of exchange rate data, but no way for external clients to access it. In this step, you will build the REST endpoints that expose your data and protect them with API key authentication.
Every request to your API will need to include a valid API key in the header. Invalid or missing keys get rejected before the request reaches your controller, keeping your data layer safe from unauthorized access.
In this step, get ready to:
- Create a service and controller that expose exchange rate data via REST endpoints.
- Build an API key interceptor that validates requests before they reach the controller.
- Register the interceptor and test the full authentication flow with curl.
Create the ExchangeRateService and RateController
The service layer encapsulates your database queries so the controller never talks to the repository directly. This separation makes your code easier to test and extend later.
- Create a new folder called service inside src/main/java/com/example/exchangerateapi/.
- Create a new file called ExchangeRateService.java inside the service folder, then paste in the following code:
package com.example.exchangerateapi.service;
import com.example.exchangerateapi.model.ExchangeRate;
import com.example.exchangerateapi.repository.ExchangeRateRepository;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class ExchangeRateService {
private final ExchangeRateRepository exchangeRateRepository;
public ExchangeRateService(ExchangeRateRepository exchangeRateRepository) {
this.exchangeRateRepository = exchangeRateRepository;
}
// Cache individual rate lookups with a composite key
@Cacheable(value = "rates", key = "#base + '_' + #target")
public Optional<ExchangeRate> getRate(String base, String target) {
return exchangeRateRepository.findByBaseCurrencyAndTargetCurrency(base, target);
}
// Cache all rates for a base currency
@Cacheable(value = "rates", key = "#base + '_all'")
public List<ExchangeRate> getAllRatesForBase(String base) {
return exchangeRateRepository.findByBaseCurrency(base);
}
}
What does this code do?
The @Service annotation registers this class as a Spring-managed bean that gets injected wherever needed.
The @Cacheable annotations tell Spring to cache the return value in Redis the first time each method is called with a given key. Subsequent calls with the same key return the cached value without hitting PostgreSQL.
- Save ExchangeRateService.java.
You won't see a change yet. The next file (the controller) uses this service to serve HTTP responses.
Now let's create the controller that maps HTTP requests to your service methods.
- Create a new folder called controller inside src/main/java/com/example/exchangerateapi/.
- Create a new file called RateController.java inside the controller folder, then paste in the following code:
package com.example.exchangerateapi.controller;
import com.example.exchangerateapi.model.ExchangeRate;
import com.example.exchangerateapi.service.ExchangeRateService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@RestController
@RequestMapping("/api/rates")
public class RateController {
private final ExchangeRateService exchangeRateService;
public RateController(ExchangeRateService exchangeRateService) {
this.exchangeRateService = exchangeRateService;
}
// GET /api/rates/USD/EUR - returns a single exchange rate
@GetMapping("/{base}/{target}")
public ResponseEntity<?> getRate(@PathVariable String base, @PathVariable String target) {
Optional<ExchangeRate> rate = exchangeRateService.getRate(base.toUpperCase(), target.toUpperCase());
if (rate.isEmpty()) {
return ResponseEntity.status(404).body(Map.of("error", "Rate not found for " + base + "/" + target));
}
return ResponseEntity.ok(Map.of(
"base", rate.get().getBaseCurrency(),
"target", rate.get().getTargetCurrency(),
"rate", rate.get().getRate(),
"lastUpdated", rate.get().getLastUpdated().toString()
));
}
// GET /api/rates/USD - returns all rates for a base currency
@GetMapping("/{base}")
public ResponseEntity<?> getAllRates(@PathVariable String base) {
List<ExchangeRate> rates = exchangeRateService.getAllRatesForBase(base.toUpperCase());
if (rates.isEmpty()) {
return ResponseEntity.status(404).body(Map.of("error", "No rates found for base currency " + base));
}
List<Map<String, Object>> rateList = rates.stream()
.map(r -> Map.<String, Object>of(
"target", r.getTargetCurrency(),
"rate", r.getRate(),
"lastUpdated", r.getLastUpdated().toString()
))
.toList();
return ResponseEntity.ok(Map.of("base", base.toUpperCase(), "rates", rateList));
}
}
What does this code do?
The @RestController annotation tells Spring this class handles HTTP requests and returns JSON directly (no view templates).
The @RequestMapping("/api/rates") sets the base path for all endpoints in this controller. The two @GetMapping methods handle fetching a single rate (/api/rates/USD/EUR) or all rates for a base currency (/api/rates/USD).
- Save RateController.java.
You won't test these endpoints yet because they are currently open to anyone. The next substep locks them down with API key authentication.
Create the ApiKeyInterceptor
A HandlerInterceptor runs before your controller method executes. If the interceptor returns false, Spring stops the request chain and the controller never fires.
- Create a new folder called interceptor inside src/main/java/com/example/exchangerateapi/.
- Create a new file called ApiKeyInterceptor.java inside the interceptor folder, then paste in the following code:
package com.example.exchangerateapi.interceptor;
import com.example.exchangerateapi.model.ApiKey;
import com.example.exchangerateapi.repository.ApiKeyRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.Optional;
@Component
public class ApiKeyInterceptor implements HandlerInterceptor {
private final ApiKeyRepository apiKeyRepository;
public ApiKeyInterceptor(ApiKeyRepository apiKeyRepository) {
this.apiKeyRepository = apiKeyRepository;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// Read the API key from the request header
String apiKeyHeader = request.getHeader("X-API-Key");
// Reject if no key provided
if (apiKeyHeader == null || apiKeyHeader.isBlank()) {
response.setStatus(401);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Missing X-API-Key header\"}");
return false;
}
// Look up the key in the database
Optional<ApiKey> apiKey = apiKeyRepository.findByKey(apiKeyHeader);
// Reject if key doesn't exist
if (apiKey.isEmpty()) {
response.setStatus(401);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Invalid API key\"}");
return false;
}
// Store the resolved key for downstream use
request.setAttribute("apiKey", apiKey.get());
return true;
}
}
What does this code do?
The preHandle method runs before every controller method mapped to the interceptor's path pattern. It checks for the X-API-Key header, validates it against the database, and returns false (blocking the request) if anything is wrong.
When the key is valid, the interceptor stores the resolved ApiKey entity in the request attributes via request.setAttribute("apiKey", ...). Later interceptors or controllers can access this object without querying the database again.
- Save ApiKeyInterceptor.java.
The interceptor exists but Spring doesn't know to use it yet. The next substep registers it.
Why HandlerInterceptor instead of Spring Security?
Spring Security is a powerful framework, but it adds significant configuration overhead for a straightforward API key check. A HandlerInterceptor integrates directly with Spring MVC, supports ordered execution and path-pattern scoping via WebMvcConfigurer, and keeps the focus on the caching and rate-limiting concepts this project teaches.
Register the interceptor and test the API
The interceptor needs to be registered with Spring's request pipeline so it applies to the correct URL paths. You will also enable caching on the application class so the @Cacheable annotations take effect.
- Open ExchangeRateApiApplication.java (in src/main/java/com/example/exchangerateapi/).
- Find this line:
@SpringBootApplication
- Add @EnableCaching directly below it, and add the corresponding import at the top of the file so it looks like this:
package com.example.exchangerateapi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching
public class ExchangeRateApiApplication {
public static void main(String[] args) {
SpringApplication.run(ExchangeRateApiApplication.class, args);
}
}
What does @EnableCaching do?
This annotation activates Spring's caching infrastructure. Without it, the @Cacheable annotations on your service methods are ignored and every call hits the database.
- Save ExchangeRateApiApplication.java.
Now create the configuration class that registers your interceptor.
- Create a new folder called config inside src/main/java/com/example/exchangerateapi/.
- Create a new file called WebConfig.java inside the config folder, then paste in the following code:
package com.example.exchangerateapi.config;
import com.example.exchangerateapi.interceptor.ApiKeyInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final ApiKeyInterceptor apiKeyInterceptor;
public WebConfig(ApiKeyInterceptor apiKeyInterceptor) {
this.apiKeyInterceptor = apiKeyInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
// Apply API key check to all /api/** endpoints
registry.addInterceptor(apiKeyInterceptor)
.addPathPatterns("/api/**")
.order(1);
}
}
What does this code do?
The WebMvcConfigurer interface lets you customize Spring MVC's configuration without replacing its defaults. Here, addInterceptors registers your ApiKeyInterceptor for all paths matching /api/**.
The .order(1) sets the execution priority. You will add a second interceptor (rate limiting) at order(2) in a later step, so authentication always runs first.
- Save WebConfig.java.
- Restart your application by pressing Ctrl+C in the terminal running the app, then run it again:
./mvnw spring-boot:run
- Wait for the Started ExchangeRateApiApplication log line to appear.
Now test the authentication flow. Open a second terminal window and try a request without an API key:
- Test an unauthenticated request by running:
curl -s http://localhost:8080/api/rates/USD/EUR
You should see a 401 JSON error response:
{"error":"Missing X-API-Key header"}
- Now test with a valid API key by running:
curl -s -H "X-API-Key: free-test-key-001" http://localhost:8080/api/rates/USD/EUR
You should see exchange rate data returned as JSON, including the base currency, target currency, rate, and last updated timestamp.
Seeing a 401 even with the key?
Make sure you typed the header name exactly as X-API-Key (case-sensitive in some environments). Also verify the key value matches free-test-key-001 from your seed data.
If you restarted Docker or deleted the volume, the database may be empty. Check the application logs for seed data messages.
help me debug my API key authentication
- Test fetching all rates for a base currency by running:
curl -s -H "X-API-Key: free-test-key-001" http://localhost:8080/api/rates/USD
You should see a JSON object with a base field set to USD and a rates array containing all seven USD exchange rates from your seed data.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file created or modified in this step.
package com.example.exchangerateapi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching
public class ExchangeRateApiApplication {
public static void main(String[] args) {
SpringApplication.run(ExchangeRateApiApplication.class, args);
}
}
package com.example.exchangerateapi.service;
import com.example.exchangerateapi.model.ExchangeRate;
import com.example.exchangerateapi.repository.ExchangeRateRepository;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class ExchangeRateService {
private final ExchangeRateRepository exchangeRateRepository;
public ExchangeRateService(ExchangeRateRepository exchangeRateRepository) {
this.exchangeRateRepository = exchangeRateRepository;
}
@Cacheable(value = "rates", key = "#base + '_' + #target")
public Optional<ExchangeRate> getRate(String base, String target) {
return exchangeRateRepository.findByBaseCurrencyAndTargetCurrency(base, target);
}
@Cacheable(value = "rates", key = "#base + '_all'")
public List<ExchangeRate> getAllRatesForBase(String base) {
return exchangeRateRepository.findByBaseCurrency(base);
}
}
package com.example.exchangerateapi.controller;
import com.example.exchangerateapi.model.ExchangeRate;
import com.example.exchangerateapi.service.ExchangeRateService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@RestController
@RequestMapping("/api/rates")
public class RateController {
private final ExchangeRateService exchangeRateService;
public RateController(ExchangeRateService exchangeRateService) {
this.exchangeRateService = exchangeRateService;
}
@GetMapping("/{base}/{target}")
public ResponseEntity<?> getRate(@PathVariable String base, @PathVariable String target) {
Optional<ExchangeRate> rate = exchangeRateService.getRate(base.toUpperCase(), target.toUpperCase());
if (rate.isEmpty()) {
return ResponseEntity.status(404).body(Map.of("error", "Rate not found for " + base + "/" + target));
}
return ResponseEntity.ok(Map.of(
"base", rate.get().getBaseCurrency(),
"target", rate.get().getTargetCurrency(),
"rate", rate.get().getRate(),
"lastUpdated", rate.get().getLastUpdated().toString()
));
}
@GetMapping("/{base}")
public ResponseEntity<?> getAllRates(@PathVariable String base) {
List<ExchangeRate> rates = exchangeRateService.getAllRatesForBase(base.toUpperCase());
if (rates.isEmpty()) {
return ResponseEntity.status(404).body(Map.of("error", "No rates found for base currency " + base));
}
List<Map<String, Object>> rateList = rates.stream()
.map(r -> Map.<String, Object>of(
"target", r.getTargetCurrency(),
"rate", r.getRate(),
"lastUpdated", r.getLastUpdated().toString()
))
.toList();
return ResponseEntity.ok(Map.of("base", base.toUpperCase(), "rates", rateList));
}
}
package com.example.exchangerateapi.interceptor;
import com.example.exchangerateapi.model.ApiKey;
import com.example.exchangerateapi.repository.ApiKeyRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.Optional;
@Component
public class ApiKeyInterceptor implements HandlerInterceptor {
private final ApiKeyRepository apiKeyRepository;
public ApiKeyInterceptor(ApiKeyRepository apiKeyRepository) {
this.apiKeyRepository = apiKeyRepository;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String apiKeyHeader = request.getHeader("X-API-Key");
if (apiKeyHeader == null || apiKeyHeader.isBlank()) {
response.setStatus(401);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Missing X-API-Key header\"}");
return false;
}
Optional<ApiKey> apiKey = apiKeyRepository.findByKey(apiKeyHeader);
if (apiKey.isEmpty()) {
response.setStatus(401);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Invalid API key\"}");
return false;
}
request.setAttribute("apiKey", apiKey.get());
return true;
}
}
package com.example.exchangerateapi.config;
import com.example.exchangerateapi.interceptor.ApiKeyInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final ApiKeyInterceptor apiKeyInterceptor;
public WebConfig(ApiKeyInterceptor apiKeyInterceptor) {
this.apiKeyInterceptor = apiKeyInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(apiKeyInterceptor)
.addPathPatterns("/api/**")
.order(1);
}
}
Your API is now serving data and rejecting unauthorized requests. Next up, you will see Redis caching in action by measuring the speed difference between cached and uncached responses.
Add Redis Caching
In the previous step, you built the REST API and secured it with API key authentication. Every request currently hits PostgreSQL directly, even if someone asks for the same exchange rate ten times in a row.
In this step, you will activate and verify Redis caching so repeated lookups skip the database entirely. The configuration and annotations are already in place from your project setup. Your job is to understand how they work, then prove caching is active by measuring the difference.
In this step, get ready to:
- Review the Redis cache configuration in your application properties.
- Understand the @Cacheable annotations on your service methods.
- Test the cache by comparing first and second request times with curl.
Review the Redis cache configuration
For Spring Boot to use Redis as its cache backend, two things need to be in place: the @EnableCaching annotation on the application class, and Redis-specific properties in application.properties.
- In VS Code, open ExchangeRateApiApplication.java and locate the @EnableCaching annotation above the class declaration:
@SpringBootApplication
@EnableCaching
public class ExchangeRateApiApplication {
- Now open application.properties and find the two Redis cache lines at the bottom:
spring.cache.type=redis
spring.cache.redis.time-to-live=60000
What do these properties do?
spring.cache.type=redis tells Spring Boot to use Redis (instead of an in-memory map) as the cache store. This means cached data persists across service restarts and can be shared between instances.
spring.cache.redis.time-to-live=60000 sets the TTL (time to live) to 60,000 milliseconds (60 seconds). After 60 seconds, cached entries expire and the next request fetches fresh data from PostgreSQL.
Understand the @Cacheable annotations
The @Cacheable annotation intercepts method calls. Before executing the method body, Spring checks Redis for a matching cache entry. If one exists, it returns the cached value without touching PostgreSQL.
- Open ExchangeRateService.java and find the two annotated methods:
@Cacheable(value = "rates", key = "#base + '_' + #target")
public Optional<ExchangeRate> getRate(String base, String target) {
return exchangeRateRepository.findByBaseCurrencyAndTargetCurrency(base, target);
}
@Cacheable(value = "rates", key = "#base + '_all'")
public List<ExchangeRate> getAllRatesForBase(String base) {
return exchangeRateRepository.findByBaseCurrency(base);
}
How does the cache key work?
The value = "rates" defines the cache name. All exchange rate entries live in a logical group called rates.
The key attribute builds a unique lookup string from the method parameters. For example, calling getRate("USD", "EUR") generates the Redis key USD_EUR. The next call with the same arguments finds this key in Redis and skips the database entirely.
What about @CacheEvict?
If you later add methods that update exchange rates, you would annotate them with @CacheEvict(value = "rates", allEntries = true) to clear stale cached values. For now, the seed data is static so eviction is not needed.
Test the cache with curl
The best way to prove caching works is to compare response times. The first request hits PostgreSQL (you will see SQL in the server logs). The second request within 60 seconds returns from Redis with no SQL query.
- Make sure your app is still running. If you stopped it earlier, restart it by running:
./mvnw spring-boot:run
- In a separate terminal, send your first request and measure the total response time by running:
curl -s -o /dev/null -w "Total time: %{time_total}s\n" -H "X-API-Key: free-test-key-001" http://localhost:8080/api/rates/USD/EUR
- Note the time printed (this is the database-hit request).
- Run the exact same command again immediately:
curl -s -o /dev/null -w "Total time: %{time_total}s\n" -H "X-API-Key: free-test-key-001" http://localhost:8080/api/rates/USD/EUR
- Compare the two times. The second request should be noticeably faster.
- Switch to the terminal running your Spring Boot app. Check the logs for the Hibernate: SQL output.
You should see SQL logged for the first request only. The second request produces no SQL output because the result came straight from Redis.
What should I see?
The first curl reports a time like 0.05-0.15 seconds (database round-trip). The second curl reports a much smaller time, often 0.01-0.03 seconds. In your server logs, you see a Hibernate: select ... line for the first request, and nothing for the second.
Both requests show the same time?
Make sure Redis is running. Check with docker compose ps and confirm the redis container shows as running.
Verify spring.cache.type=redis is present in your application.properties. If it is missing, Spring falls back to a no-op cache and every call hits the database.
If you restarted the app between calls, the cache was reset. Run both curl commands within 60 seconds of each other without restarting.
Still stuck? Help me debug why my Redis cache is not reducing response time.
- To confirm the data is in Redis, you can also peek inside the Redis container by running:
docker exec -it exchange-rate-api-redis-1 redis-cli KEYS "*"
You should see keys containing rates in the output, confirming Spring stored cached values there.
Container name not found?
Your Redis container name depends on your project folder name. Run docker compose ps to see the actual container name, then substitute it in the command above.
Need help? How do I find the correct Docker container name for redis-cli?
Your caching layer is confirmed working. Repeated requests now skip PostgreSQL and return instantly from Redis. Next up, you will build a rate limiter on top of Redis to enforce per-API-key request quotas.
Implement Redis Rate Limiting
Your API now serves exchange rates from PostgreSQL and caches them in Redis for 60 seconds. Repeated requests fly back instantly without touching the database.
Caching solves the performance problem, but there is still a fairness problem. Without rate limiting, a single consumer could flood your API with thousands of requests per minute and crowd out everyone else. In this step, you will build a fixed-window rate limiter that tracks each API key's usage in Redis and rejects requests that exceed the limit.
In this step, get ready to:
- Create a RateLimiterService that counts requests per key per minute window using Redis INCR and EXPIRE.
- Create a RateLimitInterceptor that enforces the limit and adds rate-limit response headers.
- Test the rate limiter by sending a burst of requests and watching the API return 429 after the limit is exceeded.
Create the RateLimiterService
Fixed-window rate limiting divides time into fixed intervals (windows). Each API key gets a counter that resets at the start of every window. Redis is perfect for this because its INCR command atomically increments a counter and EXPIRE automatically cleans up old windows.
- Create a new file at src/main/java/com/example/exchangerateapi/service/RateLimiterService.java and paste the following code:
package com.example.exchangerateapi.service;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
@Service
public class RateLimiterService {
private final StringRedisTemplate redisTemplate;
public RateLimiterService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public RateLimitResult isAllowed(String apiKey, int limit) {
long windowSeconds = 60;
long currentWindow = System.currentTimeMillis() / 1000 / windowSeconds;
String redisKey = "rl:" + apiKey + ":" + currentWindow;
Long currentCount = redisTemplate.opsForValue().increment(redisKey);
if (currentCount != null && currentCount == 1) {
redisTemplate.expire(redisKey, Duration.ofSeconds(windowSeconds));
}
boolean allowed = currentCount != null && currentCount <= limit;
long remaining = allowed ? limit - currentCount : 0;
return new RateLimitResult(allowed, limit, remaining);
}
public record RateLimitResult(boolean allowed, long limit, long remaining) {}
}
What does this code do?
The isAllowed method implements the fixed-window algorithm in three steps:
- It calculates the current 60-second window and builds a Redis key like rl:free-test-key-001:12345. Each window gets a unique key.
- It atomically increments the counter with opsForValue().increment(). If this is the first request in the window (count equals 1), it sets EXPIRE so Redis automatically deletes the key after 60 seconds.
- It returns a RateLimitResult record containing whether the request is allowed, the limit, and how many requests remain in the current window.
Why fixed-window over sliding window?
Fixed-window rate limiting with INCR + EXPIRE is the simplest production-grade pattern. It uses just two Redis operations per request.
Sliding window algorithms (using sorted sets) are more precise but add complexity. Fixed-window teaches the same Redis fundamentals and is used by many real APIs at scale.
- Save RateLimiterService.java.
You won't see a change yet. The next chunk creates the interceptor that calls this service on every request.
Create the RateLimitInterceptor
The service handles the counting logic, but something needs to call it on every incoming request. A HandlerInterceptor runs before the controller and can block the request if the limit is exceeded.
- Create a new file at src/main/java/com/example/exchangerateapi/interceptor/RateLimitInterceptor.java and paste the following code:
package com.example.exchangerateapi.interceptor;
import com.example.exchangerateapi.model.ApiKey;
import com.example.exchangerateapi.service.RateLimiterService;
import com.example.exchangerateapi.service.RateLimiterService.RateLimitResult;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
private static final int DEFAULT_LIMIT = 10;
private final RateLimiterService rateLimiterService;
public RateLimitInterceptor(RateLimiterService rateLimiterService) {
this.rateLimiterService = rateLimiterService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
ApiKey apiKey = (ApiKey) request.getAttribute("apiKey");
if (apiKey == null) {
return true;
}
RateLimitResult result = rateLimiterService.isAllowed(apiKey.getKey(), DEFAULT_LIMIT);
response.setHeader("X-RateLimit-Limit", String.valueOf(result.limit()));
response.setHeader("X-RateLimit-Remaining", String.valueOf(result.remaining()));
if (!result.allowed()) {
response.setStatus(429);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Rate limit exceeded. Try again later.\"}");
return false;
}
return true;
}
}
What does this interceptor do?
- It retrieves the ApiKey object that the ApiKeyInterceptor stored in the request attributes earlier in the chain.
- It calls isAllowed() with a DEFAULT_LIMIT of 10 requests per minute.
- It always sets X-RateLimit-Limit and X-RateLimit-Remaining response headers so consumers can track their usage.
- If the limit is exceeded, it returns HTTP 429 with a JSON error and blocks the request from reaching the controller.
- Save RateLimitInterceptor.java.
You won't see a change yet. The interceptor needs to be registered with Spring's request chain to take effect.
Seeing a compile error?
- Make sure RateLimiterService.java is saved in the service package and RateLimitInterceptor.java is in the interceptor package. The import paths must match.
- Check that the RateLimitResult record is inside the RateLimiterService class (not in a separate file).
Why won't my RateLimitInterceptor compile?
Register the interceptor and test the rate limiter
Your WebConfig.java already registers both interceptors from when you set it up in the earlier step. The key detail is the ordering.
- Open src/main/java/com/example/exchangerateapi/config/WebConfig.java and confirm the interceptor registration looks like this:
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(apiKeyInterceptor)
.addPathPatterns("/api/**")
.order(1);
registry.addInterceptor(rateLimitInterceptor)
.addPathPatterns("/api/**")
.order(2);
}
Why does order matter?
The ApiKeyInterceptor runs at order 1 (first). It validates the API key and stores it in the request attributes. The RateLimitInterceptor runs at order 2 (second) and reads that stored key to count requests.
If the order were reversed, the rate limiter would try to read a null API key and skip the check entirely.
- Stop the running application by pressing Ctrl+C in the terminal where it is running.
- Restart the application by running:
./mvnw spring-boot:run
- Wait for the application to start. You should see Started ExchangeRateApiApplication in the terminal output.
Now test the rate limiter by sending 12 requests in rapid succession. The limit is 10 per minute, so requests 11 and 12 should be rejected.
- Open a new terminal and run this curl loop to send 12 requests:
for i in $(seq 1 12); do curl -s -w "\n%{http_code}" -H "X-API-Key: free-test-key-001" http://localhost:8080/api/rates/USD/EUR; done
What should I see?
The first 10 requests return the exchange rate JSON with HTTP status 200. Requests 11 and 12 return a JSON error message with HTTP status 429.
The 429 responses contain: {"error":"Rate limit exceeded. Try again later."}. If you wait 60 seconds and run the loop again, the counter resets and all 10 requests succeed again.
- Verify the rate-limit headers by running a single request with verbose output:
curl -s -D - -H "X-API-Key: free-test-key-001" http://localhost:8080/api/rates/USD/EUR
- Look for the X-RateLimit-Limit and X-RateLimit-Remaining headers in the response. They show the consumer how many requests they have left in the current window.
All 12 requests returning 200?
- Make sure you restarted the application after creating both new files. The interceptor must be compiled and registered.
- Check that your WebConfig.java registers the rateLimitInterceptor with .addPathPatterns("/api/**"). Without the path pattern, it won't apply to your endpoints.
- Confirm your Docker Redis container is running with docker compose ps. If Redis is down, the increment calls fail silently and no counting occurs.
Why is my rate limiter not blocking requests after 10?
✔️ Awesome, I've got everything!
Great. Double check you have saved both new files before moving on.
ⓧ I'd like to double check the full code
Here is the full RateLimiterService.java:
package com.example.exchangerateapi.service;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
@Service
public class RateLimiterService {
private final StringRedisTemplate redisTemplate;
public RateLimiterService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public RateLimitResult isAllowed(String apiKey, int limit) {
long windowSeconds = 60;
long currentWindow = System.currentTimeMillis() / 1000 / windowSeconds;
String redisKey = "rl:" + apiKey + ":" + currentWindow;
Long currentCount = redisTemplate.opsForValue().increment(redisKey);
if (currentCount != null && currentCount == 1) {
redisTemplate.expire(redisKey, Duration.ofSeconds(windowSeconds));
}
boolean allowed = currentCount != null && currentCount <= limit;
long remaining = allowed ? limit - currentCount : 0;
return new RateLimitResult(allowed, limit, remaining);
}
public record RateLimitResult(boolean allowed, long limit, long remaining) {}
}
Here is the full RateLimitInterceptor.java:
package com.example.exchangerateapi.interceptor;
import com.example.exchangerateapi.model.ApiKey;
import com.example.exchangerateapi.service.RateLimiterService;
import com.example.exchangerateapi.service.RateLimiterService.RateLimitResult;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
private static final int DEFAULT_LIMIT = 10;
private final RateLimiterService rateLimiterService;
public RateLimitInterceptor(RateLimiterService rateLimiterService) {
this.rateLimiterService = rateLimiterService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
ApiKey apiKey = (ApiKey) request.getAttribute("apiKey");
if (apiKey == null) {
return true;
}
RateLimitResult result = rateLimiterService.isAllowed(apiKey.getKey(), DEFAULT_LIMIT);
response.setHeader("X-RateLimit-Limit", String.valueOf(result.limit()));
response.setHeader("X-RateLimit-Remaining", String.valueOf(result.remaining()));
if (!result.allowed()) {
response.setStatus(429);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Rate limit exceeded. Try again later.\"}");
return false;
}
return true;
}
}
Here is the full WebConfig.java:
package com.example.exchangerateapi.config;
import com.example.exchangerateapi.interceptor.ApiKeyInterceptor;
import com.example.exchangerateapi.interceptor.RateLimitInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final ApiKeyInterceptor apiKeyInterceptor;
private final RateLimitInterceptor rateLimitInterceptor;
public WebConfig(ApiKeyInterceptor apiKeyInterceptor, RateLimitInterceptor rateLimitInterceptor) {
this.apiKeyInterceptor = apiKeyInterceptor;
this.rateLimitInterceptor = rateLimitInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(apiKeyInterceptor)
.addPathPatterns("/api/**")
.order(1);
registry.addInterceptor(rateLimitInterceptor)
.addPathPatterns("/api/**")
.order(2);
}
}
Your API now authenticates consumers, caches hot lookups, and enforces per-key rate limits. These three layers working together is exactly what production APIs look like. Next up, you will tackle the Secret Mission and add tiered access control that differentiates between free and paid API keys.
Secret mission
Add Tiered Access Control
Your rate limiter treats every API key the same. Real APIs differentiate between free and paid tiers, giving premium users higher limits and broader data access. Can you extend your API to enforce per-tier rate limits and restrict free-tier keys to major currencies only?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using Docker containers, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker containers for PostgreSQL 16 and Redis 7.
- PostgreSQL data volume (postgres_data).
- Spring Boot project folder (exchange-rate-api).
Keep everything running
No action needed. Choose this if you're still actively developing the API or experimenting with different rate limit configurations.
- Your Docker containers will continue running in the background.
- Your Spring Boot app will remain available at http://localhost:8080.
- The PostgreSQL data volume persists your seed data between container restarts.
Pause - I'll come back to this later
Shut down running processes to free up memory and CPU. Your data is preserved in the Docker volume so you can pick up where you left off.
- Stop the Spring Boot app by pressing Ctrl+C in the terminal where it is running.
- Stop the Docker containers by running this command from your project folder:
docker compose stop
When you're ready to resume, run docker compose start and then ./mvnw spring-boot:run to get everything back up.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your containers, data volumes, and project files permanently.
- Stop the Spring Boot app by pressing Ctrl+C in the terminal where it is running.
- Remove the Docker containers and all data volumes by running this command from your project folder:
docker compose down -v
- Delete the project folder by running:
cd ~/Desktop
rm -rf exchange-rate-api
On Windows, use this command instead:
cd ~\Desktop
rmdir /s /q exchange-rate-api
Nice Work!
Nice Work!
You just built a production-style REST API with authentication, caching, and rate limiting from scratch. This is exactly the kind of backend engineering project that demonstrates real competence in interviews.
Here's what you learned:
- Built a Spring Boot REST API serving currency exchange rates from PostgreSQL, with JPA entities and seed data.
- Implemented Redis-backed caching with @Cacheable and a 60-second TTL, measurably reducing response times on repeated requests.
- Built a fixed-window rate limiter using Redis INCR + EXPIRE that enforces per-API-key request quotas with proper X-RateLimit-Limit and X-RateLimit-Remaining response headers.
- Secret Mission: Implemented tiered access control with differentiated rate limits and currency access per tier.
Ready to quiz yourself?