Banking Microservices with SSO and Docker
Build a containerized banking system with SSO, service discovery, and caching.
Introduction
30 Second Summary
Every time you check your bank balance, transfer money, or log in to a banking app, dozens of services are working together behind the scenes. These services need to find each other, trust each other, and respond fast enough that you never notice the complexity.
In this project, you will build a banking microservices system with SSO authentication, centralized configuration, service discovery, Redis caching, and Docker orchestration.
What You'll Build
A fully containerized banking platform with 8 services that authenticate via a single JWT token, discover each other dynamically through Eureka, and run behind an NGINX gateway.
By the end of this project, you'll have:
- A multi-module Gradle project with 5 Spring Boot services (Eureka Server, Config Server, Auth, Account, and Transaction) orchestrated by Docker Compose.
- A working SSO authentication flow where a single JWT token grants access across all services, with Redis caching for high-performance balance lookups.
- Hands-on experience using Claude Code to build microservices in parallel while you architect the system.
- Secret Mission: Add NGINX rate limiting to protect the banking API from brute-force attacks.
Are there any prerequisites?
You should have basic REST API experience with Spring Boot and basic Docker familiarity (a refresher is included in Step 1). You will also need a GitHub account for hosting configuration files.
Before We Start
Building a microservices system means running multiple services that talk to each other. You need two core tools to make that happen: Docker to containerize and run all your services together, and Claude Code to help you build services in parallel while you focus on architecture.
In this step, you will verify Docker is ready, install Claude Code, and create the project folder structure that organizes your five microservices.
In this step, get ready to:
- Verify Docker Desktop is running and ready to build containers.
- Install and authenticate Claude Code for AI-assisted development.
- Create the multi-module project directory structure.
Docker quick refresher
Before building anything, let's confirm Docker is running and review the key concepts you will use throughout this project.
What are the Docker concepts used in this project?
- Image: A blueprint for a container (like a class in Java).
- Container: A running instance of an image (like an object from that class).
- Dockerfile: Instructions to build an image (FROM base, COPY files, RUN commands).
- Docker Compose: A YAML file that defines and runs multiple containers together as a system.
- Volume: Persistent storage that survives container restarts (used for PostgreSQL data).
- Network: An isolated communication channel between containers. Services talk by container name, not IP address.
- Verify Docker is installed and running by running this command:
docker --version
- Check that Docker Compose is available by running:
docker compose version
✔️ I see version numbers for both commands
Docker is ready. You will use it later to build and orchestrate all 8 containers in this project.
ⓧ I see 'command not found' or an error
Docker Desktop needs to be running for these commands to work.
- Open Docker Desktop from your applications.
- Wait for the Docker engine to finish starting (the whale icon in your system tray stops animating).
- Run the two version commands again.
If Docker Desktop is not installed, download it from docker.com/products/docker-desktop and install it before continuing.
Install and configure Claude Code
Claude Code is a terminal-based AI assistant that reads your project files and builds code autonomously. You will use it to develop multiple microservices in parallel while you focus on the architecture decisions.
What plan do I need for Claude Code?
Claude Code requires a Pro plan ($20/month billed monthly, or $17/month with annual billing) or a Max plan. You will be prompted to authenticate with your Anthropic account after installation.
- Install Claude Code by running the command for your operating system:
macOS
curl -fsSL https://claude.ai/install.sh | bash
Windows
irm https://claude.ai/install.ps1 | iex
- After installation completes, authenticate Claude Code by running:
claude
You will be prompted to log in with your Anthropic account in your browser. Follow the on-screen instructions to complete authentication.
✔️ I see the Claude Code prompt in my terminal
Claude Code is ready. Type /exit to close the session for now. You will start a real session later when you delegate service building to Claude Code.
ⓧ I see an authentication error or 'command not found'
- If you see 'command not found', close your terminal and open a new one. The installer adds Claude Code to your PATH, but existing terminals need to be restarted.
- If authentication fails, confirm you have a Pro or Max plan at claude.ai/settings.
- Try running claude again after resolving the issue.
Create the project structure
This project uses a multi-module Gradle structure. Each microservice lives in its own subdirectory, keeping code separated while sharing build configuration from the root.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and all service subdirectories by running these commands:
mkdir banking-microservices
cd banking-microservices
mkdir eureka-server config-server auth-service account-service transaction-service nginx
What are these directories for?
- eureka-server: Service discovery registry so services find each other dynamically.
- config-server: Centralized configuration management, reads YAML from GitHub.
- auth-service: SSO authentication. Handles registration, login, and JWT token issuance.
- account-service: Bank account management with Redis caching for fast balance lookups.
- transaction-service: Processes deposits, withdrawals, and transfers between accounts.
- nginx: API gateway configuration. Single entry point for all external traffic.
- Confirm the directories were created by running:
ls
You should see eureka-server, config-server, auth-service, account-service, transaction-service, and nginx listed in the output.
Missing directories?
- Make sure you ran all three commands (mkdir banking-microservices, cd banking-microservices, then the second mkdir). If you skipped the cd, the subdirectories were created in the wrong location.
- Run pwd to check your current directory. You should be inside ~/Desktop/banking-microservices.
Help me troubleshoot my project directory structure.
Your environment is ready. Next up, you will create the Gradle build files, Docker Compose configuration, and the CLAUDE.md file that tells Claude Code how to build your services.
Create Project Configuration Files
Your project directory is ready with all the subdirectories in place. Now you need the configuration files that tell Gradle how to build the project, Docker Compose how to run the system, and Claude Code how to understand the project.
These files are the foundation of the entire system. Without them, you'd have a folder of empty directories with no way to build, run, or orchestrate anything.
In this step, get ready to:
- Create a multi-module Gradle build configuration.
- Write a CLAUDE.md file so Claude Code understands the architecture.
- Define all 8 containers in a Docker Compose file.
Create the root Gradle build files
A multi-module Gradle project needs two files at the root. settings.gradle declares which subprojects exist, and build.gradle configures shared dependencies and plugins across all of them.
- In your editor, create a new file called settings.gradle in the root of your banking-microservices folder, then paste this code:
rootProject.name = 'banking-microservices'
include 'eureka-server'
include 'config-server'
include 'auth-service'
include 'account-service'
include 'transaction-service'
What does this file do?
This tells Gradle that your project is a multi-module build with 5 subprojects. Each include line maps to one of the subdirectories you created in the previous step.
- Save settings.gradle.
- Now create a second file called build.gradle in the same root folder, then paste this code:
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.14' apply false
id 'io.spring.dependency-management' version '1.1.7' apply false
}
subprojects {
apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
group = 'com.banking'
version = '1.0.0'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
ext {
set('springCloudVersion', '2025.0.0')
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
tasks.named('test') {
useJUnitPlatform()
}
}
What does this build file configure?
- Spring Boot 3.5.14 plugin is declared at the root but apply false means it's only applied to subprojects, not the root itself.
- The subprojects block applies shared configuration to every service module automatically.
- Spring Cloud 2025.0.0 (Northfields) BOM manages all Spring Cloud dependency versions so you don't have to specify each one.
- Java 21 toolchain ensures all services compile with the correct JDK version regardless of what's installed locally.
- Save build.gradle.
- Confirm both files exist by running:
ls *.gradle
- You should see build.gradle and settings.gradle in the output.
Don't see both files?
- Make sure you're in the banking-microservices root folder, not inside one of the service subdirectories.
- Check that you saved both files with the exact names settings.gradle and build.gradle (no typos, no extra extensions like .txt).
Still stuck? Ask for help.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is settings.gradle:
rootProject.name = 'banking-microservices'
include 'eureka-server'
include 'config-server'
include 'auth-service'
include 'account-service'
include 'transaction-service'
Here is build.gradle:
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.14' apply false
id 'io.spring.dependency-management' version '1.1.7' apply false
}
subprojects {
apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
group = 'com.banking'
version = '1.0.0'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
ext {
set('springCloudVersion', '2025.0.0')
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
tasks.named('test') {
useJUnitPlatform()
}
}
Create CLAUDE.md for parallel development
Claude Code reads a special file called CLAUDE.md at the start of every session. This file gives Claude Code persistent context about the project architecture, conventions, and rules so it generates code that fits your system correctly.
- Create a file called CLAUDE.md in the root of your banking-microservices folder, then paste this content:
# Banking Microservices Platform
## Project Overview
A multi-module Gradle project implementing a banking microservices system with SSO, centralized config, service discovery, Redis caching, and Docker orchestration.
## Architecture
- **eureka-server** (port 8761): Service discovery registry
- **config-server** (port 8888): Centralized config, reads YAML from GitHub
- **auth-service** (port 8081): SSO - registration, login, JWT issuance
- **account-service** (port 8082): Bank account management with Redis caching
- **transaction-service** (port 8083): Deposits, withdrawals, transfers
## Build Commands
- Build all: `.\gradlew clean build -x test`
- Build single module: `.\gradlew :module-name:build -x test`
- Run with Docker: `docker compose up --build -d`
- View logs: `docker compose logs -f <service-name>`
## Code Conventions
- Java 21, Spring Boot 3.5.14, Spring Cloud 2025.0.0
- Constructor injection only (no @Autowired on fields)
- Use Java records for DTOs (request/response objects)
- Use @RestController, return ResponseEntity for all endpoints
- Package structure: controller, service, repository, model, config, security
- Each service has its own PostgreSQL database (schema isolation)
## Centralized Configuration (Config Server)
- Config Server reads from GitHub: https://github.com/your-username/banking-config-repo
- GitHub repo contains: application.yml (shared), auth-service.yml, account-service.yml, transaction-service.yml
- Client services connect via: spring.config.import=optional:configserver:http://config-server:8888
- Config Server registers with Eureka (service name: config-server)
- Each service keeps a minimal local application.yml with just spring.application.name and spring.config.import
## Security
- JWT tokens signed with HMAC-SHA256
- Secret key configured via JWT_SECRET environment variable (overrides Config Server value)
- Token contains claims: sub (userId), email, role, iat, exp
- Token expiration: 24 hours
- All services share the same JWT secret to verify tokens
- Auth endpoints (/auth/**) are public, all others require valid JWT
## Service Communication
- Services register with Eureka and discover each other by application name
- Transaction Service calls Account Service via RestClient using service name
- Example: `http://account-service/accounts/{id}`
## Redis Usage (Account Service)
- Cache account lookups with @Cacheable("accounts")
- Evict on balance changes with @CacheEvict
- TTL: 5 minutes
- Connection: redis:6379 (Docker service name)
## Database
- PostgreSQL 16
- auth-service → banking_auth database
- account-service → banking_accounts database
- transaction-service → banking_transactions database
- Use spring.jpa.hibernate.ddl-auto=update for development
Why does CLAUDE.md matter?
Later in this project, you'll start Claude Code sessions to build entire services in parallel. Without CLAUDE.md, Claude Code has no idea what ports to use, what naming conventions to follow, or how services connect to each other.
This file includes port assignments, build commands, code style rules (constructor injection, records for DTOs), database naming, and the Config Server GitHub URL. The more precise this file is, the less you need to correct Claude Code later.
- Replace your-username in the Centralized Configuration section with your actual GitHub username.
- Save CLAUDE.md.
- Confirm the file exists by running:
ls CLAUDE.md
- You should see CLAUDE.md in the output.
✔️ Awesome, I've got everything!
Great. Double check you've replaced your-username with your actual GitHub username before moving on.
ⓧ I'd like to double check the full code
Here is the full CLAUDE.md file. Remember to replace your-username with your GitHub username.
# Banking Microservices Platform
## Project Overview
A multi-module Gradle project implementing a banking microservices system with SSO, centralized config, service discovery, Redis caching, and Docker orchestration.
## Architecture
- **eureka-server** (port 8761): Service discovery registry
- **config-server** (port 8888): Centralized config, reads YAML from GitHub
- **auth-service** (port 8081): SSO - registration, login, JWT issuance
- **account-service** (port 8082): Bank account management with Redis caching
- **transaction-service** (port 8083): Deposits, withdrawals, transfers
## Build Commands
- Build all: `.\gradlew clean build -x test`
- Build single module: `.\gradlew :module-name:build -x test`
- Run with Docker: `docker compose up --build -d`
- View logs: `docker compose logs -f <service-name>`
## Code Conventions
- Java 21, Spring Boot 3.5.14, Spring Cloud 2025.0.0
- Constructor injection only (no @Autowired on fields)
- Use Java records for DTOs (request/response objects)
- Use @RestController, return ResponseEntity for all endpoints
- Package structure: controller, service, repository, model, config, security
- Each service has its own PostgreSQL database (schema isolation)
## Centralized Configuration (Config Server)
- Config Server reads from GitHub: https://github.com/your-username/banking-config-repo
- GitHub repo contains: application.yml (shared), auth-service.yml, account-service.yml, transaction-service.yml
- Client services connect via: spring.config.import=optional:configserver:http://config-server:8888
- Config Server registers with Eureka (service name: config-server)
- Each service keeps a minimal local application.yml with just spring.application.name and spring.config.import
## Security
- JWT tokens signed with HMAC-SHA256
- Secret key configured via JWT_SECRET environment variable (overrides Config Server value)
- Token contains claims: sub (userId), email, role, iat, exp
- Token expiration: 24 hours
- All services share the same JWT secret to verify tokens
- Auth endpoints (/auth/**) are public, all others require valid JWT
## Service Communication
- Services register with Eureka and discover each other by application name
- Transaction Service calls Account Service via RestClient using service name
- Example: `http://account-service/accounts/{id}`
## Redis Usage (Account Service)
- Cache account lookups with @Cacheable("accounts")
- Evict on balance changes with @CacheEvict
- TTL: 5 minutes
- Connection: redis:6379 (Docker service name)
## Database
- PostgreSQL 16
- auth-service → banking_auth database
- account-service → banking_accounts database
- transaction-service → banking_transactions database
- Use spring.jpa.hibernate.ddl-auto=update for development
Create the Docker Compose file
Docker Compose orchestrates all 8 containers as a single system. It defines how each service starts, which services it depends on, and how they communicate over a shared network.
Before writing the compose file, you need a small SQL script that creates the additional databases inside PostgreSQL. The compose file mounts this script so PostgreSQL runs it on first startup.
- Create a file called init-db.sql in the root of your banking-microservices folder, then paste this code:
CREATE DATABASE banking_accounts;
CREATE DATABASE banking_transactions;
Why a separate SQL file?
PostgreSQL's Docker image only creates one database by default (set via POSTGRES_DB). Since each microservice needs its own isolated database, this script creates the extra two. The compose file mounts it into PostgreSQL's /docker-entrypoint-initdb.d/ directory, which PostgreSQL auto-executes on first start.
- Save init-db.sql.
- Now create docker-compose.yml in the same root folder, then paste this configuration:
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: banking
POSTGRES_PASSWORD: banking123
POSTGRES_DB: banking_auth
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
expose:
- "5432"
networks:
- banking-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U banking"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
expose:
- "6379"
networks:
- banking-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
eureka-server:
build: ./eureka-server
expose:
- "8761"
ports:
- "8761:8761"
networks:
- banking-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8761/actuator/health"]
interval: 15s
timeout: 10s
retries: 5
config-server:
build: ./config-server
environment:
EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://eureka-server:8761/eureka/
expose:
- "8888"
ports:
- "8888:8888"
depends_on:
eureka-server:
condition: service_healthy
networks:
- banking-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8888/actuator/health"]
interval: 15s
timeout: 10s
retries: 5
auth-service:
build: ./auth-service
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/banking_auth
SPRING_DATASOURCE_USERNAME: banking
SPRING_DATASOURCE_PASSWORD: banking123
SPRING_CONFIG_IMPORT: optional:configserver:http://config-server:8888
EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://eureka-server:8761/eureka/
JWT_SECRET: my-super-secret-banking-jwt-key-that-is-at-least-256-bits-long
expose:
- "8081"
depends_on:
postgres:
condition: service_healthy
eureka-server:
condition: service_healthy
config-server:
condition: service_healthy
networks:
- banking-network
account-service:
build: ./account-service
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/banking_accounts
SPRING_DATASOURCE_USERNAME: banking
SPRING_DATASOURCE_PASSWORD: banking123
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: 6379
SPRING_CONFIG_IMPORT: optional:configserver:http://config-server:8888
EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://eureka-server:8761/eureka/
JWT_SECRET: my-super-secret-banking-jwt-key-that-is-at-least-256-bits-long
expose:
- "8082"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
eureka-server:
condition: service_healthy
config-server:
condition: service_healthy
networks:
- banking-network
transaction-service:
build: ./transaction-service
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/banking_transactions
SPRING_DATASOURCE_USERNAME: banking
SPRING_DATASOURCE_PASSWORD: banking123
SPRING_CONFIG_IMPORT: optional:configserver:http://config-server:8888
EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://eureka-server:8761/eureka/
JWT_SECRET: my-super-secret-banking-jwt-key-that-is-at-least-256-bits-long
expose:
- "8083"
depends_on:
postgres:
condition: service_healthy
eureka-server:
condition: service_healthy
config-server:
condition: service_healthy
networks:
- banking-network
nginx:
build: ./nginx
ports:
- "80:80"
depends_on:
- auth-service
- account-service
- transaction-service
networks:
- banking-network
volumes:
postgres_data:
networks:
banking-network:
driver: bridge
What does this compose file define?
- postgres and redis use official images directly. The 5 Spring Boot services use build: to build from Dockerfiles you'll create later.
- expose makes ports available only to other containers on the network. ports makes them accessible from your host machine too.
- depends_on with condition: service_healthy ensures services start only after their dependencies pass health checks.
- All services share banking-network so they can reach each other by container name (e.g., postgres:5432).
- Only NGINX (port 80) and Eureka (port 8761, for dashboard access) are exposed externally.
- Save docker-compose.yml.
- Validate the compose file syntax by running:
docker compose config --services
- You should see all 8 service names listed (account-service, auth-service, config-server, eureka-server, nginx, postgres, redis, transaction-service).
Seeing a YAML parsing error?
- YAML is sensitive to indentation. Make sure you used spaces (not tabs) and that nested keys are indented exactly 2 spaces.
- Check that your file is named exactly docker-compose.yml (not docker-compose.yaml or similar).
- Confirm you're running the command from the banking-microservices root folder where the file lives.
Still seeing errors? Ask for help.
✔️ Awesome, I've got everything!
Great. Make sure both docker-compose.yml and init-db.sql are saved.
ⓧ I'd like to double check the full code
Here is init-db.sql:
CREATE DATABASE banking_accounts;
CREATE DATABASE banking_transactions;
Here is docker-compose.yml:
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: banking
POSTGRES_PASSWORD: banking123
POSTGRES_DB: banking_auth
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
expose:
- "5432"
networks:
- banking-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U banking"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
expose:
- "6379"
networks:
- banking-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
eureka-server:
build: ./eureka-server
expose:
- "8761"
ports:
- "8761:8761"
networks:
- banking-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8761/actuator/health"]
interval: 15s
timeout: 10s
retries: 5
config-server:
build: ./config-server
environment:
EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://eureka-server:8761/eureka/
expose:
- "8888"
ports:
- "8888:8888"
depends_on:
eureka-server:
condition: service_healthy
networks:
- banking-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8888/actuator/health"]
interval: 15s
timeout: 10s
retries: 5
auth-service:
build: ./auth-service
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/banking_auth
SPRING_DATASOURCE_USERNAME: banking
SPRING_DATASOURCE_PASSWORD: banking123
SPRING_CONFIG_IMPORT: optional:configserver:http://config-server:8888
EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://eureka-server:8761/eureka/
JWT_SECRET: my-super-secret-banking-jwt-key-that-is-at-least-256-bits-long
expose:
- "8081"
depends_on:
postgres:
condition: service_healthy
eureka-server:
condition: service_healthy
config-server:
condition: service_healthy
networks:
- banking-network
account-service:
build: ./account-service
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/banking_accounts
SPRING_DATASOURCE_USERNAME: banking
SPRING_DATASOURCE_PASSWORD: banking123
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: 6379
SPRING_CONFIG_IMPORT: optional:configserver:http://config-server:8888
EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://eureka-server:8761/eureka/
JWT_SECRET: my-super-secret-banking-jwt-key-that-is-at-least-256-bits-long
expose:
- "8082"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
eureka-server:
condition: service_healthy
config-server:
condition: service_healthy
networks:
- banking-network
transaction-service:
build: ./transaction-service
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/banking_transactions
SPRING_DATASOURCE_USERNAME: banking
SPRING_DATASOURCE_PASSWORD: banking123
SPRING_CONFIG_IMPORT: optional:configserver:http://config-server:8888
EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://eureka-server:8761/eureka/
JWT_SECRET: my-super-secret-banking-jwt-key-that-is-at-least-256-bits-long
expose:
- "8083"
depends_on:
postgres:
condition: service_healthy
eureka-server:
condition: service_healthy
config-server:
condition: service_healthy
networks:
- banking-network
nginx:
build: ./nginx
ports:
- "80:80"
depends_on:
- auth-service
- account-service
- transaction-service
networks:
- banking-network
volumes:
postgres_data:
networks:
banking-network:
driver: bridge
Why use both NGINX and Eureka?
NGINX handles external traffic routing. Clients talk to NGINX on port 80, and NGINX routes requests to the correct service based on the URL path.
Eureka handles internal service discovery. When the Transaction Service needs to call the Account Service, Eureka resolves the service name to the correct container address. They complement each other.
Your project now has a complete build system, orchestration layer, and AI development guide. Next up, you'll build the first two infrastructure services: the Eureka discovery server and the Config Server.
Build the Eureka Server and Config Server
In the previous step, you configured the Gradle multi-project build and Docker Compose orchestration. Now it is time to build the first two infrastructure services that your banking platform depends on.
Every microservice needs two things before it can function: a place to register itself so other services can find it, and a source of configuration that is not baked into its Docker image. Eureka handles the first problem (service discovery) and Spring Cloud Config Server handles the second (centralized configuration).
In this step, get ready to:
- Create the Eureka Server module with service discovery enabled.
- Create the Config Server module that reads configuration from GitHub.
- Set up a GitHub config repository with shared and per-service YAML files.
Create the Eureka Server module
The Eureka Server is the service registry. When a microservice starts, it registers its name and network location with Eureka. Other services query Eureka to find each other by name instead of hardcoded IP addresses.
- Create the source directory structure for the Eureka Server module by running these commands:
mkdir -p eureka-server/src/main/java/com/banking/eureka
mkdir -p eureka-server/src/main/resources
- Create the eureka-server/build.gradle file with the Eureka Server dependency:
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-server'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
What does this code do?
The spring-cloud-starter-netflix-eureka-server dependency provides the full Eureka registry server. The spring-boot-starter-actuator adds health check endpoints that Docker Compose uses to determine when the service is ready.
You do not need to specify version numbers because the root build.gradle already imports the Spring Cloud BOM, which manages versions for all Spring Cloud dependencies.
- Create the main application class at eureka-server/src/main/java/com/banking/eureka/EurekaServerApplication.java:
package com.banking.eureka;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;
// Enables this application to act as a Eureka service registry
@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaServerApplication.class, args);
}
}
What does @EnableEurekaServer do?
The @EnableEurekaServer annotation transforms a standard Spring Boot application into a fully functional service registry. Other services will register with this server on startup and query it to discover their peers.
- Create the configuration file at eureka-server/src/main/resources/application.yml:
server:
port: 8761
spring:
application:
name: eureka-server
eureka:
client:
register-with-eureka: false
fetch-registry: false
server:
enable-self-preservation: false
Why disable self-registration?
Setting register-with-eureka: false and fetch-registry: false tells Eureka that it IS the registry. Without these settings, the server would try to register with itself and fail because no other Eureka instance exists.
Port 8761 is the conventional default for Eureka servers. The enable-self-preservation: false setting disables a production safety mechanism that would keep stale entries alive. For local development, you want services removed immediately when they stop.
- Create the eureka-server/Dockerfile for containerizing the service:
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY build.gradle settings.gradle ./
COPY gradle ./gradle
COPY gradlew .
COPY src ./src
RUN chmod +x gradlew
RUN ./gradlew clean build -x test --no-daemon
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8761
ENTRYPOINT ["java", "-jar", "app.jar"]
What is a multi-stage Dockerfile?
The first stage (AS build) uses the full JDK to compile your code into a JAR file. The second stage copies only the compiled JAR into a lightweight JRE image.
This produces a final image around 200MB instead of 600MB because the build tools and source code are discarded. The -x test flag skips running tests during the Docker build to speed things up.
- Save all four files you just created.
- Confirm the directory structure by running:
ls eureka-server/src/main/java/com/banking/eureka/
You should see EurekaServerApplication.java listed in the output.
Don't see the file?
Make sure you created the full nested directory path with mkdir -p before saving the file. Check that the file is inside eureka-server/src/main/java/com/banking/eureka/ and not at a higher level.
help me verify my eureka-server directory structure is correct
✔️ Awesome, I've got everything!
Great. Make sure all four files are saved before moving on to the Config Server.
ⓧ I'd like to double check the full code
Here are all the Eureka Server files for comparison:
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-server'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
package com.banking.eureka;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;
@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaServerApplication.class, args);
}
}
server:
port: 8761
spring:
application:
name: eureka-server
eureka:
client:
register-with-eureka: false
fetch-registry: false
server:
enable-self-preservation: false
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY build.gradle settings.gradle ./
COPY gradle ./gradle
COPY gradlew .
COPY src ./src
RUN chmod +x gradlew
RUN ./gradlew clean build -x test --no-daemon
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8761
ENTRYPOINT ["java", "-jar", "app.jar"]
Create the Config Server module
The Config Server solves a fundamental microservices problem: how do you change a database URL, a timeout, or a feature flag without rebuilding every Docker image? The answer is centralized configuration stored in a Git repository.
When a service starts and asks for its config, the Config Server fetches the matching YAML file from GitHub and serves it over HTTP. Change a value in GitHub, refresh the service, and the new config is live.
- Create the source directory structure for the Config Server module by running:
mkdir -p config-server/src/main/java/com/banking/config
mkdir -p config-server/src/main/resources
- Create the config-server/build.gradle file with the Config Server dependencies:
dependencies {
implementation 'org.springframework.cloud:spring-cloud-config-server'
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
What does this code do?
The spring-cloud-config-server dependency provides the full config server capability. The spring-cloud-starter-netflix-eureka-client registers the Config Server with Eureka so other services can discover it by name.
The actuator dependency provides health check endpoints for Docker Compose dependency management.
- Create the main application class at config-server/src/main/java/com/banking/config/ConfigServerApplication.java:
package com.banking.config;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.config.server.EnableConfigServer;
// Enables this application to serve configuration to other services
@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}
What does @EnableConfigServer do?
The @EnableConfigServer annotation activates an HTTP API that other services query for their configuration. When auth-service starts and connects to Config Server, the server fetches auth-service.yml from the Git repository and returns its contents.
- Create the configuration file at config-server/src/main/resources/application.yml:
server:
port: 8888
spring:
application:
name: config-server
cloud:
config:
server:
git:
uri: https://github.com/your-username/banking-config-repo
default-label: main
clone-on-start: true
eureka:
client:
service-url:
defaultZone: http://eureka-server:8761/eureka/
What do these properties configure?
The spring.cloud.config.server.git.uri property tells the Config Server where to find configuration files. The default-label: main means it reads from the main branch. The clone-on-start: true setting ensures the repo is cloned before the server accepts requests.
The eureka.client.service-url.defaultZone registers this Config Server with Eureka. Other services will discover Config Server through Eureka instead of needing a hardcoded URL.
Replace the GitHub URI
You will create the banking-config-repo GitHub repository in the next substep. Once you have it, come back and replace your-username in the uri field with your actual GitHub username.
- Create the config-server/Dockerfile using the same multi-stage pattern as the Eureka Server:
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY build.gradle settings.gradle ./
COPY gradle ./gradle
COPY gradlew .
COPY src ./src
RUN chmod +x gradlew
RUN ./gradlew clean build -x test --no-daemon
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8888
ENTRYPOINT ["java", "-jar", "app.jar"]
What changed from the Eureka Dockerfile?
The only difference is EXPOSE 8888 instead of EXPOSE 8761. The multi-stage build pattern is identical because both services are standard Spring Boot applications compiled with Gradle.
- Save all four Config Server files.
- Confirm the directory structure by running:
ls config-server/src/main/java/com/banking/config/
You should see ConfigServerApplication.java listed in the output.
Don't see the file?
Verify you ran the mkdir -p commands before creating the file. The package path must be com/banking/config (not com/banking/eureka).
help me verify my config-server directory structure is correct
✔️ Awesome, I've got everything!
Great. Make sure all files are saved before setting up the GitHub config repository.
ⓧ I'd like to double check the full code
Here are all the Config Server files for comparison:
dependencies {
implementation 'org.springframework.cloud:spring-cloud-config-server'
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
package com.banking.config;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.config.server.EnableConfigServer;
@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}
server:
port: 8888
spring:
application:
name: config-server
cloud:
config:
server:
git:
uri: https://github.com/your-username/banking-config-repo
default-label: main
clone-on-start: true
eureka:
client:
service-url:
defaultZone: http://eureka-server:8761/eureka/
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY build.gradle settings.gradle ./
COPY gradle ./gradle
COPY gradlew .
COPY src ./src
RUN chmod +x gradlew
RUN ./gradlew clean build -x test --no-daemon
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8888
ENTRYPOINT ["java", "-jar", "app.jar"]
Create the GitHub config repository
The Config Server needs a Git repository to read configuration files from. You will create a public GitHub repository containing YAML files that define database connections, ports, and shared secrets for all your services.
- Open GitHub in your browser and click New to create a new repository.
- Name the repository banking-config-repo.
- Keep it Public (no authentication needed for Config Server to read it).
- Check Add a README file to initialize the repo with a default branch.
- Click Create repository.
How does Config Server map files to services?
Config Server uses a naming convention: when a service with spring.application.name=auth-service connects, Config Server looks for auth-service.yml in the Git repo. It also serves application.yml to ALL services as shared configuration.
Per-service files override values from the shared application.yml. This lets you define common settings once and customize only what differs per service.
Now create four YAML files in this repository. You can create each file directly in the GitHub web interface by clicking Add file then Create new file.
- Create application.yml with the shared configuration for all services:
# Shared configuration for all banking microservices
eureka:
client:
service-url:
defaultZone: http://eureka-server:8761/eureka/
jwt:
secret: my-super-secret-banking-jwt-key-that-is-at-least-256-bits-long
expiration: 86400000
What does this shared config provide?
Every service that connects to Config Server receives these values. The Eureka location tells each service where to register. The JWT secret is shared across all services so they can all verify tokens issued by the Auth Service.
- Create auth-service.yml with auth-specific configuration:
# Configuration specific to the Auth Service
server:
port: 8081
spring:
datasource:
url: jdbc:postgresql://postgres:5432/banking_auth
username: banking
password: banking123
jpa:
hibernate:
ddl-auto: update
- Create account-service.yml with account-specific configuration including Redis:
# Configuration specific to the Account Service
server:
port: 8082
spring:
datasource:
url: jdbc:postgresql://postgres:5432/banking_accounts
username: banking
password: banking123
data:
redis:
host: redis
port: 6379
jpa:
hibernate:
ddl-auto: update
- Create transaction-service.yml with transaction-specific configuration:
# Configuration specific to the Transaction Service
server:
port: 8083
spring:
datasource:
url: jdbc:postgresql://postgres:5432/banking_transactions
username: banking
password: banking123
jpa:
hibernate:
ddl-auto: update
Why do the URLs use service names instead of localhost?
Inside Docker Compose, services communicate using their container names. The hostname postgres resolves to the PostgreSQL container, and redis resolves to the Redis container. Docker's internal DNS handles the name resolution automatically on the banking-network bridge network.
- Commit each file to the main branch in GitHub.
- Verify all four files are visible in your banking-config-repo repository: application.yml, auth-service.yml, account-service.yml, and transaction-service.yml.
Config Server can't find your repo later?
Double-check that the repository is Public (not Private). A private repo requires authentication credentials that are not configured in this project.
Also verify you updated the uri in config-server/src/main/resources/application.yml to match your actual GitHub username.
help me troubleshoot my Config Server connection to GitHub
Your infrastructure foundation is in place. Eureka will act as the phone book for your services, and Config Server will deliver their settings from GitHub. Next up, you will build the Auth Service that handles registration, login, and JWT token issuance.
Build the Auth Service (SSO)
Your infrastructure backbone is in place. Eureka handles service discovery and Config Server pulls centralized configuration from GitHub.
Now you need the service that controls who can access your banking system. The Auth Service is the SSO (Single Sign-On) provider. It issues JWT tokens that every other service trusts, so users authenticate once and gain access across all services.
In this step, get ready to:
- Delegate Auth Service development to Claude Code using your CLAUDE.md.
- Review the generated code for correct JWT, security, and config patterns.
- Understand how the SSO authentication flow works across services.
Delegate Auth Service building to Claude Code
Instead of writing the Auth Service line by line, you will use Claude Code to build it. Claude Code reads your CLAUDE.md file and follows the conventions you defined there.
- Switch back to your terminal inside the banking-microservices project folder.
- Start a Claude Code session by running:
claude
- Once Claude Code starts, paste the following prompt to instruct it to build the Auth Service:
Build the auth-service module for our banking microservices project. Follow the conventions in CLAUDE.md.
Requirements:
- Spring Boot service with Spring Security, Spring Data JPA, PostgreSQL driver
- JJWT library (io.jsonwebtoken) for JWT generation
- Spring Cloud Config Client (spring-cloud-starter-config) and Eureka Client
- Endpoints: POST /auth/register, POST /auth/login, POST /auth/refresh
- JWT generation with HMAC-SHA256, configurable secret via JWT_SECRET env var, 24h expiration
- Token claims: sub (userId), email, role, iat, exp
- BCrypt password hashing for user passwords
- User entity with fields: id (Long), email (String), password (String), role (String)
- Spring Security config: /auth/** endpoints are public, all others require authentication
- Minimal application.yml with just spring.application.name=auth-service and spring.config.import=optional:configserver:http://config-server:8888
- Multi-stage Dockerfile following the same pattern as eureka-server/Dockerfile, exposing port 8081
- Use constructor injection, Java records for DTOs, @RestController returning ResponseEntity
Why delegate to Claude Code?
The Auth Service involves a lot of boilerplate: security configuration, JWT utility classes, request/response DTOs, repository interfaces. Claude Code can generate all of this in a few minutes while following your project conventions from CLAUDE.md.
This mirrors real engineering workflows where AI coding assistants handle well-defined, convention-heavy modules while you focus on architectural decisions.
- Wait for Claude Code to finish generating the files. This typically takes 2-5 minutes depending on your plan.
- Claude Code will create the full directory structure under auth-service/ including source files, configuration, and the Dockerfile.
Use checkpoints to iterate safely
If Claude Code produces something incorrect, press Esc twice or type /rewind to revert to the last checkpoint and try again with a more specific prompt.
Review Claude Code's output
Claude Code generates working code, but you need to verify it follows the architectural patterns your system requires. A quick review ensures everything integrates correctly with Config Server and Eureka.
- Verify the file structure exists by running:
ls auth-service/src/main/java/com/banking/auth/
You should see directories for controller, service, repository, model, config, and security (or similar package organization).
- Open auth-service/build.gradle and confirm it includes these key dependencies:
- spring-boot-starter-web for REST endpoints.
- spring-boot-starter-security for Spring Security integration.
- spring-boot-starter-data-jpa for database access.
- org.postgresql:postgresql for the PostgreSQL driver.
- io.jsonwebtoken:jjwt-api, jjwt-impl, and jjwt-jackson for JWT handling.
- spring-cloud-starter-config for Config Server integration.
- spring-cloud-starter-netflix-eureka-client for service discovery.
- Open the JWT utility class (likely JwtUtil.java in the security package) and verify:
- Tokens are signed with HMAC-SHA256.
- The signing secret comes from an environment variable or config property (JWT_SECRET).
- Token claims include sub (userId), email, role, iat, and exp (24-hour expiration).
- Open auth-service/src/main/resources/application.yml and confirm it looks like this:
spring:
application:
name: auth-service
config:
import: optional:configserver:http://config-server:8888
Why is the application.yml so minimal?
The Auth Service only needs two things locally: its name (so Config Server knows which YAML file to serve) and the Config Server address. Everything else (database URL, JWT secret, server port) comes from the centralized auth-service.yml in your GitHub config repository.
The optional: prefix means the service won't crash if Config Server is temporarily unavailable during startup. It will use defaults and retry.
- Confirm the Dockerfile follows the multi-stage pattern by opening auth-service/Dockerfile:
- Build stage uses eclipse-temurin:21-jdk-alpine to compile the JAR.
- Runtime stage uses eclipse-temurin:21-jre-alpine (smaller image, JRE only).
- Exposes port 8081.
- Verify the project compiles by running:
./gradlew :auth-service:build -x test
You should see BUILD SUCCESSFUL in the output.
Build failing?
If you see dependency resolution errors, check that auth-service is listed in your root settings.gradle file. It should already be there from Step 2.
If Claude Code missed the Config Client dependency, add implementation 'org.springframework.cloud:spring-cloud-starter-config' to auth-service/build.gradle manually.
Help me debug my auth-service build
Understand the SSO flow
The Auth Service is the single authority that issues tokens. Every other service in the system only needs to verify those tokens. They never call back to the Auth Service to check if a user is logged in.
How does SSO work across your services?
The Auth Service signs JWTs with a secret key. This same secret is shared with all other services via Config Server (from the shared application.yml in your GitHub config repo).
When a user logs in, the Auth Service returns a signed JWT. The user includes this token in every subsequent request. The Account Service and Transaction Service verify the signature locally using the shared secret. No network call to Auth is needed.
This is what makes it "single sign-on": authenticate once with the Auth Service, and that token grants access across all services.
Why a shared JWT secret instead of public/private keys?
Symmetric signing (HMAC-SHA256 with a shared secret) is simpler to set up and fast to verify. For a learning project where all services are on the same trusted network, it is the right trade-off. Production systems with external third-party consumers often use asymmetric keys (RS256) instead.
Your Auth Service is ready. In the next step, you will build the Account Service by hand while gaining a deeper understanding of how JWT validation and Redis caching work together.
Build the Account Service
While Claude Code works on the Auth Service in the background, you can build the Account Service yourself. This service manages bank accounts and balances, acting as the financial backbone of the system.
The Account Service needs to handle high read traffic efficiently. In banking, customers check their balance far more often than they make transactions. To handle this, you will add Redis caching so repeated balance lookups skip the database entirely.
In this step, get ready to:
- Create the Account Service with JPA entities, REST endpoints, and JWT security.
- Add Redis caching with automatic cache eviction on balance changes.
- Create a multi-stage Dockerfile for the Account Service.
Create the Account Service module
The Account Service is a full Spring Boot microservice with its own database, its own REST API, and its own JWT security filter. Every request must carry a valid JWT token issued by the Auth Service.
- Create the account-service/build.gradle file with the following dependencies:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-cache'
runtimeOnly 'org.postgresql:postgresql'
implementation 'io.jsonwebtoken:jjwt-api:0.13.0'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0'
implementation 'org.springframework.cloud:spring-cloud-starter-config'
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
What does this build file do?
- spring-boot-starter-data-jpa and postgresql give the service its own database connection to banking_accounts.
- spring-boot-starter-data-redis and spring-boot-starter-cache enable Redis-backed caching for balance lookups.
- jjwt-api, jjwt-impl, and jjwt-jackson are the three artifacts needed to parse and verify JWT tokens.
- spring-cloud-starter-config connects this service to the Config Server for centralized configuration.
- spring-cloud-starter-netflix-eureka-client registers the service with Eureka for discovery.
- Save account-service/build.gradle.
- Create the Account entity at account-service/src/main/java/com/banking/account/model/Account.java:
package com.banking.account.model;
import jakarta.persistence.*;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
// JPA entity representing a bank account
@Entity
@Table(name = "accounts")
public class Account implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// The user who owns this account (matches userId from JWT)
@Column(nullable = false)
private Long userId;
// Unique account number for identification
@Column(nullable = false, unique = true)
private String accountNumber;
// Type of account: SAVINGS, CHECKING, etc.
@Column(nullable = false)
private String accountType;
// Current balance stored as BigDecimal for financial precision
@Column(nullable = false)
private BigDecimal balance;
// Timestamp when the account was created
@Column(nullable = false)
private LocalDateTime createdAt;
public Account() {}
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getAccountNumber() { return accountNumber; }
public void setAccountNumber(String accountNumber) { this.accountNumber = accountNumber; }
public String getAccountType() { return accountType; }
public void setAccountType(String accountType) { this.accountType = accountType; }
public BigDecimal getBalance() { return balance; }
public void setBalance(BigDecimal balance) { this.balance = balance; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
What does this entity do?
The Account class maps directly to an accounts table in the banking_accounts database. The balance field uses BigDecimal instead of double because floating-point arithmetic introduces rounding errors that are unacceptable in financial systems.
It implements Serializable because Redis needs to serialize objects when caching them.
- Save Account.java.
- Create the repository interface at account-service/src/main/java/com/banking/account/repository/AccountRepository.java:
package com.banking.account.repository;
import com.banking.account.model.Account;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
// Spring Data JPA generates SQL queries from method names
public interface AccountRepository extends JpaRepository<Account, Long> {
// Finds all accounts belonging to a specific user
List<Account> findByUserId(Long userId);
}
- Save AccountRepository.java.
- Create the JWT authentication filter at account-service/src/main/java/com/banking/account/security/JwtAuthFilter.java:
package com.banking.account.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.crypto.SecretKey;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
// Intercepts every request and validates the JWT token
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
// Reads the shared JWT secret from Config Server (or env variable)
@Value("${jwt.secret}")
private String jwtSecret;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
// Extract the Authorization header
String authHeader = request.getHeader("Authorization");
// Reject requests without a Bearer token
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
// Strip the "Bearer " prefix to get the raw token
String token = authHeader.substring(7);
try {
// Build the signing key from the shared secret
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
// Parse and verify the token signature
Claims claims = Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
// Pass user identity downstream via request attributes
request.setAttribute("userId", claims.getSubject());
request.setAttribute("email", claims.get("email", String.class));
request.setAttribute("role", claims.get("role", String.class));
filterChain.doFilter(request, response);
} catch (Exception e) {
// Any parsing or signature error means unauthorized
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
}
}
}
How does SSO work here?
This filter is the key to Single Sign-On across services. The Auth Service signs tokens with a secret key. The Account Service uses the same secret key (delivered via Config Server) to verify those tokens.
The Account Service never calls back to the Auth Service. It trusts the token because it can verify the cryptographic signature. This is what makes the architecture stateless and scalable.
- Save JwtAuthFilter.java.
- Create the service class at account-service/src/main/java/com/banking/account/service/AccountService.java:
package com.banking.account.service;
import com.banking.account.model.Account;
import com.banking.account.repository.AccountRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.List;
// Business logic for account operations with Redis caching
@Service
public class AccountService {
private final AccountRepository accountRepository;
// Constructor injection (no @Autowired on fields)
public AccountService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
public Account createAccount(Account account) {
return accountRepository.save(account);
}
// Cache this lookup in Redis under the "accounts" cache
@Cacheable(value = "accounts", key = "#id")
public Account getAccountById(Long id) {
return accountRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Account not found"));
}
public List<Account> getAccountsByUserId(Long userId) {
return accountRepository.findByUserId(userId);
}
// Evict the cached entry when the balance changes
@CacheEvict(value = "accounts", key = "#id")
public Account updateBalance(Long id, BigDecimal newBalance) {
Account account = accountRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Account not found"));
account.setBalance(newBalance);
return accountRepository.save(account);
}
}
What do @Cacheable and @CacheEvict do?
@Cacheable("accounts") tells Spring to check Redis before executing getAccountById. If the account is already cached, Spring returns it from Redis without touching PostgreSQL.
@CacheEvict("accounts") removes the cached entry when the balance changes. This prevents stale data. The next read will hit the database and refresh the cache with the new balance.
- Save AccountService.java.
- Create the controller at account-service/src/main/java/com/banking/account/controller/AccountController.java:
package com.banking.account.controller;
import com.banking.account.model.Account;
import com.banking.account.service.AccountService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
// REST endpoints for account operations
@RestController
@RequestMapping("/accounts")
public class AccountController {
private final AccountService accountService;
// Constructor injection
public AccountController(AccountService accountService) {
this.accountService = accountService;
}
// Create a new bank account
@PostMapping
public ResponseEntity<Account> createAccount(@RequestBody Account account) {
return ResponseEntity.ok(accountService.createAccount(account));
}
// Get a single account by ID (cached in Redis)
@GetMapping("/{id}")
public ResponseEntity<Account> getAccount(@PathVariable Long id) {
return ResponseEntity.ok(accountService.getAccountById(id));
}
// Get all accounts belonging to a user
@GetMapping("/user/{userId}")
public ResponseEntity<List<Account>> getUserAccounts(@PathVariable Long userId) {
return ResponseEntity.ok(accountService.getAccountsByUserId(userId));
}
}
What does this controller do?
- POST /accounts creates a new bank account for the authenticated user.
- GET /accounts/{id} retrieves a single account by its ID. This hits the Redis cache first.
- GET /accounts/user/{userId} retrieves all accounts belonging to a specific user.
- Save AccountController.java.
- Create the minimal application.yml at account-service/src/main/resources/application.yml:
spring:
application:
name: account-service
config:
import: optional:configserver:http://config-server:8888
Why is this file so short?
All the real configuration (database URL, Redis host, server port) lives in your GitHub config repository inside account-service.yml. This local file just tells Spring Boot two things: the service name and where to find the Config Server.
The optional: prefix means the service will not crash if Config Server is temporarily unavailable during startup.
- Save application.yml.
Seeing package errors in your IDE?
If your editor shows import errors or red underlines, the Gradle project may not have synced. In IntelliJ, click the Gradle refresh icon in the sidebar. In VS Code, reload the window with Ctrl+Shift+P then type Reload Window.
help me fix import errors in my Account Service
Add Redis caching configuration
The @Cacheable annotations in AccountService need two things to work: the @EnableCaching annotation on the main class, and a cache manager that sets the TTL (time to live) for cached entries.
- Create the main application class at account-service/src/main/java/com/banking/account/AccountServiceApplication.java:
package com.banking.account;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
// @EnableCaching activates Spring's cache abstraction
@SpringBootApplication
@EnableCaching
public class AccountServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AccountServiceApplication.class, args);
}
}
What does @EnableCaching do?
Without @EnableCaching, Spring ignores all @Cacheable and @CacheEvict annotations entirely. This single annotation tells Spring to intercept method calls and check Redis before executing the method body.
- Save AccountServiceApplication.java.
- Create the Redis cache configuration at account-service/src/main/java/com/banking/account/config/RedisCacheConfig.java:
package com.banking.account.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import java.time.Duration;
// Configures Redis as the cache backend with a 5-minute TTL
@Configuration
public class RedisCacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// Set cached entries to expire after 5 minutes
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(5))
.disableCachingNullValues();
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}
Why a 5-minute TTL?
The TTL (time to live) controls how long a cached value stays in Redis before expiring. Five minutes balances performance and freshness. Most balance checks happen in bursts (user opens the app, checks balance, makes a transfer, checks again).
If a balance changes via a transaction, the @CacheEvict annotation removes the stale entry immediately. The TTL is a safety net for edge cases where eviction might not fire.
- Save RedisCacheConfig.java.
Why Redis instead of in-memory caching?
In a microservices system, you might run multiple instances of the Account Service behind NGINX for load balancing. An in-memory cache (like a HashMap) would be per-instance and inconsistent across replicas.
Redis provides a shared cache that all instances read from. If instance A caches an account balance, instance B can serve that cached value too.
Create the Dockerfile
The Account Service uses the same multi-stage Dockerfile pattern as the Eureka Server and Config Server you built earlier.
- Create account-service/Dockerfile with this content:
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY build.gradle settings.gradle ./
COPY gradle ./gradle
COPY gradlew .
COPY src ./src
RUN chmod +x gradlew
RUN ./gradlew clean build -x test --no-daemon
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8082
ENTRYPOINT ["java", "-jar", "app.jar"]
What does this multi-stage build do?
The first stage (build) uses the full JDK to compile the Java source code into a JAR file. The second stage copies only the built JAR into a lightweight JRE-only image.
This produces a final image around 200MB instead of 600MB. The build tools (Gradle, compiler) are discarded and never ship to production.
- Save account-service/Dockerfile.
- Confirm your account-service folder contains all the expected files by running:
ls account-service/src/main/java/com/banking/account/
You should see AccountServiceApplication.java plus the config, controller, model, repository, security, and service packages.
Missing files or folders?
Double check that each file is in the correct package folder. Java is strict about package declarations matching directory paths. For example, package com.banking.account.model must live in com/banking/account/model/.
help me fix my Account Service file structure
✔️ Awesome, I've got everything!
Great. Make sure all files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete account-service/build.gradle:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-cache'
runtimeOnly 'org.postgresql:postgresql'
implementation 'io.jsonwebtoken:jjwt-api:0.13.0'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0'
implementation 'org.springframework.cloud:spring-cloud-starter-config'
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
Here is AccountServiceApplication.java:
package com.banking.account;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching
public class AccountServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AccountServiceApplication.class, args);
}
}
Here is Account.java:
package com.banking.account.model;
import jakarta.persistence.*;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "accounts")
public class Account implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long userId;
@Column(nullable = false, unique = true)
private String accountNumber;
@Column(nullable = false)
private String accountType;
@Column(nullable = false)
private BigDecimal balance;
@Column(nullable = false)
private LocalDateTime createdAt;
public Account() {}
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getAccountNumber() { return accountNumber; }
public void setAccountNumber(String accountNumber) { this.accountNumber = accountNumber; }
public String getAccountType() { return accountType; }
public void setAccountType(String accountType) { this.accountType = accountType; }
public BigDecimal getBalance() { return balance; }
public void setBalance(BigDecimal balance) { this.balance = balance; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
Here is AccountRepository.java:
package com.banking.account.repository;
import com.banking.account.model.Account;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface AccountRepository extends JpaRepository<Account, Long> {
List<Account> findByUserId(Long userId);
}
Here is JwtAuthFilter.java:
package com.banking.account.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.crypto.SecretKey;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
@Value("${jwt.secret}")
private String jwtSecret;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
String token = authHeader.substring(7);
try {
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
Claims claims = Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
request.setAttribute("userId", claims.getSubject());
request.setAttribute("email", claims.get("email", String.class));
request.setAttribute("role", claims.get("role", String.class));
filterChain.doFilter(request, response);
} catch (Exception e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
}
}
}
Here is AccountService.java:
package com.banking.account.service;
import com.banking.account.model.Account;
import com.banking.account.repository.AccountRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.List;
@Service
public class AccountService {
private final AccountRepository accountRepository;
public AccountService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
public Account createAccount(Account account) {
return accountRepository.save(account);
}
@Cacheable(value = "accounts", key = "#id")
public Account getAccountById(Long id) {
return accountRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Account not found"));
}
public List<Account> getAccountsByUserId(Long userId) {
return accountRepository.findByUserId(userId);
}
@CacheEvict(value = "accounts", key = "#id")
public Account updateBalance(Long id, BigDecimal newBalance) {
Account account = accountRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Account not found"));
account.setBalance(newBalance);
return accountRepository.save(account);
}
}
Here is AccountController.java:
package com.banking.account.controller;
import com.banking.account.model.Account;
import com.banking.account.service.AccountService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/accounts")
public class AccountController {
private final AccountService accountService;
public AccountController(AccountService accountService) {
this.accountService = accountService;
}
@PostMapping
public ResponseEntity<Account> createAccount(@RequestBody Account account) {
return ResponseEntity.ok(accountService.createAccount(account));
}
@GetMapping("/{id}")
public ResponseEntity<Account> getAccount(@PathVariable Long id) {
return ResponseEntity.ok(accountService.getAccountById(id));
}
@GetMapping("/user/{userId}")
public ResponseEntity<List<Account>> getUserAccounts(@PathVariable Long userId) {
return ResponseEntity.ok(accountService.getAccountsByUserId(userId));
}
}
Here is RedisCacheConfig.java:
package com.banking.account.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import java.time.Duration;
@Configuration
public class RedisCacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(5))
.disableCachingNullValues();
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}
Here is application.yml:
spring:
application:
name: account-service
config:
import: optional:configserver:http://config-server:8888
Here is the Dockerfile:
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY build.gradle settings.gradle ./
COPY gradle ./gradle
COPY gradlew .
COPY src ./src
RUN chmod +x gradlew
RUN ./gradlew clean build -x test --no-daemon
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8082
ENTRYPOINT ["java", "-jar", "app.jar"]
Your Account Service is complete with JWT security, Redis caching, and a clean REST API. Next up, you will build the Transaction Service to handle deposits, withdrawals, and transfers between accounts.
Build the Transaction Service
Your Account Service handles balances and caching. Now you need the service that actually moves money between accounts.
The Transaction Service is the most architecturally interesting piece of the system because it communicates with the Account Service at runtime. Instead of hardcoding a URL, it discovers the Account Service dynamically through Eureka. This is the pattern that makes microservices truly independent.
In this step, get ready to:
- Delegate the Transaction Service build to Claude Code.
- Review the inter-service communication pattern using Eureka discovery.
- Create the multi-stage Dockerfile for containerization.
Delegate to Claude Code (second session)
The Transaction Service handles deposits, withdrawals, and transfers. It needs to call the Account Service to update balances, which makes it the first service in your system with inter-service communication.
- Open a new terminal window in your banking-microservices project folder.
- Start a Claude Code session by running:
claude
- Give Claude Code the following prompt to build the Transaction Service:
Build the transaction-service module following CLAUDE.md. Requirements:
1. build.gradle with dependencies: spring-boot-starter-web, spring-boot-starter-data-jpa, postgresql, jjwt (api + impl + jackson), spring-cloud-starter-config, spring-cloud-starter-netflix-eureka-client, spring-boot-starter-actuator
2. Transaction entity with fields: id (Long, auto-generated), fromAccountId (Long), toAccountId (Long), amount (BigDecimal), type (String: DEPOSIT/WITHDRAWAL/TRANSFER), timestamp (LocalDateTime), status (String)
3. TransactionController with endpoints:
- POST /transactions/deposit
- POST /transactions/withdraw
- POST /transactions/transfer
4. TransactionService that calls Account Service via RestClient using Eureka service name 'account-service' (NOT hardcoded localhost). Use @LoadBalanced RestClient.Builder.
5. Error handling for: insufficient funds, account not found
6. JwtAuthFilter as OncePerRequestFilter validating JWT from Authorization header using JWT_SECRET environment variable (same approach as account-service)
7. Minimal application.yml with just spring.application.name=transaction-service and spring.config.import=optional:configserver:http://config-server:8888
Use constructor injection, Java records for DTOs, and ResponseEntity returns.
Why give Claude Code such a detailed prompt?
The CLAUDE.md file gives Claude Code the project conventions and architecture overview. Your prompt adds the specific requirements for this service.
Together, they give Claude Code enough context to produce code that follows the same patterns as your Account Service. The more precise your instructions, the less you need to fix afterwards.
Claude Code reads your CLAUDE.md and generates the full Transaction Service module. Wait for it to finish before continuing.
Claude Code taking too long or producing unexpected output?
If Claude Code stalls or generates code that doesn't match the requirements, press Esc twice to rewind to the last checkpoint. Then re-run your prompt with any additional clarification.
help me troubleshoot Claude Code not producing the right output for my transaction service
Review inter-service communication
Once Claude Code finishes, the most important thing to verify is how the Transaction Service calls the Account Service. This is where service discovery shines.
- Open transaction-service/src/main/java/com/banking/transaction/service/TransactionService.java in your editor.
- Verify that the service uses a @LoadBalanced RestClient.Builder to call the Account Service.
- Look for URLs using the logical service name http://account-service/accounts/... instead of a hardcoded address like http://localhost:8082.
The key pattern you should see in the TransactionService looks like this:
// RestClient uses Eureka service name, not hardcoded URL
// Eureka resolves 'account-service' to the actual container IP at runtime
restClient.get()
.uri("http://account-service/accounts/{id}", accountId)
.retrieve()
.body(AccountResponse.class);
How does Eureka resolve service names?
When the Transaction Service starts, it registers itself with Eureka and fetches the registry of all other services. The @LoadBalanced annotation on the RestClient.Builder tells Spring to intercept HTTP calls and resolve logical service names (like account-service) to actual network addresses from the Eureka registry.
This is the power of service discovery. If the Account Service moves to a different port or you add more instances, nothing in the Transaction Service code changes. Eureka handles the routing automatically.
- Also verify that the TransactionService.java includes error handling for insufficient funds (when a withdrawal or transfer exceeds the balance) and account not found (when the Account Service returns no result).
- Check that TransactionController.java exposes three endpoints: POST /transactions/deposit, POST /transactions/withdraw, and POST /transactions/transfer.
- Confirm the JwtAuthFilter.java validates JWTs from the Authorization header using the shared JWT_SECRET environment variable.
What should the build.gradle contain?
The Transaction Service needs fewer dependencies than the Account Service because it does not use Redis caching. Verify transaction-service/build.gradle includes: spring-boot-starter-web, spring-boot-starter-data-jpa, postgresql, the jjwt trio, spring-cloud-starter-config, spring-cloud-starter-netflix-eureka-client, and spring-boot-starter-actuator.
- Verify that transaction-service/src/main/resources/application.yml is minimal, containing only the application name and config server import:
spring:
application:
name: transaction-service
config:
import: optional:configserver:http://config-server:8888
Why is the local application.yml so small?
All the real configuration (database URL, server port, Eureka address) lives in the GitHub config repository. When the Transaction Service starts, it connects to the Config Server and fetches transaction-service.yml from there. The local file only needs enough info to find the Config Server.
Missing files or wrong patterns in Claude Code's output?
If the TransactionService uses a hardcoded URL like http://localhost:8083 instead of http://account-service, ask Claude Code to fix it: "Change the Account Service URL to use the Eureka service name 'account-service' instead of localhost."
If the application.yml contains database or Eureka configuration directly, ask Claude Code to remove it. Those values come from the Config Server at runtime.
help me fix Claude Code's transaction service output to use the right patterns
Create the Dockerfile
The Dockerfile follows the same multi-stage pattern you used for the other services. Building inside Docker ensures the build is reproducible regardless of what's installed on the host machine.
- Create a file called Dockerfile inside the transaction-service folder with the following content:
# Stage 1: Build the application using full JDK
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY build.gradle settings.gradle ./
COPY gradle ./gradle
COPY gradlew .
COPY src ./src
RUN chmod +x gradlew
RUN ./gradlew clean build -x test --no-daemon
# Stage 2: Run with lightweight JRE only
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8083
ENTRYPOINT ["java", "-jar", "app.jar"]
What does this Dockerfile do?
The first stage uses the full JDK to compile your code and produce a JAR file. The second stage copies only the JAR into a lightweight JRE image. This keeps the final container small (around 200MB instead of 600MB with the full JDK).
Port 8083 is exposed internally so NGINX and other services on the Docker network can reach the Transaction Service. External traffic never hits this port directly.
- Save transaction-service/Dockerfile.
- Confirm the file exists by running:
ls transaction-service/Dockerfile
You should see the file path printed in the output, confirming the Dockerfile is in place.
File not found?
Make sure you created the file inside the transaction-service folder, not at the project root. The file must be named exactly Dockerfile with a capital D and no file extension.
help me find where my Dockerfile should go in the transaction-service module
✔️ Awesome, I've got everything!
Great. Double check you've saved your Dockerfile and that all the Claude Code-generated files are in the right places under transaction-service/.
ⓧ I'd like to double check the full code
Here is the Dockerfile you should have in transaction-service/Dockerfile:
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY build.gradle settings.gradle ./
COPY gradle ./gradle
COPY gradlew .
COPY src ./src
RUN chmod +x gradlew
RUN ./gradlew clean build -x test --no-daemon
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8083
ENTRYPOINT ["java", "-jar", "app.jar"]
And here is what your transaction-service/src/main/resources/application.yml should look like:
spring:
application:
name: transaction-service
config:
import: optional:configserver:http://config-server:8888
The remaining Java files (TransactionServiceApplication.java, TransactionController.java, TransactionService.java, Transaction.java, JwtAuthFilter.java) were generated by Claude Code. Verify they follow the patterns described in the review substep above.
All five microservices are now built. Next up, you will configure NGINX as the API gateway that ties them all together behind a single entry point.
Configure NGINX as API Gateway
Your five microservices are built, each with their own endpoints and authentication logic. But right now, a client would need to know the exact port of every service to talk to it directly.
In production, you never expose internal services to the outside world. Instead, you place a reverse proxy in front of them that acts as a single entry point. In this step, you will configure NGINX as an API Gateway that routes requests to the correct service based on the URL path.
In this step, get ready to:
- Write an NGINX configuration with path-based routing to all three business services.
- Create a Dockerfile for the NGINX container.
- Understand how the gateway pattern secures and simplifies your system.
Write nginx.conf
NGINX needs a configuration file that defines where to forward incoming requests. You will create upstream blocks that name each backend service, then a server block with location rules that match URL paths to those upstreams.
- Create a new file called nginx.conf inside the nginx/ directory.
- Add the following configuration to nginx/nginx.conf:
# Define backend service groups using Docker Compose service names
upstream auth_service {
server auth-service:8081;
}
upstream account_service {
server account-service:8082;
}
upstream transaction_service {
server transaction-service:8083;
}
server {
listen 80;
# Route /auth/ requests to the Auth Service
location /auth/ {
proxy_pass http://auth_service/auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Route /accounts/ requests to the Account Service
location /accounts/ {
proxy_pass http://account_service/accounts/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Route /transactions/ requests to the Transaction Service
location /transactions/ {
proxy_pass http://transaction_service/transactions/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Simple health check endpoint for monitoring
location = /health {
access_log off;
return 200 "OK\n";
}
}
What does this configuration do?
The upstream blocks define named groups of backend servers. NGINX uses Docker Compose service names (auth-service, account-service, transaction-service) as hostnames because Docker's internal DNS resolves them automatically.
Each location block matches a URL path and forwards the request to the corresponding upstream. The proxy_set_header directives pass along the client's real IP address and protocol information so the backend services know who made the original request.
The /health endpoint is a lightweight check that returns 200 without touching any backend. Monitoring tools use this to confirm NGINX is alive.
- Save nginx/nginx.conf.
Why both NGINX and Eureka?
NGINX handles external traffic routing. Clients only know about http://localhost/ and NGINX decides which internal service to forward to. This is north-south traffic (outside world to your system).
Eureka handles internal service-to-service communication. When Transaction Service needs to call Account Service, it looks up the service name in Eureka. This is east-west traffic (between services inside your system). They complement each other.
NGINX config not working later?
The most common issue is a typo in upstream names. The name in proxy_pass http://auth_service must exactly match the upstream auth_service block name (underscores, not hyphens). The server lines inside upstreams use hyphens (auth-service:8081) because those are Docker Compose service names.
Help me debug my NGINX upstream configuration
Create the NGINX Dockerfile
NGINX ships with a default config file that serves a welcome page. You need to replace it with your custom routing configuration.
- Create a new file called Dockerfile inside the nginx/ directory.
- Add the following content to nginx/Dockerfile:
FROM nginx:alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
What does this Dockerfile do?
- FROM nginx:alpine starts from the lightweight Alpine-based NGINX image.
- RUN rm /etc/nginx/conf.d/default.conf removes the default welcome page configuration.
- COPY nginx.conf /etc/nginx/conf.d/default.conf replaces it with your custom routing config.
- Save nginx/Dockerfile.
- Confirm both files exist by running:
ls nginx/
You should see Dockerfile and nginx.conf listed in the output.
Only see one file or none?
Make sure you created both files inside the nginx/ folder, not in the project root. The Dockerfile has no file extension. If you accidentally saved it as Dockerfile.txt, rename it to just Dockerfile.
Help me check my nginx directory structure
Understand the gateway pattern
With NGINX in place, your system now follows the API Gateway pattern. External clients never talk to services directly.
What does the gateway give you?
- Single entry point: clients only know about http://localhost/ on port 80. They never see ports 8081, 8082, or 8083.
- Load balancing: NGINX can distribute requests across multiple instances of a service. Add more server lines inside an upstream block to scale horizontally.
- Security boundary: internal services use expose in Docker Compose (internal only). Only NGINX uses ports (externally accessible).
- SSL termination point: in production, HTTPS is handled at NGINX. Backend services stay on plain HTTP internally, simplifying their config.
Look at your docker-compose.yml and notice how auth-service, account-service, and transaction-service all use expose while only NGINX uses ports: - "80:80". This means only NGINX is reachable from the outside.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files in the nginx/ directory.
ⓧ I'd like to double check the full code
Here is the complete nginx/nginx.conf:
upstream auth_service {
server auth-service:8081;
}
upstream account_service {
server account-service:8082;
}
upstream transaction_service {
server transaction-service:8083;
}
server {
listen 80;
location /auth/ {
proxy_pass http://auth_service/auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /accounts/ {
proxy_pass http://account_service/accounts/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /transactions/ {
proxy_pass http://transaction_service/transactions/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /health {
access_log off;
return 200 "OK\n";
}
}
And the complete nginx/Dockerfile:
FROM nginx:alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
Your entire system is now configured. Every service has its code, its Dockerfile, and its configuration. Next up, you will build all the Docker images and launch the full 8-container system to see everything working together.
Launch and Test the Full System
You have all 5 services coded, configured, and ready to run. NGINX is configured as the gateway, Eureka is ready to track your services, and Config Server knows where to pull your GitHub YAML files from.
Now it is time to bring the entire system to life. In this step, you will build every service into a Docker image, launch all 8 containers simultaneously, and prove that the whole distributed system works end-to-end.
In this step, get ready to:
- Build all Gradle modules and launch the full Docker Compose stack.
- Test the complete SSO authentication flow through the NGINX gateway.
- Verify Config Server and Redis caching are working correctly.
Build and launch all containers
Before Docker can run your services, each one needs to be compiled into a JAR file. The root Gradle wrapper handles this for all five subprojects in one command.
- Build all services by running this command:
.\gradlew clean build -x test
What does this command do?
The .\gradlew clean build -x test command compiles all five subprojects (eureka-server, config-server, auth-service, account-service, transaction-service) into executable JAR files.
The -x test flag skips running unit tests, which speeds up the build since you are testing the integrated system manually.
- You should see a BUILD SUCCESSFUL message at the end of the output.
- Launch all 8 containers by running:
docker compose up --build -d
What does this command do?
The --build flag tells Docker Compose to build fresh images from each service's Dockerfile before starting the containers.
The -d flag runs everything in detached mode so your terminal stays available for testing commands.
This will take a few minutes the first time as Docker downloads base images and builds each service.
- Verify all containers are running by running:
docker compose ps
- You should see 8 containers listed, all with a State of running: postgres, redis, eureka-server, config-server, auth-service, account-service, transaction-service, and nginx.
- Open your browser and navigate to http://localhost:8761 to view the Eureka dashboard.
- Under Instances currently registered with Eureka, you should see CONFIG-SERVER, AUTH-SERVICE, ACCOUNT-SERVICE, and TRANSACTION-SERVICE listed.
Containers keep restarting?
If services show restarting instead of running, check the logs for that specific service.
Run docker compose logs auth-service (replace with the failing service name) to see the error. The most common cause is PostgreSQL or Eureka not being ready yet. Wait 30 seconds and run docker compose ps again.
If Config Server fails, verify your GitHub config repository URL in config-server/src/main/resources/application.yml is correct and the repository is public.
Help me debug why my Docker containers keep restarting.
Test the end-to-end flow
The entire point of your SSO system is that one login gives you access to every service. You will now prove this by registering a user, getting a JWT, and using it across Account and Transaction services.
All requests go through the NGINX gateway on port 80. No service is contacted directly.
- Register a new user by running:
curl -X POST http://localhost/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "testuser@bank.com", "password": "SecurePass123", "role": "USER"}'
- You should see a JSON response confirming the user was created (with an id and email field).
- Log in to receive a JWT token by running:
curl -X POST http://localhost/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "testuser@bank.com", "password": "SecurePass123"}'
- The response includes a token field containing your JWT. Copy this token value. You will use it in all subsequent requests.
- Create a bank account by running the following command, replacing your-jwt-token-here with the token you copied:
curl -X POST http://localhost/accounts \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-jwt-token-here" \
-d '{"accountType": "SAVINGS"}'
- You should see a JSON response with account details including id, accountNumber, and balance (starting at 0).
- Check the account balance by running (replace 1 with the account id from the previous response):
curl -X GET http://localhost/accounts/1 \
-H "Authorization: Bearer your-jwt-token-here"
- Make a deposit by running:
curl -X POST http://localhost/transactions/deposit \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-jwt-token-here" \
-d '{"accountId": 1, "amount": 1000.00}'
- You should see a transaction confirmation with type set to DEPOSIT and status showing success.
- Test a transfer between accounts by running (create a second account first using the account creation command with a different accountType if needed):
curl -X POST http://localhost/transactions/transfer \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-jwt-token-here" \
-d '{"fromAccountId": 1, "toAccountId": 2, "amount": 250.00}'
What just happened?
You authenticated once with the Auth Service and used the same JWT token to access both the Account Service and Transaction Service. This is Single Sign-On in action.
Each service independently verified the JWT signature using the shared secret (provided by Config Server via GitHub). No service called back to the Auth Service to validate your token.
The Transaction Service internally called the Account Service through Eureka service discovery to update balances. You never had to tell it where Account Service lives.
Getting a 401 Unauthorized?
Make sure you are copying the full token string from the login response. JWT tokens are long strings starting with eyJ.
Check that you include the word Bearer (with a capital B) followed by a space before the token in the Authorization header.
Help me fix my 401 Unauthorized error when calling the banking API.
Verify Config Server and Redis
Two critical infrastructure pieces are working behind the scenes: Config Server is distributing configuration to all services, and Redis is caching account data for faster lookups. You can verify both.
- Verify Config Server is serving configuration by opening this URL in your browser:
http://localhost:8888/auth-service/default
- You should see a JSON response containing the configuration properties for auth-service, including the database URL, JWT secret, and Eureka settings pulled from your GitHub repository.
Why does this prove Config Server works?
Config Server fetched auth-service.yml and application.yml from your GitHub repository and merged them into a single configuration response. Every service that starts up receives its config this way.
If you change a value in your GitHub YAML files and restart a service, it picks up the new config without rebuilding any Docker images.
Now verify Redis caching is active.
- Call the account balance endpoint twice in a row using the same curl command from earlier:
curl -X GET http://localhost/accounts/1 \
-H "Authorization: Bearer your-jwt-token-here"
- The first call hits PostgreSQL directly. The second call returns from Redis cache (you may notice it responds slightly faster).
- Inspect the cached keys in Redis by running:
docker compose exec redis redis-cli
- Once inside the Redis CLI, list all cached keys by running:
KEYS *
- You should see keys related to your accounts cache. This confirms that @Cacheable("accounts") in your Account Service is working.
- Type exit to leave the Redis CLI.
What does Redis caching accomplish?
In a real banking system, balance lookups happen far more often than balance changes. Without caching, every GET /accounts/{id} call hits the database.
With Redis, the Account Service stores the result of the first lookup in the cache with a 5-minute TTL. Subsequent lookups return instantly from Redis until the cache expires or the balance changes (which triggers @CacheEvict).
KEYS * returns empty?
Make sure you called GET /accounts/{id} at least once before checking Redis. The cache is populated on the first successful read.
Check that the Redis container is healthy by running docker compose ps redis. If it shows unhealthy, run docker compose restart redis and try again.
Help me debug why Redis caching is not working in my Account Service.
Secret mission
Add NGINX Rate Limiting
Your banking API is live and working. But what happens if someone sends thousands of login attempts per second? In this mission, you'll add NGINX rate limiting to protect your services from brute-force attacks and API abuse.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
This project runs entirely locally, so there are no ongoing cloud costs. Docker containers use system memory and CPU while running, but nothing is billed.
Resources you used:
- 8 Docker containers (NGINX, Eureka, Config Server, Auth, Account, Transaction, PostgreSQL, Redis).
- Docker volume (postgres_data) storing PostgreSQL data.
- Local project folder (banking-microservices) on your Desktop.
- GitHub repository (banking-config-repo) with centralized configuration YAML files.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with the system.
- All 8 containers stay up. PostgreSQL data persists in the volume.
- No ongoing financial cost. Docker containers use system memory and CPU while running.
- You can continue testing endpoints through the NGINX gateway at http://localhost.
- The Eureka dashboard remains available at http://localhost:8761.
Pause - I'll come back to this later
Shut down running containers to free up system memory. Your data and images stay intact for a quick restart later.
- Open a terminal and navigate to your banking-microservices folder on the Desktop.
- Stop all containers by running:
docker compose stop
- Your containers stop but are not removed. PostgreSQL data stays in the volume.
- When you're ready to resume, run:
docker compose start
- All services come back up with your existing data intact.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes containers, networks, volumes, and all stored database data.
- Open a terminal and navigate to your banking-microservices folder on the Desktop.
- Remove all containers, networks, and volumes by running:
docker compose down -v
- This removes all 8 containers, the banking-network bridge network, and the postgres_data volume. All database data is permanently deleted.
- Remove the Docker images built for this project by running:
docker compose down --rmi local
- Delete the local project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf banking-microservices
- On Windows, run:
cd ~\Desktop
Remove-Item -Recurse -Force banking-microservices
- Delete the GitHub config repository. Go to your banking-config-repo repository on GitHub.
- Click Settings in the repository's top navigation bar.
- Scroll to the bottom to the Danger Zone section.
- Click Delete this repository and follow the confirmation prompts.
Nice Work!
Nice Work!
Nice work! You just built a production-grade banking microservices system from scratch using Spring Boot, Spring Cloud, Docker, and Claude Code.
You learned how to:
- Built 5 Spring Boot microservices with SSO authentication using JWT tokens trusted across all services, centralized configuration reading from GitHub via Config Server, and Eureka service discovery so services find each other dynamically.
- Orchestrated 8 Docker containers with Docker Compose on a shared network, including PostgreSQL for data persistence, Redis caching for high-performance account lookups, and an NGINX API gateway with path-based routing and load balancing.
- Used Claude Code to parallelize development, building services asynchronously while you architected the system.
- Added NGINX rate limiting to protect the banking API from brute-force attacks with separate zones for login and general API traffic.
Ready to quiz yourself?