JAVA · STEP 5 · API
Java · Spring Boot: building and deploying an API
Build the /todos CRUD API with Spring Boot 4.1, Spring Data JPA, Bean Validation and Postgres, open CORS for a frontend, test it with curl and the contract check, then deploy it from a Dockerfile with a managed Postgres on Railway.
What you will learn#
- Creating an API
- Setting CORS headers
- Testing an API
- Deploying an API
The Todos contract#
| Request | Response |
|---|---|
GET /health | 200 {"ok":true} |
GET /todos | 200 [{id,subject,details}] ordered by id |
GET /todos/:id | 200 the object, or 404 {"error":"not found"} |
POST /todos with {subject,details} | 201 the new object |
PUT /todos/:id with {subject,details} | 200 the updated object, or 404 |
DELETE /todos/:id | 204 with no body, or 404 |
blank or missing subject or details | 400 {"error":"…"} |
Every backend guide on this site builds this same API: no trailing slashes, objects that contain exactly id, subject and details, and CORS open to any origin so the four frontend guides can point at any of them. The finished Spring Boot example is github.com/arthurbernierjr/dtc-java-api, live at api-production-c419.up.railway.app.
Setup#
- Generate a new project at start.spring.io: Maven · Java · Spring Boot 4.1.2 · Group
learning.java· Artifacttodos· Java 25 - Dependencies: Spring Web, Validation, Spring Data JPA, PostgreSQL Driver, Spring Boot Actuator
- Generate, unzip, open the
todosfolder in your editor. Thepom.xmlyou end up with (version0.0.1, which the Dockerfile below relies on):
pom.xml
<?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.2</version> </parent> <groupId>learning.java</groupId> <artifactId>todos</artifactId> <version>0.0.1</version> <properties> <java.version>25</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webmvc</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build></project>createdb javatodos, then put your database settings insrc/main/resources/application.properties(this version is already set up for deployment):
src/main/resources/application.properties
spring.datasource.url=jdbc:postgresql://${PGHOST:localhost}:${PGPORT:5432}/${PGDATABASE:javatodos}spring.datasource.username=${PGUSER:postgres}spring.datasource.password=${PGPASSWORD:postgres}spring.jpa.hibernate.ddl-auto=updateserver.port=${PORT:8080}./mvnw spring-boot:runand check that localhost:8080/todos answers[]. The generatedTodosApplication.javamain class stays exactly as start.spring.io wrote it.
WHY ${VAR:default}: Spring Boot reads application.properties and environment variables, with the environment winning. The placeholder gives each setting a local default and lets a host supply the real value without a second config file. ddl-auto=update has Hibernate create the todo table on first boot, so there is no migration step.
Creating the API#
Creating Our Todo Entity#
- Create an
entitiespackage in the code folder (src/main/java/learning/java/todos) withTodo.java
src/main/java/learning/java/todos/entities/Todo.java
package learning.java.todos.entities; import jakarta.persistence.Entity;import jakarta.persistence.GeneratedValue;import jakarta.persistence.GenerationType;import jakarta.persistence.Id;import jakarta.validation.constraints.NotBlank; @Entitypublic class Todo { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotBlank private String subject; @NotBlank private String details; protected Todo() {} public Long getId() { return id; } public String getSubject() { return subject; } public void setSubject(String subject) { this.subject = subject; } public String getDetails() { return details; } public void setDetails(String details) { this.details = details; }}@Idplus@GeneratedValue(IDENTITY)is a Postgres identity column, so the JSON you POST never carries an id.@NotBlank(fromspring-boot-starter-validation) rejectsnull,""and" ". No other fields, so the JSON is exactlyid,subject,details.- The
protectedno-argument constructor is for JPA and Jackson, not for your code.
Create a New Repository#
src/main/java/learning/java/todos/repositories/TodoRepository.java
package learning.java.todos.repositories; import org.springframework.data.jpa.repository.JpaRepository;import learning.java.todos.entities.Todo; public interface TodoRepository extends JpaRepository<Todo, Long> {}The two type arguments are the entity and the type of its id. JpaRepository gives you findAll, findById, save, existsById and deleteById for free.
Creating Our Controller#
src/main/java/learning/java/todos/controllers/TodoController.java
package learning.java.todos.controllers; import jakarta.validation.Valid;import java.util.List;import java.util.Map;import learning.java.todos.entities.Todo;import learning.java.todos.repositories.TodoRepository;import org.springframework.data.domain.Sort;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.ResponseStatus;import org.springframework.web.bind.annotation.RestController; @RestController@RequestMapping("/todos")public class TodoController { private final TodoRepository todos; public TodoController(TodoRepository todos) { this.todos = todos; } @GetMapping public List<Todo> index() { return todos.findAll(Sort.by("id")); } @GetMapping("/{id}") public ResponseEntity<?> show(@PathVariable Long id) { return todos.findById(id) .<ResponseEntity<?>>map(todo -> ResponseEntity.ok(todo)) .orElseGet(TodoController::notFound); } @PostMapping @ResponseStatus(HttpStatus.CREATED) public Todo create(@Valid @RequestBody Todo newTodo) { return todos.save(newTodo); } @PutMapping("/{id}") public ResponseEntity<?> update(@PathVariable Long id, @Valid @RequestBody Todo fixTodo) { return todos.findById(id).<ResponseEntity<?>>map(todo -> { todo.setSubject(fixTodo.getSubject()); todo.setDetails(fixTodo.getDetails()); return ResponseEntity.ok(todos.save(todo)); }).orElseGet(TodoController::notFound); } @DeleteMapping("/{id}") public ResponseEntity<?> destroy(@PathVariable Long id) { if (!todos.existsById(id)) return notFound(); todos.deleteById(id); return ResponseEntity.noContent().build(); } static ResponseEntity<Map<String, String>> notFound() { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "not found")); }}@RequestMapping("/todos")prefixes every method;@GetMapping("/{id}")isGET /todos/:id. Spring does not match trailing slashes, so/todos/is a 404 — fine, the contract never uses one.findAll(Sort.by("id"))is the "ordered by id" line of the contract; Postgres returns rows in any order unless you ask.@Validruns the@NotBlankchecks before the method body; a failure throwsMethodArgumentNotValidException, which the advice below turns into a 400.ResponseEntity<?>lets one method return either aTodo(200) or the{"error":"not found"}map (404); the.<ResponseEntity<?>>map(...)type witness makesOptionalaccept both.@ResponseStatus(HttpStatus.CREATED)makescreatea 201;noContent()makesdestroya 204 with no body.
Turning exceptions into JSON errors#
Without this class Spring answers a bad request with its own error page. A @RestControllerAdvice catches exceptions from every controller and returns exactly the small bodies the contract names:
src/main/java/learning/java/todos/controllers/ApiErrors.java
package learning.java.todos.controllers; import java.util.Map;import org.springframework.http.HttpStatus;import org.springframework.http.converter.HttpMessageNotReadableException;import org.springframework.web.bind.MethodArgumentNotValidException;import org.springframework.web.bind.annotation.ExceptionHandler;import org.springframework.web.bind.annotation.ResponseStatus;import org.springframework.web.bind.annotation.RestControllerAdvice;import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; @RestControllerAdvicepublic class ApiErrors { @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public Map<String, String> invalid(MethodArgumentNotValidException e) { return Map.of("error", "subject and details are required"); } @ExceptionHandler(HttpMessageNotReadableException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public Map<String, String> unreadable(HttpMessageNotReadableException e) { return Map.of("error", "invalid json"); } @ExceptionHandler(MethodArgumentTypeMismatchException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public Map<String, String> badId(MethodArgumentTypeMismatchException e) { return Map.of("error", "not found"); }}MethodArgumentNotValidException→ 400, a blank or missing fieldHttpMessageNotReadableException→ 400, the body was not JSON (or was empty)MethodArgumentTypeMismatchException→ 404, an id like/todos/abcthat cannot become aLong
Health check and CORS#
src/main/java/learning/java/todos/controllers/HealthController.java
package learning.java.todos.controllers; import java.util.Map;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController; @RestControllerpublic class HealthController { @GetMapping("/health") public Map<String, Boolean> health() { return Map.of("ok", true); }}Browsers only let a page on one origin call an API on another when the API says so. Instead of @CrossOrigin on each controller, one WebMvcConfigurer covers every route, preflight OPTIONS requests included:
src/main/java/learning/java/todos/config/CorsConfig.java
package learning.java.todos.config; import org.springframework.context.annotation.Configuration;import org.springframework.web.servlet.config.annotation.CorsRegistry;import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configurationpublic class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("Content-Type"); }}allowedOrigins("*") with Content-Type as the only allowed header is enough for every frontend guide on this site; credentials (cookies) stay off. The repo also carries an optional src/main/resources/static/index.html, which Spring serves at /.
Testing the API#
- Start the server:
./mvnw spring-boot:run - Create 3 todos with POST requests to
/todos(JSON body,Content-Type: application/jsonheader) — expect 201 - See the todos with a GET request to
/todos, fetch one with/todos/:id, update one with PUT - Delete a todo with a DELETE request to
/todos/:id— expect 204 and no body - Try a made-up id — expect 404; POST a blank subject — expect 400
The same run in curl (-i prints the status line):
curl -i localhost:8080/health # 200 {"ok":true}curl -i -X POST localhost:8080/todos -H "Content-Type: application/json" \ -d '{"subject": "Learn Spring", "details": "controllers, entities, repositories"}' # 201curl -i localhost:8080/todos # 200 [...]curl -i localhost:8080/todos/1 # 200curl -i -X PUT localhost:8080/todos/1 -H "Content-Type: application/json" \ -d '{"subject": "Learn Spring Boot 4", "details": "and deploy it"}' # 200curl -i -X POST localhost:8080/todos -H "Content-Type: application/json" \ -d '{"subject": " "}' # 400 {"error":"subject and details are required"}curl -i -X DELETE localhost:8080/todos/1 # 204, no bodycurl -i localhost:8080/todos/1 # 404 {"error":"not found"}Checking CORS without a frontend: send a preflight and look for Access-Control-Allow-Origin: * and Access-Control-Allow-Methods in the response:
curl -i -X OPTIONS localhost:8080/todos -H "Origin: http://localhost:5173" \ -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: Content-Type"Run the contract check against your own deploy: node check.mjs https://your-api.up.railway.app (copy check.mjs from the example repo; Node 18 or later, no packages). All 16 lines should say PASS. It works against http://localhost:8080 too.
Deploying the API#
Any host that can build a Docker image works; the free-to-start choice here is Railway, which builds the repo's Dockerfile and provides Postgres in the same project. The same Dockerfile deploys unchanged on Render or Fly.io.
- Add this
Dockerfileat the project root. The first stage builds the jar with Maven on a JDK 25 image; the second copies only the jar onto a JRE image:
Dockerfile
FROM maven:3.9-eclipse-temurin-25 AS buildWORKDIR /srcCOPY pom.xml .COPY src srcRUN mvn -q -DskipTests package FROM eclipse-temurin:25-jreWORKDIR /appCOPY --from=build /src/target/todos-0.0.1.jar app.jarEXPOSE 8080CMD ["java", "-jar", "app.jar"]Commit and push the project to a GitHub repository. The
.gitignoreexcludestarget/,.ideaand.env.On Railway, create a New Project → Deploy from GitHub repo and pick the repository. Railway finds the
Dockerfileand uses it instead of its own builder; the images pin the toolchain (maven:3.9-eclipse-temurin-25to build,eclipse-temurin:25-jreto run).Add a database: + New → Database → PostgreSQL. The new service exposes
PGHOST,PGPORT,PGUSER,PGPASSWORD,PGDATABASEandDATABASE_URL.Hand those to the API with reference variables so you never paste a password. In the API service's Variables tab add:
PGHOST=${{Postgres.PGHOST}}PGPORT=${{Postgres.PGPORT}}PGDATABASE=${{Postgres.PGDATABASE}}PGUSER=${{Postgres.PGUSER}}PGPASSWORD=${{Postgres.PGPASSWORD}} (Postgres is the database service's name; the dashboard autocompletes it.) application.properties reads exactly these names, so no code changes are needed. Railway's DATABASE_URL is not a JDBC URL, which is why we assemble jdbc:postgresql://host:port/db from the parts. Railway sets PORT on its own and server.port=${PORT:8080} picks it up.
There is no migrate step: on the first boot Hibernate's
ddl-auto=updatecreates thetodotable in the Railway database.Under the API service's Settings → Networking, click Generate Domain to get a public
https://….up.railway.appURL. Under Settings → Deploy, set the health check path to/health; Railway only switches traffic to a new deploy once that returns 2xx.Railway redeploys on every push. Watch Deploy Logs for the Spring banner and
Tomcat started on port …, then runnode check.mjs https://<your-domain>— and you're ready for the frontend.
WHY * is fine for now: CORS open to any origin is what lets every frontend guide on this site talk to this API during the lesson. In a real product narrow allowedOrigins in CorsConfig to the frontend's exact origin before launch.
The deploy this page was verified against ran Java 25.0.5, Spring Boot 4.1.2, Hibernate ORM 7.4.13 and Tomcat 11.0.27 against PostgreSQL 18.6.
Resources to Learn More#
- CORS in Spring MVC —
@CrossOrigin, global configuration, credentials - Spring Boot: externalized configuration — property precedence and
${VAR:default} - Validation and controller advice —
@NotBlank,@Validand turning exceptions into responses - Deploying to the cloud — containers,
PORTandserver.port - Railway: Dockerfiles, PostgreSQL, variables and healthchecks
See also