Build an AI Decision Copilot Backend
Build a production-grade REST API with async processing, caching, and AI.
Introduction
30 Second Summary
Every online purchase is a gamble. You read dozens of reviews, compare prices, weigh trade-offs, and still wonder if you made the right call.
In this project, you will build a production-grade backend API that accepts product queries and returns AI-generated buying decisions complete with pros, cons, a confidence score, and a final verdict.
What You'll Build
A Spring Boot REST API with async processing, PostgreSQL persistence, Redis caching, fault tolerance patterns, and RAG-based decision improvement using vector similarity search.
By the end of this project, you'll have:
- A REST API with async job processing that accepts product queries and returns structured AI decisions using Google Gemini.
- A PostgreSQL database with proper schema design, Flyway migrations, and pgvector for embedding-based similarity search.
- A production-ready backend with Redis caching, circuit breakers via Resilience4j, retry logic with exponential backoff, and rate limiting.
- A fully Dockerized application deployed to Railway with PostgreSQL, Redis, and environment-based configuration.
- Secret Mission: A webhook notification system that fires when a decision completes, with delivery tracking and retry logic.
Are there any prerequisites?
You should have basic Java knowledge, an understanding of REST APIs, and familiarity with SQL. You do not need prior experience with Spring Boot, Docker, or AI integrations.
Before We Start
Before We Start...
Set Up Java, Docker, and Project Scaffold
Before you can build anything, you need the right tools on your machine. Every production Java backend starts with a runtime, a build tool, and a containerization layer.
In this step, you'll install Java 21, Maven, and Docker Desktop. Then you'll scaffold a Spring Boot project with all the dependencies you'll use throughout the project and configure it to talk to a database, a cache, and an AI model.
In this step, get ready to:
- Install Java 21, Maven, and Docker Desktop.
- Generate a Spring Boot project with production dependencies.
- Create the package structure and application configuration.
Install Java 21, Maven, and Docker Desktop
Your backend needs three foundational tools. Java 21 is the runtime, Maven builds and manages dependencies, and Docker lets you run databases locally in containers.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
First, check whether Java 21 is already installed.
- Check your Java version by running this command:
java -version
✔️ I see java version "21.x.x"
Java 21 is ready. Skip ahead to the Maven check below.
ⓧ Command not found or a different version
You need to install Eclipse Temurin 21, the open-source Java 21 distribution.
macOS
- Install Java 21 via Homebrew by running this command:
brew install --cask temurin@21
If you don't have Homebrew installed, visit brew.sh and follow the install instructions first.
Windows
- Visit the Adoptium downloads page at adoptium.net/temurin/releases.
- Download the .msi installer for Windows x64, JDK version 21.
- Run the installer and accept the defaults.
- Close and reopen your terminal for the PATH changes to take effect.
- Verify the install by running:
java -version
You should see output containing openjdk version "21.
Next, check whether Maven is installed.
- Check your Maven version by running:
mvn -version
✔️ I see Apache Maven 3.x.x
Maven is ready. Skip ahead to the Docker check below.
ⓧ Command not found
macOS
- Install Maven via Homebrew by running:
brew install maven
Windows
- Install Maven via Chocolatey by running:
choco install maven
If you don't have Chocolatey, visit chocolatey.org/install to set it up first.
- Verify the install by running:
mvn -version
You should see output containing Apache Maven 3 and a reference to Java 21.
Finally, check whether Docker Desktop is installed.
- Check your Docker version by running:
docker --version
✔️ I see Docker version 2x.x.x
Docker is ready. Make sure Docker Desktop is running (check for the whale icon in your system tray).
ⓧ Command not found
macOS
- Install Docker Desktop via Homebrew by running:
brew install --cask docker
- Open Docker Desktop from your Applications folder or via Spotlight search.
- Accept the service agreement when prompted.
Windows
- Visit docker.com/products/docker-desktop and download the Windows installer.
- Run the installer and accept the defaults.
- Restart your computer if prompted.
- Open Docker Desktop and accept the service agreement.
- Verify the install by running:
docker --version
You should see output like Docker version 27.x.x.
Having trouble with any install?
Make sure you've closed and reopened your terminal after installing. PATH changes only apply to new terminal sessions. On macOS, if brew itself isn't found, follow the post-install instructions Homebrew prints (adding it to your shell profile).
Help me troubleshoot my Java, Maven, or Docker installation.
Generate the Spring Boot project from start.spring.io
Spring Initializr is a web tool that generates a ready-to-run Spring Boot project with your chosen dependencies already wired in. Instead of manually creating a Maven project and adding libraries one by one, you get a working skeleton in seconds.
- Open start.spring.io in your browser.
- Under Project, select Maven.
- Under Language, select Java.
- Under Spring Boot, select the latest 3.5.x version available.
- Set Group to com.decisioncopilot.
- Set Artifact to decision-copilot.
- Set Java to 21.
Now add the dependencies your project needs.
- Click Add Dependencies (or press Ctrl+B).
- Search for and add Spring Web.
- Add Spring Data JPA.
- Add Spring Data Redis (Access+Driver).
- Add Spring Boot Actuator.
- Add PostgreSQL Driver.
- Add Flyway Migration.
- Add Validation.
What about Spring AI and OpenAPI?
Spring AI and SpringDoc OpenAPI are not available as checkboxes on start.spring.io for this Spring Boot version. You'll add them manually to your pom.xml in a later step.
- Click Generate to download the project as a ZIP file.
- Extract the ZIP to your Desktop.
Now import the project into your IDE.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type the name of your IDE (IntelliJ IDEA or VS Code) and press Enter to open it.
IntelliJ IDEA
- Click File in the top menu bar.
- Click Open.
- Navigate to the decision-copilot folder on your Desktop and click OK.
- If prompted to trust the project, click Trust Project.
- Wait for IntelliJ to finish indexing and downloading dependencies (visible in the bottom progress bar).
VS Code
- Click File in the top menu bar.
- Click Open Folder.
- Select the decision-copilot folder on your Desktop and click Open.
- Install the Extension Pack for Java extension if you haven't already (search for it in the Extensions sidebar).
You should see the project structure appear in the file explorer with folders like src/main/java and src/main/resources.
IDE not recognizing the project as Java?
Make sure you opened the folder containing the pom.xml file at the root level. If you opened a parent folder by accident, the IDE won't detect it as a Maven project.
Help me fix my IDE not recognizing my Spring Boot project.
Set up the package structure and application configuration
Production Spring Boot projects organize code into packages by responsibility. This keeps your controllers, services, and data layer cleanly separated as the project grows.
- Open your terminal inside the project root (the decision-copilot folder that contains pom.xml).
- Create the package directories by running these commands:
mkdir -p src/main/java/com/decisioncopilot/controller
mkdir -p src/main/java/com/decisioncopilot/service
mkdir -p src/main/java/com/decisioncopilot/repository
mkdir -p src/main/java/com/decisioncopilot/model
mkdir -p src/main/java/com/decisioncopilot/dto
mkdir -p src/main/java/com/decisioncopilot/config
mkdir -p src/main/java/com/decisioncopilot/exception
What are these packages for?
- controller holds your REST API endpoints.
- service holds business logic and orchestration.
- repository holds database access interfaces.
- model holds JPA entity classes (your database tables as Java objects).
- dto holds Data Transfer Objects for API requests and responses.
- config holds Spring configuration classes (async, caching, etc.).
- exception holds custom exception classes and the global error handler.
- Confirm the directories exist by running:
ls src/main/java/com/decisioncopilot/
You should see config, controller, dto, exception, model, repository, and service listed in the output.
Now you need to configure how the application connects to its external services. Spring Boot uses a single application.yml file for all configuration.
Before creating the config file, you need a Gemini API key. Google provides a free tier with 1,500 requests per day on Gemini 2.5 Flash. No credit card is required.
- Open aistudio.google.com in your browser.
- Sign in with your Google account.
- Click Get API key in the left sidebar.
- Click Create API key.
- Copy the generated key and save it somewhere safe. You will use it in a moment.
Why Gemini over OpenAI or Anthropic?
Gemini is the only frontier LLM provider with a permanent free tier. You get 1,500 requests per day on Gemini 2.5 Flash with no credit card required. OpenAI requires a $5 deposit for real usage, and Anthropic has no free tier at all.
- Set your Gemini API key as an environment variable so the application can read it at runtime.
macOS
- Add the key to your shell profile by running this command (replace the placeholder with your actual key):
echo 'export GEMINI_API_KEY=your-api-key-here' >> ~/.zshrc
source ~/.zshrc
Windows
- Set the environment variable in PowerShell by running this command (replace the placeholder with your actual key):
[System.Environment]::SetEnvironmentVariable('GEMINI_API_KEY', 'your-api-key-here', 'User')
Close and reopen your terminal for the change to take effect.
Now create the main configuration file.
- Spring Initializr created an application.properties file by default. Delete it and create application.yml instead by running:
rm src/main/resources/application.properties
touch src/main/resources/application.yml
- Open src/main/resources/application.yml in your IDE and paste the following configuration:
spring:
application:
name: decision-copilot
datasource:
url: jdbc:postgresql://localhost:5432/decision_copilot
username: postgres
password: postgres
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
flyway:
enabled: true
locations: classpath:db/migration
data:
redis:
host: localhost
port: 6379
cache:
type: redis
redis:
time-to-live: 30m
ai:
openai:
api-key: ${GEMINI_API_KEY}
base-url: https://generativelanguage.googleapis.com/v1beta/openai
chat:
options:
model: gemini-2.5-flash
temperature: 0.7
server:
port: 8080
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
resilience4j:
circuitbreaker:
instances:
llmService:
registerHealthIndicator: true
slidingWindowSize: 10
minimumNumberOfCalls: 5
permittedNumberOfCallsInHalfOpenState: 3
automaticTransitionFromOpenToHalfOpenEnabled: true
waitDurationInOpenState: 30s
failureRateThreshold: 50
recordExceptions:
- java.util.concurrent.TimeoutException
- java.io.IOException
- org.springframework.web.client.HttpServerErrorException
retry:
instances:
llmService:
maxAttempts: 3
waitDuration: 2s
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
retryExceptions:
- java.util.concurrent.TimeoutException
- java.io.IOException
ratelimiter:
instances:
decisionApi:
limitForPeriod: 10
limitRefreshPeriod: 1s
timeoutDuration: 0
management:
endpoints:
web:
exposure:
include: health,metrics,circuitbreakers,ratelimiters
health:
circuitbreakers:
enabled: true
What does this configuration do?
- spring.datasource connects to a PostgreSQL database running on port 5432. You'll start this database via Docker in the next step.
- spring.jpa.ddl-auto: validate tells Hibernate to check that your entity classes match the database schema, but never auto-create tables. Flyway handles all schema changes.
- spring.flyway enables database migration scripts. Your schema changes are versioned SQL files.
- spring.data.redis and spring.cache set up Redis caching with a 30-minute TTL for cached decisions.
- spring.ai.openai points Spring AI at Google Gemini's OpenAI-compatible endpoint. The ${GEMINI_API_KEY} placeholder reads from your environment variable at startup.
- resilience4j configures fault tolerance: a circuit breaker that opens after 50% failures, retries with exponential backoff, and a rate limiter capping requests at 10 per second.
- management exposes health check and metrics endpoints for monitoring.
- Save application.yml.
You won't be able to start the application yet because PostgreSQL and Redis aren't running. You'll spin them up in the next step with Docker Compose.
YAML indentation errors?
YAML is whitespace-sensitive. Make sure you're using spaces (not tabs) and that nested keys are indented by exactly 2 spaces relative to their parent. Most IDEs highlight YAML syntax errors with a red underline.
Help me fix a YAML indentation error in my application.yml.
✔️ Awesome, I've got everything!
Make sure you've saved your file. You're all set for the next step.
ⓧ I'd like to double check the full code
Here is the complete application.yml for comparison.
spring:
application:
name: decision-copilot
datasource:
url: jdbc:postgresql://localhost:5432/decision_copilot
username: postgres
password: postgres
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
flyway:
enabled: true
locations: classpath:db/migration
data:
redis:
host: localhost
port: 6379
cache:
type: redis
redis:
time-to-live: 30m
ai:
openai:
api-key: ${GEMINI_API_KEY}
base-url: https://generativelanguage.googleapis.com/v1beta/openai
chat:
options:
model: gemini-2.5-flash
temperature: 0.7
server:
port: 8080
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
resilience4j:
circuitbreaker:
instances:
llmService:
registerHealthIndicator: true
slidingWindowSize: 10
minimumNumberOfCalls: 5
permittedNumberOfCallsInHalfOpenState: 3
automaticTransitionFromOpenToHalfOpenEnabled: true
waitDurationInOpenState: 30s
failureRateThreshold: 50
recordExceptions:
- java.util.concurrent.TimeoutException
- java.io.IOException
- org.springframework.web.client.HttpServerErrorException
retry:
instances:
llmService:
maxAttempts: 3
waitDuration: 2s
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
retryExceptions:
- java.util.concurrent.TimeoutException
- java.io.IOException
ratelimiter:
instances:
decisionApi:
limitForPeriod: 10
limitRefreshPeriod: 1s
timeoutDuration: 0
management:
endpoints:
web:
exposure:
include: health,metrics,circuitbreakers,ratelimiters
health:
circuitbreakers:
enabled: true
Your development environment is ready and your project is configured. Next up, you'll create the Docker Compose file to spin up PostgreSQL and Redis, then write your first database migration.
Configure Docker Compose and Database Schema
Your Spring Boot project has an application.yml pointing at a PostgreSQL database on port 5432 and a Redis cache on port 6379. Those services do not exist yet.
In this step, you will spin up the infrastructure your backend depends on using Docker Compose. You will also define the database schema through a Flyway migration so that tables are created automatically every time the app starts.
In this step, get ready to:
- Create a Docker Compose configuration for PostgreSQL and Redis with health checks and persistent volumes.
- Write a Flyway migration defining the core database tables, enums, and indexes.
- Start the containers and verify connectivity end to end.
Create the Docker Compose file
Docker Compose lets you define multi-container environments in a single YAML file. Your backend needs two services: a PostgreSQL database with the pgvector extension (for vector search later) and a Redis cache.
- Create a file called docker-compose.yml in the root of your decision-copilot project folder (the same level as pom.xml).
- Paste the following configuration into the file:
version: '3.8'
services:
# PostgreSQL 16 with pgvector extension pre-installed
postgres:
image: pgvector/pgvector:pg16
container_name: decision-copilot-db
environment:
POSTGRES_DB: decision_copilot
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
# Redis 7 for caching decision results
redis:
image: redis:7-alpine
container_name: decision-copilot-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# Named volumes persist data between container restarts
volumes:
postgres_data:
redis_data:
What does this configuration do?
The postgres service uses the pgvector/pgvector:pg16 image. This gives you PostgreSQL 16 with the vector extension pre-installed, which you will need for RAG in a later step.
The redis service uses the lightweight redis:7-alpine image. Alpine images are smaller and faster to pull.
Both services include healthcheck blocks so Docker knows when each container is actually ready to accept connections. Named volumes (postgres_data, redis_data) keep your data safe across container restarts.
- Save docker-compose.yml.
Why pgvector instead of plain PostgreSQL?
In Step 9 you will add RAG (Retrieval-Augmented Generation) using vector similarity search. The pgvector/pgvector:pg16 image bundles the vector extension so you do not have to install it manually later. Everything else works exactly like standard PostgreSQL 16.
Write the Flyway migration
Flyway manages your database schema through versioned SQL scripts. When your Spring Boot app starts, Flyway checks which migrations have already run and applies any new ones automatically.
- Create the directory path src/main/resources/db/migration/ inside your project.
- Create a file called V1__init_schema.sql in that folder.
- Paste the following SQL into the file:
-- Enable UUID generation
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Custom enum types for status tracking
CREATE TYPE decision_status AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED');
CREATE TYPE verdict_type AS ENUM ('BUY', 'DONT_BUY', 'MAYBE');
-- Products table stores fetched product information
CREATE TABLE products (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
name VARCHAR(500) NOT NULL,
url VARCHAR(2000),
price DECIMAL(10, 2),
rating DECIMAL(3, 2),
review_count INTEGER,
category VARCHAR(200),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Decision queries track each user request and its processing status
CREATE TABLE decision_queries (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
product_id UUID REFERENCES products(id),
input_text VARCHAR(2000) NOT NULL,
status decision_status DEFAULT 'PENDING',
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Decision results hold the AI-generated verdict, pros, cons, and metadata
CREATE TABLE decision_results (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
query_id UUID REFERENCES decision_queries(id) UNIQUE,
product_id UUID REFERENCES products(id),
verdict verdict_type NOT NULL,
confidence_score DECIMAL(3, 2),
pros TEXT[] NOT NULL,
cons TEXT[] NOT NULL,
summary TEXT NOT NULL,
reasoning TEXT,
llm_model VARCHAR(100),
token_usage INTEGER,
processing_time_ms BIGINT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Indexes for fast lookups
CREATE INDEX idx_decision_queries_status ON decision_queries(status);
CREATE INDEX idx_decision_queries_created_at ON decision_queries(created_at DESC);
CREATE INDEX idx_decision_results_query_id ON decision_results(query_id);
CREATE INDEX idx_decision_results_verdict ON decision_results(verdict);
CREATE INDEX idx_products_name ON products(name);
CREATE INDEX idx_products_name_trgm ON products USING gin(name gin_trgm_ops);
What does this migration do?
The uuid-ossp extension lets PostgreSQL auto-generate UUID primary keys. The two ENUM types (decision_status and verdict_type) enforce valid states at the database level.
The products table stores fetched product data. The decision_queries table tracks every user request with a status column for async polling. The decision_results table holds the AI verdict, pros/cons arrays, and performance metadata.
Indexes on status and created_at speed up the polling and history endpoints you will build later. The trigram GIN index on product names enables fast fuzzy text search.
- Save V1__init_schema.sql.
Flyway naming convention
Flyway file names follow the pattern V<version>__<description>.sql. The double underscore between the version number and description is required. Flyway will refuse to run files that do not match this pattern.
✔️ Awesome, I've got everything!
Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the full docker-compose.yml:
version: '3.8'
services:
postgres:
image: pgvector/pgvector:pg16
container_name: decision-copilot-db
environment:
POSTGRES_DB: decision_copilot
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: decision-copilot-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:
And the full V1__init_schema.sql:
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TYPE decision_status AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED');
CREATE TYPE verdict_type AS ENUM ('BUY', 'DONT_BUY', 'MAYBE');
CREATE TABLE products (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
name VARCHAR(500) NOT NULL,
url VARCHAR(2000),
price DECIMAL(10, 2),
rating DECIMAL(3, 2),
review_count INTEGER,
category VARCHAR(200),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE decision_queries (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
product_id UUID REFERENCES products(id),
input_text VARCHAR(2000) NOT NULL,
status decision_status DEFAULT 'PENDING',
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE decision_results (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
query_id UUID REFERENCES decision_queries(id) UNIQUE,
product_id UUID REFERENCES products(id),
verdict verdict_type NOT NULL,
confidence_score DECIMAL(3, 2),
pros TEXT[] NOT NULL,
cons TEXT[] NOT NULL,
summary TEXT NOT NULL,
reasoning TEXT,
llm_model VARCHAR(100),
token_usage INTEGER,
processing_time_ms BIGINT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_decision_queries_status ON decision_queries(status);
CREATE INDEX idx_decision_queries_created_at ON decision_queries(created_at DESC);
CREATE INDEX idx_decision_results_query_id ON decision_results(query_id);
CREATE INDEX idx_decision_results_verdict ON decision_results(verdict);
CREATE INDEX idx_products_name ON products(name);
CREATE INDEX idx_products_name_trgm ON products USING gin(name gin_trgm_ops);
Start containers and verify connectivity
With both files in place, you can start the infrastructure and confirm everything connects.
- Make sure Docker Desktop is running.
- Open your terminal in the project root (the folder containing docker-compose.yml).
- Start both containers in detached mode by running:
docker compose up -d
- Wait a few seconds for the health checks to pass, then confirm both containers are healthy by running:
docker compose ps
You should see two containers listed with a status of healthy.
Containers not starting?
- If you see a port conflict on 5432 or 6379, another process is already using that port. Stop any local PostgreSQL or Redis instances first.
- If Docker reports "cannot connect to the Docker daemon", make sure Docker Desktop is open and fully started.
- Help me troubleshoot my Docker containers.
- Test the PostgreSQL connection by running:
docker exec -it decision-copilot-db psql -U postgres -d decision_copilot -c "SELECT 1;"
You should see a result with one row containing the value 1.
- Test the Redis connection by running:
docker exec -it decision-copilot-redis redis-cli ping
You should see PONG printed in the terminal.
Why verify manually before running the app?
If the containers are not reachable, your Spring Boot app will fail on startup with a confusing stack trace. Confirming connectivity now means any startup error later is a code issue, not an infrastructure issue.
Finally, confirm that Flyway runs the migration when your app starts.
- Run your Spring Boot application from your IDE (click the green play button next to the main method in DecisionCopilotApplication.java), or run this command from the project root:
./mvnw spring-boot:run
- Watch the console output for Flyway log lines. You should see:
Flyway ... - Migrating schema "public" to version "1 - init schema"
Flyway ... - Successfully applied 1 migration
Seeing a migration error about pg_trgm?
The last index in the migration uses gin_trgm_ops which requires the pg_trgm extension. If you see an error like operator class "gin_trgm_ops" does not exist, connect to the database and enable the extension manually:
docker exec -it decision-copilot-db psql -U postgres -d decision_copilot -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
Then restart your Spring Boot app. Flyway will retry the migration.
Help me fix my Flyway migration error.
App fails to start with connection refused?
- Confirm your containers are still running with docker compose ps.
- Make sure application.yml has url: jdbc:postgresql://localhost:5432/decision_copilot (not postgres as the host, which is only valid inside Docker networks).
- Help me fix my database connection error.
Once you see the Flyway success message and the app starts without errors, your tables are ready.
- Verify the tables exist by running:
docker exec -it decision-copilot-db psql -U postgres -d decision_copilot -c "\dt"
You should see products, decision_queries, decision_results, and the Flyway history table (flyway_schema_history) listed.
Your database and cache infrastructure is live. Next up, you will build the domain models and repository layer that map to these tables.
Build the Product Data Service and LLM Integration
Your database schema and domain models are locked in. Now it is time to build the services that actually do something with that data.
In this step, you will build two critical services. The first fetches product information (simulated for now, but designed so a real scraper can be swapped in later). The second sends that product data to Google Gemini and parses back a structured buy/don't-buy decision.
In this step, get ready to:
- Build a ProductDataService with a swappable interface design.
- Add the Spring AI dependency and configure it to use Gemini's free API.
- Build a fault-tolerant LlmDecisionService with circuit breaker and retry patterns.
Create the ProductDataService interface and implementation
In production, you would fetch real product data from a web scraper or external API. For now, you will simulate this data. The key design decision is to code against an interface so the real implementation can be dropped in without changing any other class.
Why an interface for product data?
Any class that depends on ProductDataService only knows about the interface, not the implementation. When you later build a real scraper (or buy access to a product API), you swap one class and nothing else changes.
This is the Dependency Inversion principle in action. It also makes unit testing trivial because you can mock the interface.
- Create a new file called ProductDataService.java inside src/main/java/com/decisioncopilot/service/.
- Add the following interface definition:
package com.decisioncopilot.service;
import com.decisioncopilot.dto.ProductData;
// Interface for fetching product information
// Designed for easy swapping between simulated and real data sources
public interface ProductDataService {
ProductData fetchProductData(String input, String category);
}
What does this code do?
This interface defines a single method: fetchProductData. It takes the user's input text and an optional category, then returns a ProductData record (the DTO you created in the previous step).
- Save ProductDataService.java.
- Now create the simulated implementation. Create a new file called ProductDataServiceImpl.java in the same service package.
- Add the following implementation:
package com.decisioncopilot.service;
import com.decisioncopilot.dto.ProductData;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
// Simulated product data fetcher
// Replace this class with a real scraper or API client in production
@Service
public class ProductDataServiceImpl implements ProductDataService {
@Override
public ProductData fetchProductData(String input, String category) {
// Generate deterministic simulated data based on input
// This keeps results consistent for the same product name
int hash = Math.abs(input.hashCode());
return new ProductData(
input,
generatePrice(hash),
generateRating(hash),
generateReviewCount(hash),
category != null ? category : "General"
);
}
private BigDecimal generatePrice(int hash) {
// Simulated price between $19.99 and $999.99
double price = 19.99 + (hash % 980);
return BigDecimal.valueOf(price).setScale(2, RoundingMode.HALF_UP);
}
private BigDecimal generateRating(int hash) {
// Simulated rating between 2.5 and 5.0
double rating = 2.5 + (hash % 25) / 10.0;
return BigDecimal.valueOf(rating).setScale(2, RoundingMode.HALF_UP);
}
private int generateReviewCount(int hash) {
// Simulated review count between 10 and 5000
return 10 + (hash % 4990);
}
}
What does this code do?
The @Service annotation registers this class as a Spring bean. Because it implements ProductDataService, Spring will inject it wherever that interface is needed.
The simulated data uses the input string's hash code to generate deterministic values. Querying the same product name always returns the same price, rating, and review count. This makes testing predictable.
- Save ProductDataServiceImpl.java.
- Confirm your IDE shows no compilation errors in either file.
Seeing a 'cannot find symbol ProductData' error?
Make sure the ProductData record exists in com.decisioncopilot.dto and that the import statement matches the package path exactly.
help me fix my ProductData import error
Add the Spring AI dependency and get your Gemini API key
Spring AI provides a unified interface for calling LLM providers. You will use its OpenAI-compatible starter, pointed at Google Gemini's endpoint. This works because Gemini exposes an OpenAI-compatible API.
Why Gemini instead of OpenAI or Anthropic?
Gemini has the only permanent free tier among frontier LLM providers. You get 1,500 requests per day on Gemini 2.5 Flash with no credit card required. OpenAI requires a $5 deposit for any real usage. Anthropic has no free tier at all.
For a portfolio project you will demo repeatedly, free matters.
- Get your free Gemini API key by visiting Google AI Studio.
- Sign in with your Google account.
- Click Create API Key.
- Copy the generated key and store it somewhere safe. You will need it shortly.
Keep your API key private
Never commit your API key to Git. You will pass it as an environment variable, which keeps it out of your source code entirely.
Now add the Spring AI dependency to your project. Spring AI uses a BOM (Bill of Materials) to manage version compatibility across its modules.
- Open your pom.xml file.
- Add the Spring AI BOM inside your <dependencyManagement> section (create it if it does not exist):
<dependencyManagement>
<dependencies>
<!-- Spring AI version management -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.6</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
- Add the Spring AI OpenAI starter inside your <dependencies> section:
<!-- Spring AI - OpenAI-compatible client (used with Gemini endpoint) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
What does this code do?
The BOM locks all Spring AI modules to version 1.0.6 so you do not need to specify versions on individual dependencies.
The spring-ai-starter-model-openai starter provides the ChatClient class. Although named "openai", it works with any OpenAI-compatible endpoint. Gemini exposes one at its /v1beta/openai path.
- Save pom.xml.
- Reload your Maven project by running this command in your terminal:
./mvnw dependency:resolve
You should see Maven downloading the Spring AI artifacts. Look for lines containing spring-ai in the output.
Your application.yml already contains the Spring AI configuration from the previous step. Take a moment to verify it is correct.
- Open src/main/resources/application.yml.
- Confirm the spring.ai section looks like this:
ai:
openai:
api-key: ${GEMINI_API_KEY}
base-url: https://generativelanguage.googleapis.com/v1beta/openai
chat:
options:
model: gemini-2.5-flash
temperature: 0.7
What is happening here?
The base-url points Spring AI's OpenAI client at Gemini's OpenAI-compatible endpoint instead of OpenAI's servers. Spring AI does not know the difference.
The ${GEMINI_API_KEY} syntax pulls the value from an environment variable at runtime. Your actual key never appears in the source code.
- Set your Gemini API key as an environment variable by running this in your terminal:
export GEMINI_API_KEY=your-api-key-here
- Replace your-api-key-here with the key you copied from Google AI Studio.
Persist the variable across terminal sessions
Add the export line to your ~/.zshrc (macOS) or ~/.bashrc (Linux) so the key is available every time you open a new terminal.
Maven can't resolve Spring AI artifacts?
Make sure the BOM version is exactly 1.0.6 and that it is inside <dependencyManagement>, not <dependencies>. The BOM controls versions for other Spring AI modules.
help me fix my Spring AI Maven dependency issue
Build the LlmDecisionService with circuit breaker and retry
LLM APIs are inherently unreliable. They time out, rate limit you, and occasionally return garbage. A production service needs to handle all of these gracefully.
The LlmDecisionService wraps the LLM call with two Resilience4j patterns: a circuit breaker that stops calling a broken service, and retry with exponential backoff for transient failures.
What is a circuit breaker?
A circuit breaker monitors recent call failures. When the failure rate crosses a threshold (50% in your config), it "opens" and immediately returns a fallback response instead of making more doomed requests. After a cooldown period (30 seconds), it lets a few test requests through to see if the service recovered.
This protects both your application (no thread pool exhaustion waiting on timeouts) and the remote service (no flood of retry traffic).
- Create a new file called LlmDecisionService.java inside src/main/java/com/decisioncopilot/service/.
- Add the full service class:
package com.decisioncopilot.service;
import com.decisioncopilot.dto.ProductData;
import com.decisioncopilot.dto.LlmDecisionResult;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.retry.annotation.Retry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
@Service
public class LlmDecisionService {
private static final Logger log = LoggerFactory.getLogger(LlmDecisionService.class);
private final ChatClient chatClient;
private final ObjectMapper objectMapper;
public LlmDecisionService(ChatClient.Builder chatClientBuilder, ObjectMapper objectMapper) {
this.chatClient = chatClientBuilder.build();
this.objectMapper = objectMapper;
}
@CircuitBreaker(name = "llmService", fallbackMethod = "fallbackDecision")
@Retry(name = "llmService")
public LlmDecisionResult generateDecision(ProductData product) {
log.info("Generating decision for product: {}", product.name());
String prompt = buildPrompt(product);
String response = chatClient.prompt()
.user(prompt)
.call()
.content();
return parseResponse(response);
}
private String buildPrompt(ProductData product) {
return """
You are a product purchase advisor. Analyze the following product and provide a buying recommendation.
Product: %s
Price: $%s
Rating: %s/5.0 (%d reviews)
Category: %s
Respond in the following JSON format only, no markdown:
{
"verdict": "BUY" or "DONT_BUY" or "MAYBE",
"confidenceScore": 0.0 to 1.0,
"pros": ["pro1", "pro2", "pro3"],
"cons": ["con1", "con2", "con3"],
"summary": "One paragraph summary of your recommendation",
"reasoning": "Detailed reasoning for your verdict"
}
""".formatted(
product.name(),
product.price(),
product.rating(),
product.reviewCount(),
product.category()
);
}
private LlmDecisionResult parseResponse(String response) {
try {
return objectMapper.readValue(response, LlmDecisionResult.class);
} catch (Exception e) {
log.error("Failed to parse LLM response: {}", response, e);
throw new RuntimeException("Failed to parse LLM response", e);
}
}
private LlmDecisionResult fallbackDecision(ProductData product, Throwable t) {
log.warn("Circuit breaker triggered for product: {}. Error: {}", product.name(), t.getMessage());
return new LlmDecisionResult(
"MAYBE",
0.5,
java.util.List.of("Unable to fully analyze at this time"),
java.util.List.of("AI service temporarily unavailable"),
"The AI decision service is temporarily unavailable. Please try again later.",
"Fallback response due to service disruption: " + t.getMessage()
);
}
}
What does this code do?
- The constructor injects a ChatClient.Builder (auto-configured by Spring AI) and builds the client. It also injects Jackson's ObjectMapper for JSON parsing.
- The generateDecision method is annotated with both @CircuitBreaker and @Retry. On failure, Resilience4j retries up to 3 times with exponential backoff. If the circuit breaker opens, it calls fallbackDecision immediately.
- The buildPrompt method constructs a text block telling Gemini to respond with structured JSON containing a verdict, confidence score, pros, and cons.
- The parseResponse method deserializes Gemini's JSON string into your LlmDecisionResult record.
- The fallbackDecision method returns a safe "MAYBE" response when the LLM is unreachable, so your API never crashes.
- Save LlmDecisionService.java.
- Verify your IDE shows no compilation errors. All imports should resolve against the Spring AI and Resilience4j libraries you added.
Cannot resolve ChatClient or Resilience4j annotations?
If ChatClient does not resolve, your Spring AI dependency was not pulled in correctly. Re-run ./mvnw dependency:resolve and check for errors.
If the Resilience4j annotations do not resolve, confirm that resilience4j-spring-boot3 is in your pom.xml dependencies (it should be from your initial project setup).
help me resolve missing imports in LlmDecisionService
- Verify the application compiles cleanly by running:
./mvnw compile
You should see BUILD SUCCESS in the terminal output. This confirms all three service files compile against your existing entities, DTOs, and dependencies.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three service files before moving on.
ⓧ I'd like to double check the full code
Here is the complete LlmDecisionService for comparison:
package com.decisioncopilot.service;
import com.decisioncopilot.dto.ProductData;
import com.decisioncopilot.dto.LlmDecisionResult;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.github.resilience4j.retry.annotation.Retry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
@Service
public class LlmDecisionService {
private static final Logger log = LoggerFactory.getLogger(LlmDecisionService.class);
private final ChatClient chatClient;
private final ObjectMapper objectMapper;
public LlmDecisionService(ChatClient.Builder chatClientBuilder, ObjectMapper objectMapper) {
this.chatClient = chatClientBuilder.build();
this.objectMapper = objectMapper;
}
@CircuitBreaker(name = "llmService", fallbackMethod = "fallbackDecision")
@Retry(name = "llmService")
public LlmDecisionResult generateDecision(ProductData product) {
log.info("Generating decision for product: {}", product.name());
String prompt = buildPrompt(product);
String response = chatClient.prompt()
.user(prompt)
.call()
.content();
return parseResponse(response);
}
private String buildPrompt(ProductData product) {
return """
You are a product purchase advisor. Analyze the following product and provide a buying recommendation.
Product: %s
Price: $%s
Rating: %s/5.0 (%d reviews)
Category: %s
Respond in the following JSON format only, no markdown:
{
"verdict": "BUY" or "DONT_BUY" or "MAYBE",
"confidenceScore": 0.0 to 1.0,
"pros": ["pro1", "pro2", "pro3"],
"cons": ["con1", "con2", "con3"],
"summary": "One paragraph summary of your recommendation",
"reasoning": "Detailed reasoning for your verdict"
}
""".formatted(
product.name(),
product.price(),
product.rating(),
product.reviewCount(),
product.category()
);
}
private LlmDecisionResult parseResponse(String response) {
try {
return objectMapper.readValue(response, LlmDecisionResult.class);
} catch (Exception e) {
log.error("Failed to parse LLM response: {}", response, e);
throw new RuntimeException("Failed to parse LLM response", e);
}
}
private LlmDecisionResult fallbackDecision(ProductData product, Throwable t) {
log.warn("Circuit breaker triggered for product: {}. Error: {}", product.name(), t.getMessage());
return new LlmDecisionResult(
"MAYBE",
0.5,
java.util.List.of("Unable to fully analyze at this time"),
java.util.List.of("AI service temporarily unavailable"),
"The AI decision service is temporarily unavailable. Please try again later.",
"Fallback response due to service disruption: " + t.getMessage()
);
}
}
Your data layer and AI integration are ready. Next up, you will wire them together with async processing and Redis caching so your API can handle concurrent requests without blocking.
Implement Async Processing and Redis Caching
Your LLM integration is wired up and ready to generate decisions. The problem is that LLM calls are slow. A single Gemini request can take 3-10 seconds, and you cannot block your API thread for that long.
In this step, you will build an async processing pipeline that accepts requests immediately and processes them in the background using dedicated thread pools. You will also add a Redis caching layer so repeated queries for the same product return instantly from cache instead of calling the LLM again.
In this step, get ready to:
- Configure custom thread pools for async LLM processing.
- Build the DecisionOrchestrator service that coordinates the full async decision flow.
- Wire up Redis caching with a 30-minute TTL on decision results.
Create the async configuration with custom thread pools
Spring's @Async annotation lets you run methods on a background thread. By default, Spring uses a single shared thread pool, which is dangerous in production because one slow service can starve all other async tasks.
You will create two named thread pools: one dedicated to LLM calls (which are slow and resource-intensive) and one for general background work. This isolation means LLM calls cannot block other async operations.
- Create a new file at src/main/java/com/decisioncopilot/config/AsyncConfig.java.
- Add the following code:
package com.decisioncopilot.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncConfig {
// Dedicated thread pool for LLM calls (slow, resource-intensive)
@Bean(name = "llmTaskExecutor")
public Executor llmTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("llm-async-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
// General-purpose thread pool for lighter background tasks
@Bean(name = "generalTaskExecutor")
public Executor generalTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("general-async-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(15);
executor.initialize();
return executor;
}
}
What does this code do?
@EnableAsync activates Spring's async processing support across your application. Without this, @Async annotations on methods would be silently ignored.
The llmTaskExecutor pool has 4 core threads that are always alive, and can scale up to 8 under load. The queue holds up to 50 pending tasks before rejecting new ones. The thread name prefix (llm-async-) makes it easy to identify these threads in logs and stack traces.
The generalTaskExecutor is smaller (2-4 threads) because general tasks are typically lighter and faster than LLM calls.
Why dedicated thread pools instead of a single shared one?
LLM calls are slow (3-10 seconds each). If they share a thread pool with quick database lookups or notification sends, a burst of LLM requests could consume all available threads. Every other async task in your system would then queue behind them.
Isolating slow work into its own pool is the same pattern companies like Stripe use at similar scale. It gives you a natural backpressure mechanism and makes monitoring straightforward.
- Save AsyncConfig.java.
You will not see a visible change yet. The thread pools are registered as Spring beans and will be used by the orchestrator you build next.
Build the DecisionOrchestrator service
The orchestrator is the central coordinator of your decision pipeline. It receives a product query, hands it off to a background thread, and manages the full lifecycle: fetch product data, call the LLM, persist the result, and update the query status.
- Create a new file at src/main/java/com/decisioncopilot/service/DecisionOrchestrator.java.
- Add the imports and class declaration:
package com.decisioncopilot.service;
import com.decisioncopilot.dto.LlmDecisionResult;
import com.decisioncopilot.dto.ProductData;
import com.decisioncopilot.model.DecisionQuery;
import com.decisioncopilot.model.DecisionResult;
import com.decisioncopilot.model.DecisionStatus;
import com.decisioncopilot.model.Product;
import com.decisioncopilot.repository.DecisionQueryRepository;
import com.decisioncopilot.repository.DecisionResultRepository;
import com.decisioncopilot.repository.ProductRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@Service
public class DecisionOrchestrator {
private static final Logger log = LoggerFactory.getLogger(DecisionOrchestrator.class);
private final ProductDataService productDataService;
private final LlmDecisionService llmDecisionService;
private final DecisionQueryRepository queryRepository;
private final DecisionResultRepository resultRepository;
private final ProductRepository productRepository;
public DecisionOrchestrator(
ProductDataService productDataService,
LlmDecisionService llmDecisionService,
DecisionQueryRepository queryRepository,
DecisionResultRepository resultRepository,
ProductRepository productRepository) {
this.productDataService = productDataService;
this.llmDecisionService = llmDecisionService;
this.queryRepository = queryRepository;
this.resultRepository = resultRepository;
this.productRepository = productRepository;
}
}
What does this code do?
The constructor injects five dependencies: the product data fetcher, the LLM service, and three repositories (queries, results, products). Spring wires these automatically because each is a registered bean.
The SLF4J logger uses the thread name prefix you configured in AsyncConfig, so you can trace which thread pool handled each log entry.
Now add the public methods that drive the decision flow. These go inside the class body, below the constructor.
- Add the submitDecision and processDecisionAsync methods below the constructor:
// Entry point: creates a PENDING query and kicks off background processing
@Transactional
public UUID submitDecision(String input, String category) {
DecisionQuery query = new DecisionQuery();
query.setInputText(input);
query.setStatus(DecisionStatus.PENDING);
query = queryRepository.save(query);
processDecisionAsync(query.getId(), input, category);
return query.getId();
}
// Runs on the llmTaskExecutor thread pool, not the request thread
@Async("llmTaskExecutor")
public CompletableFuture<Void> processDecisionAsync(UUID queryId, String input, String category) {
long startTime = System.currentTimeMillis();
log.info("Processing decision async for queryId: {}", queryId);
try {
updateStatus(queryId, DecisionStatus.PROCESSING);
ProductData productData = productDataService.fetchProductData(input, category);
Product product = saveProduct(productData);
linkProductToQuery(queryId, product.getId());
LlmDecisionResult llmResult = llmDecisionService.generateDecision(productData);
long processingTime = System.currentTimeMillis() - startTime;
saveDecisionResult(queryId, product.getId(), llmResult, processingTime);
updateStatus(queryId, DecisionStatus.COMPLETED);
log.info("Decision completed for queryId: {} in {}ms", queryId, processingTime);
} catch (Exception e) {
log.error("Decision processing failed for queryId: {}", queryId, e);
updateStatusWithError(queryId, DecisionStatus.FAILED, e.getMessage());
}
return CompletableFuture.completedFuture(null);
}
// Cached lookup: Redis stores the result for 30 minutes
@Cacheable(value = "decisions", key = "#queryId")
public DecisionResult getDecisionResult(UUID queryId) {
return resultRepository.findByQueryId(queryId).orElse(null);
}
What does this code do?
- submitDecision is synchronous and fast. It saves a PENDING query to the database and immediately returns the query ID to the caller. The actual work happens on a background thread.
- @Async("llmTaskExecutor") tells Spring to run processDecisionAsync on the LLM thread pool you just configured. The caller does not wait for it to complete.
- The try/catch ensures failures update the query status to FAILED with the error message, so clients polling for status always get a definitive answer.
- @Cacheable(value = "decisions") tells Spring to check Redis before hitting the database. If the result is cached, it returns instantly without a database query.
Finally, add the private helper methods that handle persistence operations.
- Add the following private methods below getDecisionResult:
private void updateStatus(UUID queryId, DecisionStatus status) {
queryRepository.updateStatus(queryId, status);
}
private void updateStatusWithError(UUID queryId, DecisionStatus status, String error) {
queryRepository.updateStatusWithError(queryId, status, error);
}
private Product saveProduct(ProductData data) {
Product product = new Product();
product.setName(data.name());
product.setPrice(data.price());
product.setRating(data.rating());
product.setReviewCount(data.reviewCount());
product.setCategory(data.category());
return productRepository.save(product);
}
private void linkProductToQuery(UUID queryId, UUID productId) {
queryRepository.linkProduct(queryId, productId);
}
private void saveDecisionResult(UUID queryId, UUID productId, LlmDecisionResult llmResult, long processingTime) {
DecisionResult result = new DecisionResult();
result.setQueryId(queryId);
result.setProductId(productId);
result.setVerdict(llmResult.verdict());
result.setConfidenceScore(llmResult.confidenceScore());
result.setPros(llmResult.pros());
result.setCons(llmResult.cons());
result.setSummary(llmResult.summary());
result.setReasoning(llmResult.reasoning());
result.setLlmModel("gemini-2.5-flash");
result.setProcessingTimeMs(processingTime);
resultRepository.save(result);
}
What do these helpers do?
Each helper has a single responsibility. saveProduct maps the DTO to a JPA entity and persists it. linkProductToQuery associates the product with the original query in the database. saveDecisionResult maps the LLM response into your domain model and records the processing time for observability.
- Save DecisionOrchestrator.java.
- Confirm there are no compilation errors in your IDE. Red underlines under repository method names (like updateStatus or linkProduct) are expected if you have not yet added those custom query methods to your repository interfaces.
Seeing 'Cannot resolve method' errors?
If your IDE highlights queryRepository.updateStatus or queryRepository.linkProduct as unresolved, add the corresponding custom query methods to your DecisionQueryRepository interface. These are @Modifying @Query methods that run UPDATE statements.
help me add custom query methods to my repository
Verify Redis caching configuration
Your application.yml already configures Redis as the cache provider with a 30-minute TTL. The @Cacheable annotation you added to getDecisionResult automatically stores results in Redis after the first database lookup.
- Open application.yml and confirm you have these lines under the spring: section:
cache:
type: redis
redis:
time-to-live: 30m
How does Redis caching work here?
When a client polls for a decision result, Spring checks Redis first using the query ID as the cache key. If the result is cached, the database is never hit. If not, Spring queries PostgreSQL, stores the result in Redis with a 30-minute expiration, and returns it.
The 30-minute TTL means frequently polled decisions are served from memory. After 30 minutes, the cache entry expires and the next request refreshes it from the database. This balances freshness with performance.
- Verify your Redis container is still running by executing this command in your terminal:
docker exec decision-copilot-redis redis-cli ping
You should see PONG in the output. This confirms Redis is alive and ready to accept cache entries from your application.
Not seeing PONG?
If you get a "No such container" error, your Docker containers may have stopped. Run docker compose up -d from your project root to restart them.
If you see "Could not connect to Redis", check that port 6379 is not already in use by another process on your machine.
help me debug my Redis container connection
✔️ Awesome, I've got everything!
Great. Double check you have saved both AsyncConfig.java and DecisionOrchestrator.java.
ⓧ I'd like to double check the full code
Here is the complete AsyncConfig.java:
package com.decisioncopilot.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "llmTaskExecutor")
public Executor llmTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("llm-async-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
@Bean(name = "generalTaskExecutor")
public Executor generalTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("general-async-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(15);
executor.initialize();
return executor;
}
}
Here is the complete DecisionOrchestrator.java:
package com.decisioncopilot.service;
import com.decisioncopilot.dto.LlmDecisionResult;
import com.decisioncopilot.dto.ProductData;
import com.decisioncopilot.model.DecisionQuery;
import com.decisioncopilot.model.DecisionResult;
import com.decisioncopilot.model.DecisionStatus;
import com.decisioncopilot.model.Product;
import com.decisioncopilot.repository.DecisionQueryRepository;
import com.decisioncopilot.repository.DecisionResultRepository;
import com.decisioncopilot.repository.ProductRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@Service
public class DecisionOrchestrator {
private static final Logger log = LoggerFactory.getLogger(DecisionOrchestrator.class);
private final ProductDataService productDataService;
private final LlmDecisionService llmDecisionService;
private final DecisionQueryRepository queryRepository;
private final DecisionResultRepository resultRepository;
private final ProductRepository productRepository;
public DecisionOrchestrator(
ProductDataService productDataService,
LlmDecisionService llmDecisionService,
DecisionQueryRepository queryRepository,
DecisionResultRepository resultRepository,
ProductRepository productRepository) {
this.productDataService = productDataService;
this.llmDecisionService = llmDecisionService;
this.queryRepository = queryRepository;
this.resultRepository = resultRepository;
this.productRepository = productRepository;
}
@Transactional
public UUID submitDecision(String input, String category) {
DecisionQuery query = new DecisionQuery();
query.setInputText(input);
query.setStatus(DecisionStatus.PENDING);
query = queryRepository.save(query);
processDecisionAsync(query.getId(), input, category);
return query.getId();
}
@Async("llmTaskExecutor")
public CompletableFuture<Void> processDecisionAsync(UUID queryId, String input, String category) {
long startTime = System.currentTimeMillis();
log.info("Processing decision async for queryId: {}", queryId);
try {
updateStatus(queryId, DecisionStatus.PROCESSING);
ProductData productData = productDataService.fetchProductData(input, category);
Product product = saveProduct(productData);
linkProductToQuery(queryId, product.getId());
LlmDecisionResult llmResult = llmDecisionService.generateDecision(productData);
long processingTime = System.currentTimeMillis() - startTime;
saveDecisionResult(queryId, product.getId(), llmResult, processingTime);
updateStatus(queryId, DecisionStatus.COMPLETED);
log.info("Decision completed for queryId: {} in {}ms", queryId, processingTime);
} catch (Exception e) {
log.error("Decision processing failed for queryId: {}", queryId, e);
updateStatusWithError(queryId, DecisionStatus.FAILED, e.getMessage());
}
return CompletableFuture.completedFuture(null);
}
@Cacheable(value = "decisions", key = "#queryId")
public DecisionResult getDecisionResult(UUID queryId) {
return resultRepository.findByQueryId(queryId).orElse(null);
}
private void updateStatus(UUID queryId, DecisionStatus status) {
queryRepository.updateStatus(queryId, status);
}
private void updateStatusWithError(UUID queryId, DecisionStatus status, String error) {
queryRepository.updateStatusWithError(queryId, status, error);
}
private Product saveProduct(ProductData data) {
Product product = new Product();
product.setName(data.name());
product.setPrice(data.price());
product.setRating(data.rating());
product.setReviewCount(data.reviewCount());
product.setCategory(data.category());
return productRepository.save(product);
}
private void linkProductToQuery(UUID queryId, UUID productId) {
queryRepository.linkProduct(queryId, productId);
}
private void saveDecisionResult(UUID queryId, UUID productId, LlmDecisionResult llmResult, long processingTime) {
DecisionResult result = new DecisionResult();
result.setQueryId(queryId);
result.setProductId(productId);
result.setVerdict(llmResult.verdict());
result.setConfidenceScore(llmResult.confidenceScore());
result.setPros(llmResult.pros());
result.setCons(llmResult.cons());
result.setSummary(llmResult.summary());
result.setReasoning(llmResult.reasoning());
result.setLlmModel("gemini-2.5-flash");
result.setProcessingTimeMs(processingTime);
resultRepository.save(result);
}
}
Your async processing pipeline and caching layer are in place. Next up, you will build the REST controller that exposes this orchestrator to the outside world via HTTP endpoints.
Build the REST Controller and Global Error Handling
You have all the backend machinery in place: async orchestration, LLM integration, caching, and database persistence. None of it is accessible to the outside world yet.
In this step, you will expose everything through a REST API with three endpoints. You will also build a global error handler so that every failure returns a consistent, developer-friendly JSON response instead of a raw stack trace.
In this step, get ready to:
- Build a REST controller with submit, poll, and history endpoints.
- Create a global exception handler with proper HTTP status codes.
- Test the full async flow end-to-end via Swagger UI.
Build the DecisionController with three endpoints
The controller needs a helper service to map database entities into response DTOs. You will create a DecisionQueryService first, then wire it into the controller alongside the orchestrator you already built.
- Create a new file DecisionQueryService.java inside the src/main/java/com/decisioncopilot/service package.
- Paste the following code into the file:
package com.decisioncopilot.service;
import com.decisioncopilot.dto.DecisionResponse;
import com.decisioncopilot.model.DecisionQuery;
import com.decisioncopilot.model.DecisionResult;
import com.decisioncopilot.repository.DecisionQueryRepository;
import com.decisioncopilot.repository.DecisionResultRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import java.util.NoSuchElementException;
import java.util.UUID;
@Service
public class DecisionQueryService {
private final DecisionQueryRepository queryRepository;
private final DecisionResultRepository resultRepository;
public DecisionQueryService(DecisionQueryRepository queryRepository,
DecisionResultRepository resultRepository) {
this.queryRepository = queryRepository;
this.resultRepository = resultRepository;
}
// Fetch a single decision by query ID and map to response DTO
public DecisionResponse getDecisionById(UUID id) {
DecisionQuery query = queryRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException("Decision not found: " + id));
DecisionResult result = resultRepository.findByQueryId(id).orElse(null);
return mapToResponse(query, result);
}
// Fetch paginated decision history
public Page<DecisionResponse> getDecisionHistory(Pageable pageable) {
return queryRepository.findAll(pageable)
.map(query -> {
DecisionResult result = resultRepository.findByQueryId(query.getId()).orElse(null);
return mapToResponse(query, result);
});
}
// Map entity pair to the response DTO
private DecisionResponse mapToResponse(DecisionQuery query, DecisionResult result) {
if (result == null) {
return new DecisionResponse(
query.getId(),
query.getStatus().name(),
null, null, null, null, null,
null, null, null, null, null,
query.getCreatedAt()
);
}
return new DecisionResponse(
query.getId(),
query.getStatus().name(),
result.getProductName(),
result.getPrice(),
result.getRating(),
result.getVerdict(),
result.getConfidenceScore(),
result.getPros(),
result.getCons(),
result.getSummary(),
result.getReasoning(),
result.getProcessingTimeMs(),
query.getCreatedAt()
);
}
}
What does this code do?
This service sits between the controller and the repositories. It handles the query-to-DTO mapping so the controller stays thin.
- `getDecisionById` fetches both the query and its result (if completed), then combines them into a single response object.
- `getDecisionHistory` returns a paginated list of all decisions, mapping each to a response DTO.
- If a result does not exist yet (the job is still processing), the response includes the status but leaves decision fields null.
- Save DecisionQueryService.java.
You won't see a change yet. The controller you build next uses this service.
- Create a new file DecisionController.java inside the src/main/java/com/decisioncopilot/controller package.
- Paste the following code into the file:
package com.decisioncopilot.controller;
import com.decisioncopilot.dto.DecisionRequest;
import com.decisioncopilot.dto.DecisionResponse;
import com.decisioncopilot.service.DecisionOrchestrator;
import com.decisioncopilot.service.DecisionQueryService;
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/decisions")
public class DecisionController {
private final DecisionOrchestrator orchestrator;
private final DecisionQueryService queryService;
public DecisionController(DecisionOrchestrator orchestrator, DecisionQueryService queryService) {
this.orchestrator = orchestrator;
this.queryService = queryService;
}
// Submit a new product decision request
@PostMapping
@RateLimiter(name = "decisionApi")
public ResponseEntity<Map<String, Object>> submitDecision(@Valid @RequestBody DecisionRequest request) {
UUID queryId = orchestrator.submitDecision(request.input(), request.category());
return ResponseEntity
.accepted()
.location(URI.create("/api/decisions/" + queryId))
.body(Map.of(
"id", queryId,
"status", "PENDING",
"message", "Decision is being processed. Poll the status endpoint.",
"pollUrl", "/api/decisions/" + queryId
));
}
// Poll for decision status and result
@GetMapping("/{id}")
public ResponseEntity<DecisionResponse> getDecision(@PathVariable UUID id) {
DecisionResponse response = queryService.getDecisionById(id);
return ResponseEntity.ok(response);
}
// Browse paginated decision history
@GetMapping
public ResponseEntity<Page<DecisionResponse>> getDecisionHistory(
@PageableDefault(size = 20, sort = "createdAt") Pageable pageable) {
Page<DecisionResponse> history = queryService.getDecisionHistory(pageable);
return ResponseEntity.ok(history);
}
}
What does this code do?
This controller exposes three endpoints that form the async job pattern: submit, poll, and list.
- `POST /api/decisions` accepts a product query, kicks off async processing, and returns a 202 Accepted with a poll URL. The `@RateLimiter` annotation ties into the Resilience4j config you set up earlier.
- `GET /api/decisions/{id}` lets the client poll for the result by ID.
- `GET /api/decisions` returns paginated history with a default page size of 20, sorted by creation date.
Why 202 Accepted instead of 200 OK?
HTTP 202 signals that the server accepted the request but has not finished processing it. This is the standard response for async job APIs. The client knows to poll the Location header URL for the final result.
- Save DecisionController.java.
Seeing import errors in your IDE?
Make sure your pom.xml includes the spring-boot-starter-web and spring-boot-starter-data-jpa dependencies. The PageableDefault annotation comes from spring-data-web which is bundled with the JPA starter.
help me resolve import errors in DecisionController
Create the global exception handler
Without a global exception handler, Spring Boot returns raw error pages or inconsistent JSON when something goes wrong. A @ControllerAdvice class intercepts exceptions thrown by any controller and maps them to clean, predictable error responses.
- Create a new file ErrorResponse.java inside the src/main/java/com/decisioncopilot/dto package.
- Paste this record that defines the error response shape:
package com.decisioncopilot.dto;
import java.time.LocalDateTime;
import java.util.List;
// Consistent error response shape for all API errors
public record ErrorResponse(
int status,
String error,
String message,
List<String> details,
LocalDateTime timestamp
) {
public ErrorResponse(int status, String error, String message) {
this(status, error, message, List.of(), LocalDateTime.now());
}
}
What does this code do?
This record gives every error response the same fields: HTTP status code, error name, human-readable message, optional details list, and a timestamp. The compact constructor provides a convenience overload for simple errors without extra details.
- Save ErrorResponse.java.
- Create a new file GlobalExceptionHandler.java inside the src/main/java/com/decisioncopilot/exception package.
- Paste the following code:
package com.decisioncopilot.exception;
import com.decisioncopilot.dto.ErrorResponse;
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
import jakarta.persistence.EntityNotFoundException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.util.List;
import java.util.NoSuchElementException;
@ControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
// Handle Bean Validation failures (400 Bad Request)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationErrors(MethodArgumentNotValidException ex) {
List<String> details = ex.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.toList();
ErrorResponse response = new ErrorResponse(
HttpStatus.BAD_REQUEST.value(),
"Validation Failed",
"Request contains invalid fields",
details,
java.time.LocalDateTime.now()
);
return ResponseEntity.badRequest().body(response);
}
// Handle not-found exceptions (404 Not Found)
@ExceptionHandler({EntityNotFoundException.class, NoSuchElementException.class})
public ResponseEntity<ErrorResponse> handleNotFound(Exception ex) {
ErrorResponse response = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
"Not Found",
ex.getMessage()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
// Handle rate limiter rejection (429 Too Many Requests)
@ExceptionHandler(RequestNotPermitted.class)
public ResponseEntity<ErrorResponse> handleRateLimitExceeded(RequestNotPermitted ex) {
ErrorResponse response = new ErrorResponse(
HttpStatus.TOO_MANY_REQUESTS.value(),
"Rate Limit Exceeded",
"Too many requests. Please try again later."
);
HttpHeaders headers = new HttpHeaders();
headers.add("Retry-After", "1");
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.headers(headers)
.body(response);
}
// Catch-all for unexpected errors (500 Internal Server Error)
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
log.error("Unexpected error occurred", ex);
ErrorResponse response = new ErrorResponse(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"Internal Server Error",
"An unexpected error occurred. Please try again later."
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
What does this code do?
Each `@ExceptionHandler` method catches a specific exception type and returns a structured JSON response with the correct HTTP status code.
- Validation errors (400) include per-field details so the client knows exactly what to fix.
- Not-found errors (404) catch both JPA and standard Java exceptions for flexibility.
- Rate limit errors (429) include a `Retry-After` header telling the client how many seconds to wait.
- The catch-all (500) logs the full stack trace for debugging but only returns a generic message to the client. This prevents leaking internal details.
- Save GlobalExceptionHandler.java.
Can't resolve RequestNotPermitted import?
This class is from the resilience4j-ratelimiter module. Confirm that your pom.xml includes the resilience4j-spring-boot3 starter dependency. If you added it recently, reload your Maven project in your IDE.
help me fix missing Resilience4j imports
Test the full flow end-to-end via Swagger UI
Everything is wired together. Time to start the application and test the entire async flow through the browser-based Swagger UI that springdoc generates automatically from your controller.
- Make sure your Docker containers are running. If they are not, start them by running:
docker compose up -d
- Start the Spring Boot application by running:
GEMINI_API_KEY=your-api-key-here ./mvnw spring-boot:run
What should I see?
The application logs should show Flyway running migrations, Hibernate validating the schema, and the embedded Tomcat starting on port 8080. Look for the line Started DecisionCopilotApplication to confirm the app is ready.
- Open your browser and navigate to http://localhost:8080/swagger-ui.html.
- Expand the POST /api/decisions endpoint.
- Click Try it out.
- Replace the request body with this JSON:
{
"input": "Sony WH-1000XM5 headphones",
"category": "Electronics"
}
- Click Execute.
You should see a 202 Accepted response with a JSON body containing an id, status of PENDING, and a pollUrl.
- Copy the UUID from the id field in the response.
- Expand the GET /api/decisions/{id} endpoint.
- Click Try it out and paste the UUID into the id field.
- Click Execute.
If a few seconds have passed, the response should show status as COMPLETED with a full verdict, pros, cons, and confidence score from Gemini.
- Test caching by executing the same POST request again with the same product input.
- Expand the GET /api/decisions endpoint and click Execute to browse paginated decision history.
You should see both queries listed in the history response.
How do I know caching is working?
Check your application logs. The first request shows LLM processing messages. Polling the same result a second time should be nearly instant because it hits the Redis cache. You will not see a second LLM call in the logs.
Getting a 500 error on POST?
- Check that your GEMINI_API_KEY environment variable is set correctly. The app will fail to call the LLM without a valid key.
- Make sure both Docker containers are healthy. Run docker compose ps and confirm both show healthy status.
- Look at the full stack trace in your terminal. The error message tells you whether the problem is database connectivity, LLM timeout, or a serialization issue.
help me debug a 500 error on my POST endpoint
Swagger UI page is blank or shows 404?
- Confirm springdoc-openapi-starter-webmvc-ui is in your pom.xml. This is the dependency that serves the Swagger UI at runtime.
- Verify the URL is exactly http://localhost:8080/swagger-ui.html. The path is configured in your application.yml under springdoc.swagger-ui.path.
help me fix Swagger UI not loading
✔️ Awesome, I've got everything!
Great. Double check you have saved all three new files before moving on.
ⓧ I'd like to double check the full code
Here is the complete DecisionController.java for comparison:
package com.decisioncopilot.controller;
import com.decisioncopilot.dto.DecisionRequest;
import com.decisioncopilot.dto.DecisionResponse;
import com.decisioncopilot.service.DecisionOrchestrator;
import com.decisioncopilot.service.DecisionQueryService;
import io.github.resilience4j.ratelimiter.annotation.RateLimiter;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/decisions")
public class DecisionController {
private final DecisionOrchestrator orchestrator;
private final DecisionQueryService queryService;
public DecisionController(DecisionOrchestrator orchestrator, DecisionQueryService queryService) {
this.orchestrator = orchestrator;
this.queryService = queryService;
}
@PostMapping
@RateLimiter(name = "decisionApi")
public ResponseEntity<Map<String, Object>> submitDecision(@Valid @RequestBody DecisionRequest request) {
UUID queryId = orchestrator.submitDecision(request.input(), request.category());
return ResponseEntity
.accepted()
.location(URI.create("/api/decisions/" + queryId))
.body(Map.of(
"id", queryId,
"status", "PENDING",
"message", "Decision is being processed. Poll the status endpoint.",
"pollUrl", "/api/decisions/" + queryId
));
}
@GetMapping("/{id}")
public ResponseEntity<DecisionResponse> getDecision(@PathVariable UUID id) {
DecisionResponse response = queryService.getDecisionById(id);
return ResponseEntity.ok(response);
}
@GetMapping
public ResponseEntity<Page<DecisionResponse>> getDecisionHistory(
@PageableDefault(size = 20, sort = "createdAt") Pageable pageable) {
Page<DecisionResponse> history = queryService.getDecisionHistory(pageable);
return ResponseEntity.ok(history);
}
}
Your API is live and serving real AI-generated decisions. Next up, you will harden it with Resilience4j circuit breaker configuration, retry policies, and rate limiting enforcement to make it truly production-grade.
Add RAG with pgvector and Deploy to Railway
Your API handles async processing, caching, and fault tolerance. The next upgrade is making it smarter over time.
Right now, every decision starts from scratch. The LLM has no memory of past analyses. In production systems, past decisions provide valuable context for new ones. This is where RAG (Retrieval-Augmented Generation) comes in. You will add vector embeddings to your database so similar past decisions can be retrieved and injected into the LLM prompt. Then you will containerize the full application and deploy it live on Railway.
In this step, get ready to:
- Add pgvector infrastructure for embedding-based similarity search.
- Create a multi-stage Dockerfile optimized for production.
- Deploy the full application to Railway with PostgreSQL and Redis.
Set up pgvector for embedding-based similarity search
Vector similarity search lets you find past decisions that are conceptually related to a new query. Instead of keyword matching, embeddings capture semantic meaning. Two products in the same category with similar characteristics will have nearby vectors, even if their names are completely different.
What is RAG?
RAG stands for Retrieval-Augmented Generation. Before sending a prompt to the LLM, you retrieve relevant context from your database and include it in the prompt. This gives the LLM grounding in your specific data, improving accuracy and consistency.
In this project, RAG means retrieving the 3 most similar past decisions and adding them as context when generating a new decision. The LLM can reference these to provide more consistent recommendations.
- Add the Spring AI PgVector starter dependency to your pom.xml by pasting this inside the <dependencies> block:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
</dependency>
What does this dependency do?
The spring-ai-starter-vector-store-pgvector starter auto-configures a VectorStore bean backed by PostgreSQL's pgvector extension. It handles embedding storage, indexing, and similarity search out of the box. The version is managed by the Spring AI BOM you already have in your pom.xml.
- Create the Flyway migration file for vector storage by creating V2__add_vector_store.sql inside src/main/resources/db/migration/:
ALTER TABLE decision_results ADD COLUMN IF NOT EXISTS embedding vector(1536);
CREATE INDEX IF NOT EXISTS idx_decision_results_embedding_hnsw
ON decision_results USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
What does this migration do?
The first statement adds an embedding column with 1536 dimensions to your decision_results table. This matches the embedding size produced by Gemini's text embedding model.
The second statement creates an HNSW (Hierarchical Navigable Small World) index for fast approximate nearest-neighbor search. The vector_cosine_ops operator class means similarity is measured by cosine distance. The m = 16 and ef_construction = 64 parameters tune the index for a good balance of speed and recall.
- Save V2__add_vector_store.sql.
- Restart your Spring Boot application so Flyway picks up the new migration.
- Check your application logs for Successfully applied 2 migrations (or similar output confirming V2 ran).
Migration failed with a vector type error?
Make sure your Docker PostgreSQL container is running the pgvector/pgvector:pg16 image. The standard postgres:16 image does not include the vector extension. Check your docker-compose.yml to confirm the image name.
help me fix my pgvector Flyway migration error
With the embedding column and HNSW index in place, your database can now store and query vector embeddings. The RAG pipeline works by generating an embedding of each new decision's summary, storing it alongside the result, then retrieving the 3 most similar past embeddings when processing a new query. Those past decisions become additional context in the LLM prompt.
How does the RAG flow work in this system?
- When a new decision completes, the orchestrator generates an embedding of the decision summary and stores it in the embedding column.
- When a new query arrives, the system embeds the input text and performs a cosine similarity search against all stored embeddings.
- The top 3 most similar past decisions are retrieved and appended to the LLM prompt as context.
- The LLM uses this context to produce more consistent, grounded recommendations.
Create a multi-stage Dockerfile for production
A multi-stage Dockerfile separates the build environment from the runtime environment. The build stage compiles your code with the full JDK and Maven. The run stage contains only the lightweight JRE and the compiled JAR. This produces a significantly smaller image.
- Create a file called Dockerfile in your project root (the same level as pom.xml) with the following content:
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY pom.xml .
COPY .mvn .mvn
COPY mvnw .
RUN chmod +x mvnw
RUN ./mvnw dependency:go-offline -B
COPY src src
RUN ./mvnw package -DskipTests -B
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
What does this Dockerfile do?
The first stage (build) uses the full JDK to compile the project. It copies pom.xml and the Maven wrapper first, then runs dependency:go-offline to download all dependencies. This layer gets cached by Docker, so subsequent builds skip the download step unless your dependencies change.
The second stage uses only the JRE (no compiler, no Maven). It copies just the compiled JAR from the build stage. The result is a lean production image that is roughly 200-300MB instead of 800MB+.
- Save Dockerfile.
- Verify the Docker build works by running:
docker build -t decision-copilot .
- You should see Docker complete both stages and output Successfully tagged decision-copilot:latest.
Build fails at the Maven stage?
Make sure mvnw (the Maven wrapper script) exists in your project root. If it is missing, run mvn wrapper:wrapper locally to generate it. Also confirm the .mvn directory is not listed in your .gitignore.
help me fix my Docker build for the Spring Boot app
✔️ Awesome, I've got everything!
Great. Double check you have saved both Dockerfile and V2__add_vector_store.sql.
ⓧ I'd like to double check the full code
Here is the complete Dockerfile:
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY pom.xml .
COPY .mvn .mvn
COPY mvnw .
RUN chmod +x mvnw
RUN ./mvnw dependency:go-offline -B
COPY src src
RUN ./mvnw package -DskipTests -B
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Here is the complete V2 migration:
ALTER TABLE decision_results ADD COLUMN IF NOT EXISTS embedding vector(1536);
CREATE INDEX IF NOT EXISTS idx_decision_results_embedding_hnsw
ON decision_results USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
Deploy to Railway
Railway is a deployment platform that detects your Dockerfile, builds the image, and runs it in the cloud. It also offers managed PostgreSQL and Redis as plugins, so your full stack can run in one project.
- Push your project to a GitHub repository if you have not already done so.
- Open railway.app and log in with your GitHub account.
- Click New Project on the dashboard.
- Select Deploy from GitHub repo and choose your decision-copilot repository.
- Click Add Variables instead of deploying immediately.
Why add variables first?
Your application needs environment variables for the database connection, Redis, and the Gemini API key. If you deploy without them, the first deployment will fail because Spring Boot cannot connect to any services.
- Add a PostgreSQL database to the project by clicking New on the project canvas, then selecting Database, then PostgreSQL.
- Add a Redis instance the same way: click New, then Database, then Redis.
- Click on your application service tile and open the Variables tab.
- Add the following environment variables:
- GEMINI_API_KEY - your Gemini API key from Google AI Studio.
- SPRING_DATASOURCE_URL - the JDBC connection URL provided by Railway's PostgreSQL plugin (find it in the PostgreSQL service's Variables tab).
- SPRING_DATASOURCE_USERNAME - the database username from the PostgreSQL plugin variables.
- SPRING_DATASOURCE_PASSWORD - the database password from the PostgreSQL plugin variables.
- SPRING_DATA_REDIS_HOST - the Redis host from the Redis plugin variables.
- SPRING_DATA_REDIS_PORT - the Redis port from the Redis plugin variables.
Use Railway's variable references
Railway supports referencing variables from other services. For example, you can set SPRING_DATASOURCE_URL to ${{Postgres.DATABASE_URL}} and Railway resolves it automatically. Check the PostgreSQL service's Connect tab for the exact JDBC URL format.
- Click Deploy at the top of the canvas to trigger the first deployment.
- Watch the build logs. Railway detects your Dockerfile and uses it to build the image.
- Once the build completes, navigate to your service's Settings tab.
- Under Networking, click Generate Domain to get a public URL.
Railway auto-provides the PORT variable
Railway automatically injects a PORT environment variable. Your application.yml already sets server.port: 8080, which Railway maps to its internal routing. You do not need to configure PORT yourself.
- Verify your live API by visiting the generated domain with /swagger-ui.html appended.
- Submit a test decision via the Swagger UI on your live Railway URL.
- Poll the status endpoint until it returns COMPLETED to confirm the full pipeline works in production.
Deployment builds but the app fails to start?
Check the deploy logs for connection errors. The most common cause is missing or incorrectly formatted environment variables. Make sure SPRING_DATASOURCE_URL starts with jdbc:postgresql:// and not just postgresql://.
Also confirm that both the PostgreSQL and Redis services show a green Active status on the canvas before your app tries to connect.
help me troubleshoot my Railway Spring Boot deployment
Secret mission
Build a Webhook Notification System
Your API processes decisions asynchronously. But what happens after a decision completes? Right now, clients have to poll. In this Secret Mission, you'll build an async webhook notification system that pushes results to external services the moment a decision is ready.
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 uses Railway for cloud deployment, which has ongoing costs. Your local Docker setup is free to keep.
Cost warning
Your Railway deployment costs approximately $8-15/month on the Hobby plan ($5 subscription plus resource usage). PostgreSQL runs ~$3-5/month, Redis ~$2-3/month, and the Spring Boot container ~$3-5/month.
If you are not actively using the deployed API, consider pausing or deleting your Railway project to avoid charges.
Resources you used:
- Railway PostgreSQL database (cloud, ~$3-5/month).
- Railway Redis instance (cloud, ~$2-3/month).
- Railway Spring Boot app container (cloud, ~$3-5/month).
- Local Docker containers (PostgreSQL and Redis via Docker Compose).
- Local project files and Maven dependencies.
- Google Gemini API key (free tier, no cost).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep the API live for interviews and portfolio demos.
- Monitor your usage and costs via the Railway dashboard.
- Your local Docker containers will keep running until you stop them or restart your machine.
- Your Gemini API key stays active on the free tier with no charges.
Pause - I'll come back to this later
Shut down running processes to reduce costs while keeping your project intact. Railway scales to zero when idle, so your services stop consuming CPU and memory when not receiving requests.
- Stop your local Docker containers by running:
docker compose down
- Your local data persists in Docker volumes and will be available when you start the containers again.
- On Railway, your services will automatically scale to zero when idle. You still pay for database storage (~$0.15/GB/month) but not for CPU or RAM.
- To restart later, run:
docker compose up -d
Delete - I don't want to use this again
Remove all project resources and start fresh. This stops all Railway charges immediately.
- In the Railway dashboard, open your project.
- Click Settings, then click Delete Project. This removes all Railway services (PostgreSQL, Redis, and the app container).
- Stop and remove your local Docker containers and volumes by running:
docker compose down -v
- Delete the project folder from your Desktop if you no longer need the source code.
- Optionally revoke your Gemini API key from the Google AI Studio console if you no longer need it.
Nice Work!
Nice Work!
You just built a production-grade AI Decision Copilot backend from scratch. This is the kind of system design that gets discussed in Staff Engineer interviews.
You've learned how to:
- Built a REST API with async job processing using Spring Boot, Redis caching, PostgreSQL persistence, and Resilience4j fault tolerance patterns.
- Integrated Google Gemini for AI-powered decision generation via Spring AI and built a RAG pipeline using pgvector for embedding-based similarity search.
- Containerized the full stack with Docker and deployed to Railway as a live, production-ready API.
- Added a webhook notification system with delivery tracking and retry logic.
Ready to quiz yourself?