REST API with Spring Boot and PostgreSQL
Build a production-grade REST API using the enterprise Controller-Service-Repository pattern.
Introduction
30 Second Summary
Every app you interact with daily has a backend storing and serving data through APIs. From banking apps to game asset libraries, the same architectural patterns keep enterprise systems running reliably at scale.
In this project, you will build a REST API from scratch using Spring Boot and PostgreSQL that manages metadata for 3D assets. You will follow the Controller-Service-Repository pattern that dominates enterprise Java development.
What You'll Build
A fully functional REST API that performs CRUD operations on a PostgreSQL database, structured with the enterprise Controller-Service-Repository architecture.
By the end of this project, you'll have:
- A Spring Boot 4.1.0 Maven project with enterprise package structure connected to a PostgreSQL database running in Docker.
- A complete REST API with five endpoints using proper HTTP methods, status codes, and JSON responses.
- Hands-on experience with Java's Dependency Injection, JPA entity mapping, and interface-based service design.
- Secret Mission: Add input validation, global error handling with @RestControllerAdvice, and custom database query methods.
Are there any prerequisites?
This project assumes you have basic programming experience (a TypeScript or Python background is perfect). You should already have Docker Desktop installed and running on your machine.
If you are on Windows, you should also have WSL2 configured. No prior Java experience is required.
Set Up Your Environment
Every enterprise Java project starts with three things: a runtime to compile and execute your code, an IDE that understands Java's type system, and a build tool to manage dependencies.
In this step, you will install JDK 21, set up IntelliJ IDEA, and generate a Spring Boot project with all the dependencies your API needs.
In this step, get ready to:
- Install JDK 21 (Eclipse Temurin) and verify it with a version check.
- Install IntelliJ IDEA and confirm it detects your JDK.
- Generate your Spring Boot project from start.spring.io and open it in IntelliJ.
Install JDK 21 (Eclipse Temurin)
Java code needs a JDK (Java Development Kit) to compile and run. This project requires JDK 21, the current Long Term Support release.
- Open a terminal by pressing the Windows key, typing cmd, and pressing Enter.
- Install Eclipse Temurin JDK 21 via winget by running this command:
winget install EclipseAdoptium.Temurin.21.JDK
What does this command do?
This uses the Windows Package Manager (winget) to download and install Eclipse Temurin JDK 21. The installer automatically sets your JAVA_HOME environment variable and adds Java to your system PATH.
- The installer may prompt you to accept license agreements. Enter Y to accept and proceed.
- Close your current terminal and open a new one (the PATH changes only take effect in new sessions).
- Verify the installation by running this command:
java -version
✔️ I see version 21
Your output starts with a line containing 21 in the version string. JDK 21 is installed and ready.
ⓧ I see an older version
Your system has an older Java version taking priority on the PATH.
- Re-run the winget install command above. It will install JDK 21 alongside any existing version.
- Close and reopen your terminal, then run java -version again.
- If the old version persists, open Environment Variables from the Start menu and ensure JAVA_HOME points to your Temurin 21 installation (typically C:\Program Files\Eclipse Adoptium\jdk-21...).
- Move the Temurin 21 entry to the top of your system Path variable.
ⓧ Command not found
Java is not installed or the PATH was not updated.
- Ensure the winget install command completed without errors.
- Close and reopen your terminal to pick up the new PATH entries.
- If winget is not available on your system, download the MSI installer directly from the Adoptium installation page. Select Windows, JDK 21, and run the .msi file. On the Custom Setup screen, check all options including Set JAVA_HOME variable.
What is a JDK?
A JDK (Java Development Kit) contains the compiler (javac), the runtime (JVM), and standard libraries you need to build and run Java applications. Eclipse Temurin is a free, open-source JDK distribution maintained by the Adoptium working group.
Install IntelliJ IDEA
IntelliJ IDEA is the standard IDE for enterprise Java development. It provides code completion, refactoring tools, and built-in Maven support that you will rely on throughout this project.
- Open your browser and navigate to jetbrains.com/idea/download.
- Download the installer for Windows. Since 2025.3, IntelliJ IDEA is a single unified product with free core Java and Spring support.
- Run the installer and follow the on-screen prompts. Accept the defaults.
- Launch IntelliJ IDEA once the installation completes.
- On the Welcome screen, click Customize in the left sidebar.
- Confirm that IntelliJ detects your JDK 21 installation. You can check this under All settings (gear icon at the top) then Build, Execution, Deployment then Build Tools then Maven then Importing. The JDK dropdown should show 21.
IntelliJ does not detect JDK 21?
If no JDK appears in the dropdown, click the + button and browse to your Temurin installation folder (typically C:\Program Files\Eclipse Adoptium\jdk-21...).
Help me configure my JDK in IntelliJ IDEA.
Generate the Spring Boot project
Spring Initializr is an official web tool that scaffolds a Spring Boot project with all the correct dependencies and build configuration pre-wired. You will use it to create the Maven project structure for your API.
- Open start.spring.io in your browser.
- Set Project to Maven.
- Set Language to Java.
- Set Spring Boot to 4.1.0.
- Under Project Metadata, set Group to com.assetmanager.
- Set Artifact to api.
- Set Java to 21.
- Click ADD DEPENDENCIES and add each of the following one by one: Spring Web, Spring Data JPA, PostgreSQL Driver, Validation.
- Click GENERATE to download the project as a ZIP file.
What are these dependencies?
- Spring Web provides the web server and REST controller annotations.
- Spring Data JPA provides JPA (Java Persistence API) and Hibernate for database access.
- PostgreSQL Driver lets your app talk to a PostgreSQL database.
- Validation adds Jakarta Bean Validation for input checking (used in the Secret Mission).
- Extract the downloaded api.zip file to your Desktop.
- In IntelliJ IDEA, click Open on the Welcome screen.
- Navigate to the extracted api folder on your Desktop and click OK.
- If prompted to trust the project, click Trust Project.
IntelliJ will begin importing Maven dependencies. You will see a progress bar at the bottom of the window.
- Wait for the progress bar to finish. This may take 1-2 minutes on the first import as Maven downloads all required libraries.
- In the Project panel on the left, expand src then main then java then com.assetmanager.api.
- Confirm you can see ApiApplication.java listed there.
Maven imports not resolving?
If you see red underlines or unresolved imports, right-click the pom.xml file in the Project panel and select Maven then Reload project. Ensure you are connected to the internet for Maven to download dependencies.
Help me fix Maven import errors in IntelliJ IDEA.
✔️ Awesome, I've got everything!
Great. Double check you see ApiApplication.java in the project panel and that there are no red error indicators in pom.xml.
ⓧ I'd like to double check the full code
Here is the full pom.xml that Spring Initializr generated. Compare it with yours to ensure nothing is missing.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
<relativePath/>
</parent>
<groupId>com.assetmanager</groupId>
<artifactId>api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>asset-manager-api</name>
<description>3D Asset Metadata REST API</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
And here is the main application class:
package com.assetmanager.api;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ApiApplication {
public static void main(String[] args) {
SpringApplication.run(ApiApplication.class, args);
}
}
Your development environment is ready. Next up, you will spin up a PostgreSQL database using Docker Compose and connect your Spring Boot application to it.
Spin Up PostgreSQL with Docker Compose
Your development environment is ready with JDK 21 and IntelliJ IDEA. Now your API needs somewhere to store data.
Every enterprise REST API needs a database behind it. In this step, you will spin up a PostgreSQL database using Docker Compose and wire Spring Boot to connect to it.
In this step, get ready to:
- Create a Docker Compose file that runs PostgreSQL 16 locally.
- Configure Spring Boot to connect to the database.
- Verify the application starts with a live database connection.
Create the Docker Compose file
Docker Compose lets you define infrastructure as code in a single YAML file. Instead of manually installing PostgreSQL on your machine, you declare the database service and Docker handles the rest.
- In IntelliJ IDEA, right-click the project root folder (api) in the Project tool window on the left.
- Select New, then File.
- Name the file docker-compose.yml and press Enter.
- Paste the following configuration into the new file:
services:
postgres:
image: postgres:16
container_name: assetmanager-db
environment:
POSTGRES_DB: assetmanager
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
What does this configuration do?
- image: postgres:16 pulls the official PostgreSQL 16 Docker image.
- container_name: assetmanager-db gives the container a recognizable name you can reference later.
- environment sets the database name, username, and password that PostgreSQL creates on first boot.
- ports maps port 5432 inside the container to port 5432 on your machine, so Spring Boot can connect via localhost.
- volumes persists database data so it survives container restarts.
- Save docker-compose.yml.
- Open a terminal in IntelliJ by pressing Alt+F12 (Windows).
- Start the PostgreSQL container by running this command:
docker compose up -d
- Verify the container is running by running:
docker ps
You should see a container named assetmanager-db with status Up in the output.
Container not starting?
- Make sure Docker Desktop is running. Check your system tray for the Docker whale icon.
- If port 5432 is already in use, another PostgreSQL instance may be running. Stop it or change the host port in docker-compose.yml.
- help me troubleshoot my Docker Compose PostgreSQL container
Configure Spring Boot to connect to PostgreSQL
Spring Boot needs connection details to find your database. You provide these in the application.properties file that Spring reads on startup.
- In the Project tool window, expand src then main then resources.
- Open application.properties.
- Paste the following configuration into the file:
spring.datasource.url=jdbc:postgresql://localhost:5432/assetmanager
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
What do these properties do?
- spring.datasource.url tells Spring Boot where to find the database. The URL matches the port and database name from your Docker Compose file.
- spring.datasource.username and spring.datasource.password provide the credentials you set in the environment section of docker-compose.yml.
- spring.jpa.hibernate.ddl-auto=update tells Hibernate to automatically create or update database tables based on your Java entity classes.
- spring.jpa.show-sql=true prints every SQL query to the console so you can see what Hibernate does behind the scenes.
- spring.jpa.properties.hibernate.format_sql=true makes those SQL queries readable with proper indentation.
- Save application.properties.
Why Docker Compose for the database?
In German enterprise development (banking, automotive, insurance), Docker Compose is the standard way to manage local development dependencies. Every developer on the team gets an identical database by running a single command.
This also prepares you for production patterns where databases run in containers orchestrated by Kubernetes.
Run the application and verify the connection
With PostgreSQL running and the connection details in place, Spring Boot should start up cleanly and connect to the database.
- In IntelliJ, open ApiApplication.java from the Project tool window (src > main > java > com.assetmanager.api).
- Click the green Run icon in the gutter next to the main() method.
- Select Run 'ApiApplication.main()'.
Keyboard shortcut for running
After the first run, you can press Shift+F10 to re-run the application without clicking through the menu.
- Watch the Run tool window at the bottom of IntelliJ.
You should see Spring Boot logs ending with a line that says Started ApiApplication and no red error messages.
Seeing a connection refused error?
- Run docker ps in the terminal to confirm the assetmanager-db container is running. If it is not listed, run docker compose up -d again.
- Double-check that spring.datasource.url in application.properties uses port 5432 and database name assetmanager.
- The container may need a few seconds to initialize on first run. Wait 10 seconds and try running the application again.
- help me fix my Spring Boot PostgreSQL connection error
✔️ Awesome, I've got everything!
Great. Make sure you have saved both docker-compose.yml and application.properties.
ⓧ I'd like to double check the full code
Here is the complete docker-compose.yml:
services:
postgres:
image: postgres:16
container_name: assetmanager-db
environment:
POSTGRES_DB: assetmanager
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
Here is the complete application.properties:
spring.datasource.url=jdbc:postgresql://localhost:5432/assetmanager
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
Your database is live and Spring Boot is connected. Next up, you will create the JPA entity that maps a Java class to a real database table.
Build the Entity and Repository Layer
Your Spring Boot app connects to PostgreSQL successfully. Now it needs to know what data to store and how to structure it.
In enterprise Java, the data layer follows a strict pattern. An entity defines the shape of your data (the table and its columns). A repository provides the interface for reading and writing that data. In this step, you will create both, following the enterprise package structure that keeps each concern in its own folder.
In this step, get ready to:
- Create the enterprise package structure (model, repository, service, controller).
- Define the Asset entity with JPA annotations to map it to a PostgreSQL table.
- Create the repository interface and verify Hibernate generates the table.
Create the package structure and ProcessingStatus enum
Enterprise Java projects separate concerns into dedicated packages. Each package holds one type of class, making large codebases navigable.
- In IntelliJ, open the Project tool window on the left side.
- Expand src > main > java > com.assetmanager.api.
- Right-click on com.assetmanager.api and select New > Package.
- Type model and press Enter.
- Repeat this three more times to create packages named repository, service, and controller.
What is this package structure?
This is the Controller-Service-Repository pattern. Each layer has one job: the model holds your data classes, the repository talks to the database, the service contains business logic, and the controller handles HTTP requests.
Keeping them separate means you can change one layer without breaking the others.
Now you will create an enum that defines the processing states a 3D asset can be in.
- Right-click the model package and select New > Java Class.
- In the dialog, select Enum from the kind dropdown.
- Name it ProcessingStatus and press Enter.
- Replace the generated content with this code:
package com.assetmanager.api.model;
public enum ProcessingStatus {
PENDING,
PROCESSED,
FAILED
}
What does this enum do?
An enum defines a fixed set of possible values. Every 3D asset starts as PENDING when uploaded, moves to PROCESSED after AI analysis, or to FAILED if something went wrong.
Storing this as a STRING in the database (rather than a number) means the values stay readable even if you reorder the enum later.
- Save ProcessingStatus.java with Ctrl+S (Windows) or Cmd+S (macOS).
✔️ Awesome, I've got everything!
Great. Make sure you have four packages visible under com.assetmanager.api and the ProcessingStatus file saved.
ⓧ I'd like to double check the full code
package com.assetmanager.api.model;
public enum ProcessingStatus {
PENDING,
PROCESSED,
FAILED
}
Don't see the package options?
Make sure you are right-clicking on com.assetmanager.api inside the src/main/java tree (the blue folder icon). If you right-click the wrong folder, the Package option will not appear.
help me create packages in IntelliJ
Create the Asset entity class
The entity is where you define how your Java object maps to a database table. Each field becomes a column. JPA annotations tell Hibernate exactly how to translate between Java and SQL.
- Right-click the model package and select New > Java Class.
- Name it Asset and press Enter.
- Replace the generated content with this code:
package com.assetmanager.api.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "assets")
public class Asset {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
private String description;
@Column(nullable = false)
private String fileType;
private Long fileSizeBytes;
private String uploadedBy;
private String storageUrl;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ProcessingStatus processingStatus = ProcessingStatus.PENDING;
@Column(columnDefinition = "TEXT")
private String aiMetadata;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getFileType() {
return fileType;
}
public void setFileType(String fileType) {
this.fileType = fileType;
}
public Long getFileSizeBytes() {
return fileSizeBytes;
}
public void setFileSizeBytes(Long fileSizeBytes) {
this.fileSizeBytes = fileSizeBytes;
}
public String getUploadedBy() {
return uploadedBy;
}
public void setUploadedBy(String uploadedBy) {
this.uploadedBy = uploadedBy;
}
public String getStorageUrl() {
return storageUrl;
}
public void setStorageUrl(String storageUrl) {
this.storageUrl = storageUrl;
}
public ProcessingStatus getProcessingStatus() {
return processingStatus;
}
public void setProcessingStatus(ProcessingStatus processingStatus) {
this.processingStatus = processingStatus;
}
public String getAiMetadata() {
return aiMetadata;
}
public void setAiMetadata(String aiMetadata) {
this.aiMetadata = aiMetadata;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
}
What do these annotations do?
- `@Entity` tells Hibernate this class maps to a database table.
- `@Table(name = "assets")` sets the exact table name in PostgreSQL.
- `@Id` and `@GeneratedValue(strategy = GenerationType.IDENTITY)` make `id` an auto-incrementing primary key.
- `@Column(nullable = false)` marks fields that cannot be null in the database.
- `@Enumerated(EnumType.STRING)` stores the enum as readable text ("PENDING") instead of a number.
- `@PrePersist` on `onCreate()` automatically sets `createdAt` to the current time before the record is first saved.
- Save Asset.java.
Why all the getters and setters?
JPA requires a way to read and write each field. In Java, the standard pattern is explicit getter and setter methods for every field. This is the Object-Oriented Programming principle of encapsulation in action. The fields are private, and access goes through controlled methods.
✔️ Awesome, I've got everything!
Great. Make sure Asset.java is saved. You will verify it works after creating the repository in the next substep.
ⓧ I'd like to double check the full code
package com.assetmanager.api.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "assets")
public class Asset {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
private String description;
@Column(nullable = false)
private String fileType;
private Long fileSizeBytes;
private String uploadedBy;
private String storageUrl;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ProcessingStatus processingStatus = ProcessingStatus.PENDING;
@Column(columnDefinition = "TEXT")
private String aiMetadata;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getFileType() {
return fileType;
}
public void setFileType(String fileType) {
this.fileType = fileType;
}
public Long getFileSizeBytes() {
return fileSizeBytes;
}
public void setFileSizeBytes(Long fileSizeBytes) {
this.fileSizeBytes = fileSizeBytes;
}
public String getUploadedBy() {
return uploadedBy;
}
public void setUploadedBy(String uploadedBy) {
this.uploadedBy = uploadedBy;
}
public String getStorageUrl() {
return storageUrl;
}
public void setStorageUrl(String storageUrl) {
this.storageUrl = storageUrl;
}
public ProcessingStatus getProcessingStatus() {
return processingStatus;
}
public void setProcessingStatus(ProcessingStatus processingStatus) {
this.processingStatus = processingStatus;
}
public String getAiMetadata() {
return aiMetadata;
}
public void setAiMetadata(String aiMetadata) {
this.aiMetadata = aiMetadata;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
}
Seeing red underlines on the imports?
If IntelliJ shows errors on the jakarta.persistence imports, your Maven dependencies may not have fully loaded. Click the Maven reload icon (circular arrows) in the top-right corner of IntelliJ, or open the Maven tool window on the right and click Reload All Maven Projects.
help me fix Jakarta persistence import errors in IntelliJ
Create the repository and verify the table
Spring Data JPA eliminates boilerplate database code. You define an interface, and Spring automatically generates the implementation at runtime. No SQL queries to write for basic CRUD operations.
- Right-click the repository package and select New > Java Class.
- In the dialog, select Interface from the kind dropdown.
- Name it AssetRepository and press Enter.
- Replace the generated content with this code:
package com.assetmanager.api.repository;
import com.assetmanager.api.model.Asset;
import com.assetmanager.api.model.ProcessingStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface AssetRepository extends JpaRepository<Asset, Long> {
}
What does this interface do?
By extending JpaRepository<Asset, Long>, Spring auto-generates implementations for save(), findAll(), findById(), delete(), and more. You write zero SQL.
The two generic parameters tell Spring which entity (Asset) this repository manages and what type the primary key is (Long).
- Save AssetRepository.java.
- Restart the Spring Boot application by clicking the green Run button in IntelliJ (or stop and re-run ApiApplication).
In the console output, you should see Hibernate's formatted SQL creating the assets table. It will include column definitions matching your entity fields.
- Verify the table exists in PostgreSQL by running this command in your terminal:
docker exec -it assetmanager-db psql -U postgres -d assetmanager -c "\dt"
You should see a table named assets listed in the output.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files and can see the assets table confirmed in the \dt output.
ⓧ I'd like to double check the full code
package com.assetmanager.api.repository;
import com.assetmanager.api.model.Asset;
import com.assetmanager.api.model.ProcessingStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface AssetRepository extends JpaRepository<Asset, Long> {
}
Not seeing the CREATE TABLE SQL?
If the console does not show Hibernate DDL output, check that application.properties still has spring.jpa.show-sql=true and spring.jpa.hibernate.ddl-auto=update. Also confirm that Asset.java has the @Entity annotation on the class.
If you see the table already exists (because you ran the app before adding show-sql), Hibernate may skip DDL for existing tables. The docker exec command above is your definitive check.
help me troubleshoot Hibernate not creating my table
Your data layer is complete. You have a clean entity that maps to PostgreSQL and a repository that gives you full CRUD operations for free. Next up, you will build the service layer that wraps this repository with business logic.
Build the Service Layer
Your data layer is solid. The Asset entity maps to PostgreSQL, and the AssetRepository gives you automatic CRUD operations through Spring Data JPA.
Now you need a place for business logic. In enterprise Java, you never let controllers talk directly to repositories. Instead, you introduce a service layer that sits between them. This layer is where validation rules, orchestration, and error handling live.
In this step, get ready to:
- Define the AssetService interface as a CRUD contract.
- Implement the service with constructor-based Dependency Injection.
- Verify Spring wires your service bean with no errors.
Create the AssetService interface
In enterprise Java, you define a contract (an interface) before writing the implementation. This follows the SOLID principle of programming to an interface, not an implementation.
- In IntelliJ's Project sidebar, right-click the service package (src/main/java/com/assetmanager/api/service).
- Select New, then Java Class.
- Choose Interface from the kind dropdown and name it AssetService.
- Replace the generated contents with the following code:
package com.assetmanager.api.service;
import com.assetmanager.api.model.Asset;
import java.util.List;
// Defines the CRUD contract for asset operations
public interface AssetService {
// Create a new asset record
Asset createAsset(Asset asset);
// Retrieve all assets from the database
List<Asset> getAllAssets();
// Find a single asset by its primary key
Asset getAssetById(Long id);
// Update an existing asset's fields
Asset updateAsset(Long id, Asset assetDetails);
// Remove an asset by its ID
void deleteAsset(Long id);
}
What does this code do?
This interface declares five methods that any implementation must provide. It does not contain any logic itself.
- createAsset and updateAsset return the saved Asset object so the caller gets the generated ID and timestamp.
- getAssetById takes a Long id and returns a single asset.
- deleteAsset returns void because there is nothing to send back after deletion.
- Save AssetService.java with Ctrl+S (Windows) or Cmd+S (macOS).
Why an interface instead of a class?
In enterprise codebases, you define a service interface so you can swap implementations without changing the code that depends on them. For example, you could swap in a caching implementation or a mock for unit tests.
Spring's Dependency Injection automatically resolves which class implements the interface at runtime. You code against the contract, and Spring handles the wiring.
✔️ Awesome, I've got everything!
Great. Make sure you have saved AssetService.java before moving on.
ⓧ I'd like to double check the full code
package com.assetmanager.api.service;
import com.assetmanager.api.model.Asset;
import java.util.List;
public interface AssetService {
Asset createAsset(Asset asset);
List<Asset> getAllAssets();
Asset getAssetById(Long id);
Asset updateAsset(Long id, Asset assetDetails);
void deleteAsset(Long id);
}
Create the AssetServiceImpl class
The implementation class contains the actual business logic. It uses constructor injection to receive the repository, which makes the dependency explicit and supports testing without the Spring container.
- Right-click the service package in the Project sidebar.
- Select New, then Java Class.
- Name it AssetServiceImpl and leave the kind as Class.
- Replace the generated contents with the following code:
package com.assetmanager.api.service;
import com.assetmanager.api.model.Asset;
import com.assetmanager.api.repository.AssetRepository;
import org.springframework.stereotype.Service;
import java.util.List;
// Marks this class as a Spring-managed service bean
@Service
public class AssetServiceImpl implements AssetService {
// Final field ensures the dependency cannot change after construction
private final AssetRepository assetRepository;
// Constructor injection - Spring passes the repository automatically
public AssetServiceImpl(AssetRepository assetRepository) {
this.assetRepository = assetRepository;
}
@Override
public Asset createAsset(Asset asset) {
// save() persists the entity and returns it with the generated ID
return assetRepository.save(asset);
}
@Override
public List<Asset> getAllAssets() {
// findAll() retrieves every row from the assets table
return assetRepository.findAll();
}
@Override
public Asset getAssetById(Long id) {
// findById() returns an Optional - throw if the asset doesn't exist
return assetRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Asset not found with id: " + id));
}
@Override
public Asset updateAsset(Long id, Asset assetDetails) {
// Fetch the existing asset first, then overwrite its fields
Asset asset = getAssetById(id);
asset.setName(assetDetails.getName());
asset.setDescription(assetDetails.getDescription());
asset.setFileType(assetDetails.getFileType());
asset.setFileSizeBytes(assetDetails.getFileSizeBytes());
asset.setUploadedBy(assetDetails.getUploadedBy());
asset.setStorageUrl(assetDetails.getStorageUrl());
asset.setProcessingStatus(assetDetails.getProcessingStatus());
asset.setAiMetadata(assetDetails.getAiMetadata());
return assetRepository.save(asset);
}
@Override
public void deleteAsset(Long id) {
// Fetch first to confirm it exists, then delete
Asset asset = getAssetById(id);
assetRepository.delete(asset);
}
}
What does this code do?
- @Service tells Spring to register this class as a bean in the application context. Spring will detect that it implements AssetService and wire it wherever the interface is requested.
- The constructor takes AssetRepository as a parameter. Spring automatically injects the repository bean here because there is only one constructor.
- getAssetById uses .orElseThrow() to convert the Optional returned by findById into a concrete object or an exception.
- updateAsset fetches the existing record, copies every field from the incoming object, then saves. Calling save() on an entity that already has an ID triggers an UPDATE instead of an INSERT.
- Save AssetServiceImpl.java.
Why constructor injection instead of @Autowired?
Constructor injection is the Spring-recommended approach. It makes dependencies explicit, allows the field to be final (immutable after creation), and lets you write unit tests without starting a Spring context.
When a class has a single constructor, Spring uses it automatically for injection. No @Autowired annotation is needed.
Seeing red underlines in IntelliJ?
- If AssetRepository or Asset show as unresolved, make sure the import statements at the top match the package paths exactly.
- If IntelliJ shows "Cannot resolve symbol", try clicking File then Invalidate Caches / Restart.
Ask for help: Why is IntelliJ showing unresolved imports in my AssetServiceImpl?
✔️ Awesome, I've got everything!
Great. Make sure both AssetService.java and AssetServiceImpl.java are saved before running the application.
ⓧ I'd like to double check the full code
package com.assetmanager.api.service;
import com.assetmanager.api.model.Asset;
import com.assetmanager.api.repository.AssetRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class AssetServiceImpl implements AssetService {
private final AssetRepository assetRepository;
public AssetServiceImpl(AssetRepository assetRepository) {
this.assetRepository = assetRepository;
}
@Override
public Asset createAsset(Asset asset) {
return assetRepository.save(asset);
}
@Override
public List<Asset> getAllAssets() {
return assetRepository.findAll();
}
@Override
public Asset getAssetById(Long id) {
return assetRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Asset not found with id: " + id));
}
@Override
public Asset updateAsset(Long id, Asset assetDetails) {
Asset asset = getAssetById(id);
asset.setName(assetDetails.getName());
asset.setDescription(assetDetails.getDescription());
asset.setFileType(assetDetails.getFileType());
asset.setFileSizeBytes(assetDetails.getFileSizeBytes());
asset.setUploadedBy(assetDetails.getUploadedBy());
asset.setStorageUrl(assetDetails.getStorageUrl());
asset.setProcessingStatus(assetDetails.getProcessingStatus());
asset.setAiMetadata(assetDetails.getAiMetadata());
return assetRepository.save(asset);
}
@Override
public void deleteAsset(Long id) {
Asset asset = getAssetById(id);
assetRepository.delete(asset);
}
}
Run the application to verify dependency injection
Spring's Dependency Injection container scans your packages at startup. If the wiring is correct, the application starts cleanly. If something is misconfigured, you get an immediate error.
- Restart the application by clicking the green Run button in IntelliJ's toolbar, or run this command in your terminal:
./mvnw spring-boot:run
- Watch the console output. You should see Started ApiApplication with no exceptions.
What should I see?
The console prints Hibernate SQL statements, then finishes with a line like Started ApiApplication in X seconds. If you see this, Spring successfully created your AssetServiceImpl bean and injected the repository into it.
There should be no No qualifying bean or UnsatisfiedDependencyException messages.
Seeing a bean injection error?
- Check that AssetServiceImpl has the @Service annotation. Without it, Spring does not know the class exists.
- Verify the class is inside the com.assetmanager.api.service package. Spring Boot's component scan starts from the package containing ApiApplication.java and scans sub-packages.
- Make sure the constructor parameter type is AssetRepository (the interface), not a concrete class.
Ask for help: Why is Spring Boot throwing a bean injection error for my AssetServiceImpl?
Your service layer is wired and ready. Next up, you will build the controller layer that exposes your service methods as REST endpoints over HTTP.
Build the Controller Layer and Test Your API
Your service layer is ready to handle business logic. Now you need a way for the outside world to talk to it over HTTP.
The controller is the front door of your API. It receives HTTP requests, delegates work to the service layer, and sends back properly formatted JSON responses with the correct status codes.
In this step, get ready to:
- Create the AssetController class with constructor injection.
- Implement five REST endpoints with proper HTTP methods and status codes.
- Test all endpoints with curl to verify end-to-end functionality.
Create the AssetController class
In Spring Boot, a REST controller is a class annotated with @RestController that automatically serializes return values to JSON. The @RequestMapping annotation sets the base URL path for all endpoints in the class.
- In IntelliJ, right-click the controller package inside src/main/java/com/assetmanager/api/.
- Select New, then Java Class.
- Name it AssetController and press Enter.
- Replace the generated file content with this code:
package com.assetmanager.api.controller;
import com.assetmanager.api.model.Asset;
import com.assetmanager.api.service.AssetService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/assets")
public class AssetController {
private final AssetService assetService;
public AssetController(AssetService assetService) {
this.assetService = assetService;
}
}
What does this code do?
@RestController tells Spring this class handles HTTP requests and returns JSON responses automatically. @RequestMapping("/api/assets") sets the base URL path so every endpoint in this class starts with /api/assets.
The constructor receives an AssetService parameter. Spring automatically injects the AssetServiceImpl bean you created in the previous step. This is the same constructor injection pattern you used in the service layer.
- Save AssetController.java.
Seeing red underlines on the imports?
If IntelliJ shows unresolved import errors, wait a few seconds for the indexing to finish. If they persist, press Ctrl+Shift+O (Windows) or Ctrl+Shift+O (macOS) to optimize imports.
help me fix unresolved imports in my Spring Boot controller
Implement the REST endpoints
Each endpoint maps an HTTP verb to a service method. Spring uses annotations like @PostMapping and @GetMapping to route incoming requests to the correct method.
- In AssetController.java, add the following five methods inside the class body, after the constructor:
// CREATE - accepts JSON body, returns 201 Created
@PostMapping
public ResponseEntity<Asset> createAsset(@RequestBody Asset asset) {
Asset created = assetService.createAsset(asset);
return new ResponseEntity<>(created, HttpStatus.CREATED);
}
// READ ALL - returns list of all assets with 200 OK
@GetMapping
public ResponseEntity<List<Asset>> getAllAssets() {
return ResponseEntity.ok(assetService.getAllAssets());
}
// READ ONE - returns single asset by ID with 200 OK
@GetMapping("/{id}")
public ResponseEntity<Asset> getAssetById(@PathVariable Long id) {
return ResponseEntity.ok(assetService.getAssetById(id));
}
// UPDATE - accepts JSON body, returns updated asset with 200 OK
@PutMapping("/{id}")
public ResponseEntity<Asset> updateAsset(@PathVariable Long id, @RequestBody Asset asset) {
return ResponseEntity.ok(assetService.updateAsset(id, asset));
}
// DELETE - returns 204 No Content
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteAsset(@PathVariable Long id) {
assetService.deleteAsset(id);
return ResponseEntity.noContent().build();
}
What does this code do?
- @PostMapping handles POST requests. @RequestBody tells Spring to deserialize the incoming JSON into an Asset object. Returns HTTP 201 to signal a resource was created.
- @GetMapping handles GET requests. The version without a path matches the base URL (/api/assets). The version with "/{id}" captures the URL segment as a method parameter via @PathVariable.
- @PutMapping("/{id}") handles PUT requests for updating an existing resource. It receives both the ID from the URL and the updated fields from the JSON body.
- @DeleteMapping("/{id}") handles DELETE requests. It returns ResponseEntity.noContent().build() which sends a 204 status with an empty body.
- ResponseEntity wraps the response so you can control both the body and the HTTP status code. This is the enterprise pattern for REST APIs in Spring.
- Save AssetController.java.
- Restart the application by clicking the green Run button in IntelliJ (or stop and re-run ApiApplication).
- Verify the console shows "Started ApiApplication" with no errors.
Application fails to start?
Check that the closing brace } of the class is after all five methods. A misplaced brace is the most common cause of compile errors here.
Also confirm the assetService field name in your methods matches the constructor parameter exactly. Java is case-sensitive.
help me fix my AssetController startup error
✔️ Awesome, I've got everything!
Great. Make sure you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete AssetController.java file:
package com.assetmanager.api.controller;
import com.assetmanager.api.model.Asset;
import com.assetmanager.api.service.AssetService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/assets")
public class AssetController {
private final AssetService assetService;
public AssetController(AssetService assetService) {
this.assetService = assetService;
}
@PostMapping
public ResponseEntity<Asset> createAsset(@RequestBody Asset asset) {
Asset created = assetService.createAsset(asset);
return new ResponseEntity<>(created, HttpStatus.CREATED);
}
@GetMapping
public ResponseEntity<List<Asset>> getAllAssets() {
return ResponseEntity.ok(assetService.getAllAssets());
}
@GetMapping("/{id}")
public ResponseEntity<Asset> getAssetById(@PathVariable Long id) {
return ResponseEntity.ok(assetService.getAssetById(id));
}
@PutMapping("/{id}")
public ResponseEntity<Asset> updateAsset(@PathVariable Long id, @RequestBody Asset asset) {
return ResponseEntity.ok(assetService.updateAsset(id, asset));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteAsset(@PathVariable Long id) {
assetService.deleteAsset(id);
return ResponseEntity.noContent().build();
}
}
Test your API with curl
Your API is running. Time to prove it works by sending real HTTP requests and verifying the responses.
- Open a WSL2 terminal (separate from the one running your app).
- Create a new 3D asset by running this command:
curl -X POST http://localhost:8080/api/assets \
-H "Content-Type: application/json" \
-d '{"name": "Spaceship Model", "fileType": "FBX", "fileSizeBytes": 2048000, "uploadedBy": "artist1", "description": "A low-poly spaceship"}'
You should see a JSON response with an id field, a createdAt timestamp, and processingStatus set to PENDING.
- Retrieve all assets by running:
curl http://localhost:8080/api/assets
You should see a JSON array containing the asset you just created.
- Retrieve a single asset by ID by running:
curl http://localhost:8080/api/assets/1
You should see the same asset, fetched directly by its ID.
- Update the asset by running:
curl -X PUT http://localhost:8080/api/assets/1 \
-H "Content-Type: application/json" \
-d '{"name": "Spaceship Model v2", "fileType": "FBX", "fileSizeBytes": 3072000, "uploadedBy": "artist1", "description": "An updated high-poly spaceship", "processingStatus": "PROCESSED"}'
You should see the updated asset with name changed to Spaceship Model v2 and processingStatus changed to PROCESSED.
- Delete the asset by running:
curl -v -X DELETE http://localhost:8080/api/assets/1
In the verbose output, look for the line < HTTP/1.1 204 which confirms the server returned a 204 No Content status with an empty body.
- Confirm the deletion by running GET all again:
curl http://localhost:8080/api/assets
You should see an empty array [] confirming the asset was removed from PostgreSQL.
What do these status codes mean?
- 201 Created signals a new resource was successfully created.
- 200 OK signals the request succeeded and the response body contains the result.
- 204 No Content signals the request succeeded but there is nothing to return. This is the standard response for successful DELETE operations.
Getting "Connection refused" on curl?
Make sure your Spring Boot application is still running in IntelliJ. Check the Run panel at the bottom of IntelliJ to confirm the process is active.
If you see a 500 error on POST, check that your Docker container assetmanager-db is running by executing docker ps in a terminal. The database must be available for writes to succeed.
help me debug curl connection issues with my Spring Boot API
Your REST API is fully functional. You can create, read, update, and delete 3D asset records, and they persist in PostgreSQL between requests. Next up, take on the Secret Mission to add enterprise-grade validation and error handling.
Secret mission
Add Validation and Error Handling
Your API accepts any data and throws ugly stack traces when things go wrong. Real enterprise APIs validate input at the boundary and return structured error responses. Add Bean Validation, a global exception handler with @RestControllerAdvice, and custom query methods to level up your API.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker container assetmanager-db running PostgreSQL 16.
- Docker volume postgres_data storing your database data.
- The api project folder with all Spring Boot source code.
- JDK 21 (Eclipse Temurin) installed via the Adoptium MSI installer.
- IntelliJ IDEA installed on your machine.
Keep everything running
No action needed. Choose this if you are still actively building on this project or plan to extend it with Kafka, Kubernetes, or AI layers next.
- Your Docker container continues running in the background. It uses minimal resources.
- IntelliJ IDEA and JDK 21 stay installed and ready for your next Java project.
- Your project files remain on disk in the api folder.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and data stay on disk so you can resume later.
- Stop the Spring Boot application if it is still running. Press Ctrl+C in the terminal, or click the red Stop button in IntelliJ.
- Stop the Docker container by running this command from the project folder:
docker compose stop
- When you come back, start it again with docker compose up -d and run the application from IntelliJ.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to clean everything up.
- Stop the Spring Boot application if it is still running.
- Remove the Docker container and its data volume by running this command from the project folder:
docker compose down -v
What does this command do?
The -v flag tells Docker Compose to remove the named volumes declared in your docker-compose.yml. This deletes the postgres_data volume and all database records stored inside it.
- Delete the project folder. On macOS or WSL2:
rm -rf api
- On Windows (Command Prompt):
rmdir /s /q api
- To uninstall JDK 21 (Eclipse Temurin): open Settings on Windows, go to Apps, find the Eclipse Temurin JDK entry, and click Uninstall.
- To uninstall IntelliJ IDEA: open Settings on Windows, go to Apps, find the IntelliJ IDEA entry, and click Uninstall. Optionally remove leftover settings by deleting the JetBrains folders inside %APPDATA% and %LOCALAPPDATA%.
Nice Work!
Nice Work!
Nice work! You just built a production-grade REST API from scratch using Spring Boot 4.1.0 and Java 21, backed by a PostgreSQL database running in Docker.
You've learned how to:
- Built a complete REST API with proper HTTP methods, status codes, and ResponseEntity using Spring Boot 4.1.0.
- Structured a project following the enterprise Controller-Service-Repository pattern with interface-based service design and constructor Dependency Injection.
- Mapped a JPA entity to PostgreSQL with Hibernate, including enums with @Enumerated and lifecycle callbacks with @PrePersist.
- Secret Mission: Added Bean Validation, global exception handling with @RestControllerAdvice, and Spring Data JPA custom query methods.
This is the exact architecture pattern used across German enterprise codebases in banking, automotive, and insurance. Your Kafka, Kubernetes, and AI layers will plug directly into this foundation.
Ready to quiz yourself?