The “right way” to build a REST API in Java with Spring Boot is not just about making URLs return JSON. A good REST API should be:
- Cleanly structured
- Easy to test
- Easy to maintain
- Validated properly
- Consistent in error handling
- Separated into controller, service, repository, entity, and DTO layers
- Built around HTTP semantics, not just Java methods exposed over HTTP
In this article, we will build a simple User REST API using:
- Spring Boot
- Spring MVC
- Spring Data JPA
- Jakarta Persistence
- Jakarta Validation
- Java records
- Java 25
- Lombok
The example API will support basic user operations:
GET /api/users
GET /api/users/{id}
POST /api/users
PUT /api/users/{id}
DELETE /api/users/{id}
1. Create a Spring Boot Project
You can create a Spring Boot project from Spring Initializr with these dependencies:
- Spring Web
- Spring Data JPA
- Validation
- PostgreSQL Driver, MySQL Driver, or H2 Database
- Lombok
For Maven, the important dependencies look like this:
<dependencies>
<!-- REST API support -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Data JPA and Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Jakarta Bean Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Example database: PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
If you only want an in-memory database while learning, you can use H2 instead:
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
2. Use a Clean Project Structure
A common clean structure is:
com.example.demo
├── DemoApplication.java
├── user
│ ├── User.java
│ ├── UserRepository.java
│ ├── UserService.java
│ ├── UserController.java
│ ├── CreateUserRequest.java
│ ├── UpdateUserRequest.java
│ └── UserResponse.java
└── exception
├── ApiError.java
├── ResourceNotFoundException.java
└── GlobalExceptionHandler.java
This is a feature-based structure. Instead of separating everything by technical layer only, all user-related classes stay together.
For small applications, this is easy to understand. For larger applications, it also scales well because each feature remains self-contained.
3. Create the Main Spring Boot Application Class
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
Keep this class in the root package, such as:
com.example.demo
That allows Spring Boot to automatically scan subpackages such as:
com.example.demo.user
com.example.demo.exception
4. Configure the Database
For PostgreSQL, create:
src/main/resources/application.properties
Example:
spring.datasource.url=jdbc:postgresql://localhost:5432/demo
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
For local learning, ddl-auto=update is convenient.
For production, prefer:
spring.jpa.hibernate.ddl-auto=validate
Then manage schema changes using a migration tool such as Flyway or Liquibase.
5. Create the Entity
The entity represents the database table.
package com.example.demo.user;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;
@Entity
@Getter
@Setter
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
}
Notice the import:
import jakarta.persistence.Entity;
Modern Spring Boot uses Jakarta EE packages, not the old javax.persistence packages.
6. Create DTOs for Requests and Responses
A common mistake is exposing entities directly from controllers.
For small demos, returning entities may seem fine. But in real applications, it is better to use DTOs because they separate your API contract from your database model.
Create User Request
package com.example.demo.user;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreateUserRequest(
@NotBlank(message = "Name is required")
@Size(max = 100, message = "Name must not exceed 100 characters")
String name,
@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
@Size(max = 150, message = "Email must not exceed 150 characters")
String email
) {
}
Update User Request
package com.example.demo.user;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record UpdateUserRequest(
@NotBlank(message = "Name is required")
@Size(max = 100, message = "Name must not exceed 100 characters")
String name,
@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
@Size(max = 150, message = "Email must not exceed 150 characters")
String email
) {
}
User Response
package com.example.demo.user;
public record UserResponse(
Long id,
String name,
String email
) {
}
Java records are excellent for DTOs because they are concise and immutable by default.
7. Create the Repository
Spring Data JPA provides most CRUD operations automatically.
package com.example.demo.user;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
}
By extending JpaRepository<User, Long>, you automatically get methods such as:
findAll()
findById(id)
save(entity)
delete(entity)
deleteById(id)
existsById(id)
You do not need to write SQL for basic CRUD operations.
8. Create a Custom Not Found Exception
Instead of returning null or manually building error responses everywhere, create a reusable exception.
package com.example.demo.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
We will handle this exception globally later.
9. Create the Service Layer
The service layer contains business logic and transaction boundaries.
package com.example.demo.user;
import com.example.demo.exception.ResourceNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Transactional(readOnly = true)
public List<UserResponse> findAll() {
return userRepository.findAll()
.stream()
.map(this::toResponse)
.toList();
}
@Transactional(readOnly = true)
public UserResponse findById(Long id) {
User user = findUserById(id);
return toResponse(user);
}
@Transactional
public UserResponse create(CreateUserRequest request) {
if (userRepository.existsByEmail(request.email())) {
throw new IllegalArgumentException("Email is already used");
}
User user = new User();
user.setName(request.name());
user.setEmail(request.email());
User savedUser = userRepository.save(user);
return toResponse(savedUser);
}
@Transactional
public UserResponse update(Long id, UpdateUserRequest request) {
User user = findUserById(id);
user.setName(request.name());
user.setEmail(request.email());
return toResponse(user);
}
@Transactional
public void delete(Long id) {
User user = findUserById(id);
userRepository.delete(user);
}
private User findUserById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException(
"User with id " + id + " was not found"
));
}
private UserResponse toResponse(User user) {
return new UserResponse(
user.getId(),
user.getName(),
user.getEmail()
);
}
}
A few important things are happening here:
- The controller will not access the repository directly.
- Read methods use
@Transactional(readOnly = true).
- Write methods use
@Transactional.
- The service maps entities to response DTOs.
- Missing users throw a meaningful exception.
This keeps the controller thin and the business logic centralized.
10. Create the REST Controller
The controller handles HTTP details: URLs, request bodies, response status codes, and validation.
package com.example.demo.user;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public List<UserResponse> findAll() {
return userService.findAll();
}
@GetMapping("/{id}")
public UserResponse findById(@PathVariable Long id) {
return userService.findById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserResponse create(@Valid @RequestBody CreateUserRequest request) {
return userService.create(request);
}
@PutMapping("/{id}")
public UserResponse update(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request
) {
return userService.update(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
userService.delete(id);
}
}
The controller is intentionally small.
It does not:
- Contain database logic
- Build SQL queries
- Manage transactions
- Know how users are persisted
- Contain complicated business rules
Its job is HTTP handling.
11. Understand REST Endpoint Design
Good REST URLs usually identify resources using nouns.
Good:
GET /api/users
GET /api/users/10
POST /api/users
PUT /api/users/10
DELETE /api/users/10
Less ideal:
GET /api/getUsers
POST /api/createUser
POST /api/deleteUser
The HTTP method already describes the action.
| HTTP Method |
Meaning |
Example |
GET |
Read data |
GET /api/users |
POST |
Create new data |
POST /api/users |
PUT |
Replace or update data |
PUT /api/users/1 |
PATCH |
Partially update data |
PATCH /api/users/1 |
DELETE |
Delete data |
DELETE /api/users/1 |
12. Add Global Exception Handling
A good API should return consistent error responses.
Create an API error response:
package com.example.demo.exception;
import java.time.Instant;
import java.util.List;
public record ApiError(
int status,
String error,
String message,
String path,
Instant timestamp,
List<FieldErrorDetail> fieldErrors
) {
public ApiError(
int status,
String error,
String message,
String path
) {
this(status, error, message, path, Instant.now(), List.of());
}
public ApiError(
int status,
String error,
String message,
String path,
List<FieldErrorDetail> fieldErrors
) {
this(status, error, message, path, Instant.now(), fieldErrors);
}
public record FieldErrorDetail(
String field,
String message
) {
}
}
Now create the global exception handler:
package com.example.demo.exception;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ApiError handleResourceNotFoundException(
ResourceNotFoundException ex,
HttpServletRequest request
) {
return new ApiError(
HttpStatus.NOT_FOUND.value(),
HttpStatus.NOT_FOUND.getReasonPhrase(),
ex.getMessage(),
request.getRequestURI()
);
}
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiError handleIllegalArgumentException(
IllegalArgumentException ex,
HttpServletRequest request
) {
return new ApiError(
HttpStatus.BAD_REQUEST.value(),
HttpStatus.BAD_REQUEST.getReasonPhrase(),
ex.getMessage(),
request.getRequestURI()
);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiError handleValidationException(
MethodArgumentNotValidException ex,
HttpServletRequest request
) {
List<ApiError.FieldErrorDetail> fieldErrors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> new ApiError.FieldErrorDetail(
error.getField(),
error.getDefaultMessage()
))
.toList();
return new ApiError(
HttpStatus.BAD_REQUEST.value(),
HttpStatus.BAD_REQUEST.getReasonPhrase(),
"Validation failed",
request.getRequestURI(),
fieldErrors
);
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiError handleException(
Exception ex,
HttpServletRequest request
) {
return new ApiError(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(),
"An unexpected error occurred",
request.getRequestURI()
);
}
}
Now, when something fails, your API returns structured JSON instead of a stack trace or inconsistent response.
Example validation error:
{
"status": 400,
"error": "Bad Request",
"message": "Validation failed",
"path": "/api/users",
"timestamp": "2026-07-06T10:15:30Z",
"fieldErrors": [
{
"field": "email",
"message": "Email must be valid"
}
]
}
13. Test the API with HTTP Requests
You can use curl, Postman, HTTPie, or IntelliJ IDEA HTTP Client.
Create a User
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"name":"Alice","email":"[email protected]"}'
Expected response:
{
"id": 1,
"name": "Alice",
"email": "[email protected]"
}
HTTP status:
201 Created
Get All Users
curl http://localhost:8080/api/users
Example response:
[
{
"id": 1,
"name": "Alice",
"email": "[email protected]"
}
]
Get One User
curl http://localhost:8080/api/users/1
Example response:
{
"id": 1,
"name": "Alice",
"email": "[email protected]"
}
Update a User
curl -X PUT http://localhost:8080/api/users/1 \
-H "Content-Type: application/json" \
-d '{"name":"Alice Smith","email":"[email protected]"}'
Example response:
{
"id": 1,
"name": "Alice Smith",
"email": "[email protected]"
}
Delete a User
curl -X DELETE http://localhost:8080/api/users/1
Expected status:
204 No Content
14. Add Basic Controller Tests
Testing your controller helps ensure the API contract works as expected.
Here is an example using @WebMvcTest and MockMvc.
package com.example.demo.user;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.hamcrest.Matchers.hasSize;
import static org.mockito.ArgumentMatchers.any;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockitoBean
private UserService userService;
@Test
void shouldReturnUsers() throws Exception {
Mockito.when(userService.findAll())
.thenReturn(List.of(
new UserResponse(1L, "Alice", "[email protected]"),
new UserResponse(2L, "Bob", "[email protected]")
));
mockMvc.perform(get("/api/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(2)))
.andExpect(jsonPath("$[0].name").value("Alice"))
.andExpect(jsonPath("$[1].name").value("Bob"));
}
@Test
void shouldCreateUser() throws Exception {
CreateUserRequest request = new CreateUserRequest(
"Alice",
"[email protected]"
);
Mockito.when(userService.create(any(CreateUserRequest.class)))
.thenReturn(new UserResponse(1L, "Alice", "[email protected]"));
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("Alice"))
.andExpect(jsonPath("$.email").value("[email protected]"));
}
@Test
void shouldRejectInvalidCreateUserRequest() throws Exception {
CreateUserRequest request = new CreateUserRequest(
"",
"invalid-email"
);
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
}
Testing at this level verifies:
- URL mappings
- HTTP status codes
- JSON request/response structure
- Validation behavior
- Controller-service interaction
15. Common REST API Best Practices
Use DTOs Instead of Exposing Entities
Avoid this in real APIs:
@GetMapping("/{id}")
public User findById(@PathVariable Long id) {
return userRepository.findById(id).orElseThrow();
}
Prefer this:
@GetMapping("/{id}")
public UserResponse findById(@PathVariable Long id) {
return userService.findById(id);
}
DTOs give you control over what your API exposes.
Keep Controllers Thin
A controller should mostly do this:
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserResponse create(@Valid @RequestBody CreateUserRequest request) {
return userService.create(request);
}
Avoid putting business logic directly in the controller.
Put Transactions in Services
Use:
@Transactional
public UserResponse create(CreateUserRequest request) {
// business operation
}
Avoid placing @Transactional on controller methods in most applications.
Use Validation on Request DTOs
Use Jakarta Validation annotations:
public record CreateUserRequest(
@NotBlank String name,
@Email @NotBlank String email
) {
}
Then activate validation in the controller:
public UserResponse create(@Valid @RequestBody CreateUserRequest request) {
return userService.create(request);
}
Return Correct HTTP Status Codes
Use meaningful status codes:
| Situation |
Status Code |
| Successful read |
200 OK |
| Successful creation |
201 Created |
| Successful delete |
204 No Content |
| Invalid request |
400 Bad Request |
| Unauthorized |
401 Unauthorized |
| Forbidden |
403 Forbidden |
| Resource not found |
404 Not Found |
| Conflict |
409 Conflict |
| Server error |
500 Internal Server Error |
Use Plural Resource Names
Prefer:
/api/users
/api/orders
/api/products
Instead of:
/api/user
/api/order
/api/product
Use Query Parameters for Filtering
Example:
GET /api/[email protected]
GET /api/users?name=alice
Path variables are usually better for identifying a specific resource:
GET /api/users/1
Query parameters are usually better for searching, filtering, sorting, and pagination.
16. Add Pagination for Collection Endpoints
Returning all records may work during development, but it can become a problem when your table grows.
Spring Data supports pagination using Pageable.
Repository already supports it because JpaRepository includes paging methods.
Update the service:
package com.example.demo.user;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
// imports omitted
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Transactional(readOnly = true)
public Page<UserResponse> findAll(Pageable pageable) {
return userRepository.findAll(pageable)
.map(this::toResponse);
}
private UserResponse toResponse(User user) {
return new UserResponse(
user.getId(),
user.getName(),
user.getEmail()
);
}
}
Update the controller:
package com.example.demo.user;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.*;
// imports omitted
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public Page<UserResponse> findAll(Pageable pageable) {
return userService.findAll(pageable);
}
}
Now you can call:
GET /api/users?page=0&size=10
With sorting:
GET /api/users?page=0&size=10&sort=name,asc
17. A Better Response for Created Resources
For POST, you can return 201 Created with a Location header.
package com.example.demo.user;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@PostMapping
public ResponseEntity<UserResponse> create(
@Valid @RequestBody CreateUserRequest request,
UriComponentsBuilder uriBuilder
) {
UserResponse response = userService.create(request);
URI location = uriBuilder
.path("/api/users/{id}")
.buildAndExpand(response.id())
.toUri();
return ResponseEntity
.created(location)
.body(response);
}
}
This produces a response like:
HTTP/1.1 201 Created
Location: http://localhost:8080/api/users/1
This is a nice RESTful touch because the response tells the client where the new resource can be found.
18. Recommended Request Flow
A clean REST API usually follows this flow:
HTTP Request
↓
Controller
↓
Service
↓
Repository
↓
Database
And back:
Database
↓
Repository
↓
Service
↓
Controller
↓
HTTP Response
Each layer has a clear job:
| Layer |
Responsibility |
| Controller |
Handles HTTP requests and responses |
| Service |
Contains business logic and transactions |
| Repository |
Handles database access |
| Entity |
Maps Java objects to database tables |
| DTO |
Defines API request and response shapes |
| Exception Handler |
Produces consistent error responses |
19. What Makes It “The Right Way”?
A Spring Boot REST API is built the right way when it follows these principles:
- Use
@RestController for REST endpoints
- Keep controllers thin
- Put business logic in services
- Use repositories only for data access
- Use DTOs at the API boundary
- Validate request bodies with Jakarta Validation
- Handle exceptions globally
- Return meaningful HTTP status codes
- Use transactions in the service layer
- Avoid exposing JPA entities directly
- Use pagination for collection endpoints
- Keep package structure clean
- Use Jakarta imports in modern Spring Boot applications
Complete Minimal Example
Here is the core structure again.
com.example.demo
├── DemoApplication.java
├── user
│ ├── User.java
│ ├── UserRepository.java
│ ├── UserService.java
│ ├── UserController.java
│ ├── CreateUserRequest.java
│ ├── UpdateUserRequest.java
│ └── UserResponse.java
└── exception
├── ApiError.java
├── ResourceNotFoundException.java
└── GlobalExceptionHandler.java
That gives you a clean, maintainable foundation for a real REST API.
Summary
To build a REST API in Java using Spring Boot the right way:
- Use Spring Web for REST controllers.
- Use Spring Data JPA for persistence.
- Use Jakarta Validation for request validation.
- Use DTOs instead of exposing entities.
- Keep your controller thin.
- Put business logic and transactions in the service layer.
- Use a repository for database access.
- Use global exception handling for consistent error responses.
- Return correct HTTP status codes such as
200, 201, 204, 400, and 404.
- Add pagination before your API grows too large.
The clean pattern is:
Controller → Service → Repository → Database
With DTOs at the API boundary and entities at the persistence boundary, your Spring Boot REST API will be easier to maintain, test, and evolve.