When you decide to “build a REST API” with Spring Boot, it’s easy to get stuck on how to combine the three layers — Controller, Service, and Repository. You may have looked up the individual annotations and understood them, but the picture of how they all connect into a working implementation might still be fuzzy.
In this article, we’ll use a simple entity called Item to build four endpoints (GET/POST/PUT/DELETE) end-to-end in a three-layer architecture. By the time you finish reading, you should be ready to apply the same structure to your own projects.
What we’ll build
Here’s the final list of endpoints.
| Method | Path | Description |
|---|---|---|
| GET | /items | Get all items |
| GET | /items/{id} | Get one item |
| POST | /items | Create new |
| PUT | /items/{id} | Update |
| DELETE | /items/{id} | Delete |
The package structure looks like this.
src/main/java/com/example/demo/
├── controller/
│ └── ItemController.java
├── service/
│ └── ItemService.java
├── repository/
│ └── ItemRepository.java
└── entity/
└── Item.java
Project setup
Create the project with Spring Initializr, selecting Spring Web, Spring Data JPA, and H2 Database. The following dependencies will be added to your pom.xml.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
Add the H2 configuration to application.properties. Since it’s an in-memory DB, it’s perfect for verifying behavior during development.
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.h2.console.enabled=true
spring.jpa.show-sql=true
Clarifying the responsibilities of each layer
Before diving into the implementation, let’s clearly define the role of each layer.
- Controller — The entry point that receives HTTP requests and returns responses. No business logic should be written here.
- Service — Where business logic is implemented. It calls the Repository to perform data operations.
- Repository — Abstraction over DB access. Spring Data JPA automatically generates the implementation.
The dependency direction flows one way: Controller → Service → Repository. If the Controller uses the Repository directly, or if business logic gets mixed into the Controller, future changes become painful.
Defining the entity class
package com.example.demo.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "items")
public class Item {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
private String description;
// getters / setters omitted (Lombok's @Data also works)
}
@Entity makes it a JPA-managed class, and @Id along with @GeneratedValue configures auto-numbering for the ID.
Defining the Repository
Just extend JpaRepository.
package com.example.demo.repository;
import com.example.demo.entity.Item;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ItemRepository extends JpaRepository<Item, Long> {
}
With just this, you can use findAll(), findById(), save(), and deleteById(). If you need custom search conditions, see the Spring Data JPA query methods article.
Implementing the Service class
package com.example.demo.service;
import com.example.demo.entity.Item;
import com.example.demo.repository.ItemRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ItemService {
private final ItemRepository itemRepository;
public ItemService(ItemRepository itemRepository) {
this.itemRepository = itemRepository;
}
public List<Item> findAll() {
return itemRepository.findAll();
}
public Item findById(Long id) {
return itemRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Item not found: " + id));
}
public Item create(Item item) {
return itemRepository.save(item);
}
public Item update(Long id, Item item) {
Item existing = findById(id);
existing.setName(item.getName());
existing.setDescription(item.getDescription());
return itemRepository.save(existing);
}
public void delete(Long id) {
itemRepository.deleteById(id);
}
}
Constructor injection is the officially recommended approach by Spring. It makes tests easier to write than attaching @Autowired to fields.
For the places where RuntimeException is thrown, in real-world projects it’s common to centralize handling with a custom exception and @ControllerAdvice as introduced in the exception handling article.
Implementing the Controller
If you want to customize things like date formats in the response JSON, null exclusion, or snake_case conversion, see Customizing JSON serialization with Spring Boot’s Jackson configuration.
package com.example.demo.controller;
import com.example.demo.entity.Item;
import com.example.demo.service.ItemService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/items")
public class ItemController {
private final ItemService itemService;
public ItemController(ItemService itemService) {
this.itemService = itemService;
}
@GetMapping
public ResponseEntity<List<Item>> findAll() {
return ResponseEntity.ok(itemService.findAll());
}
@GetMapping("/{id}")
public ResponseEntity<Item> findById(@PathVariable Long id) {
return ResponseEntity.ok(itemService.findById(id));
}
@PostMapping
public ResponseEntity<Item> create(@RequestBody Item item) {
return ResponseEntity.status(HttpStatus.CREATED).body(itemService.create(item));
}
@PutMapping("/{id}")
public ResponseEntity<Item> update(@PathVariable Long id, @RequestBody Item item) {
return ResponseEntity.ok(itemService.update(id, item));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
itemService.delete(id);
return ResponseEntity.noContent().build();
}
}
The key point is how HTTP status codes are used.
- POST →
201 Created(a new resource was created) - DELETE →
204 No Content(succeeded but no response body) - Successful retrieval or update →
200 OK
@RequestBody converts the request JSON into an object, and @PathVariable receives the {id} from the URL.
If you want to add input validation, simply add @Valid before @RequestBody and attach annotations like @NotBlank to the entity. For details, see the validation article.
Verifying with curl
Start the app with mvn spring-boot:run and verify the behavior using curl.
# Create data (returns 201 Created)
curl -X POST http://localhost:8080/items \
-H "Content-Type: application/json" \
-d '{"name":"Test Item","description":"Description"}'
# Get all
curl http://localhost:8080/items
# Get one
curl http://localhost:8080/items/1
# Update
curl -X PUT http://localhost:8080/items/1 \
-H "Content-Type: application/json" \
-d '{"name":"Updated Item","description":"Updated"}'
# Delete (returns 204 No Content)
curl -X DELETE http://localhost:8080/items/1
The H2 console can be accessed from http://localhost:8080/h2-console. For the JDBC URL, enter jdbc:h2:mem:testdb.
Next steps
Frequently Asked Questions (FAQ)
What’s the difference between Controller, Service, and Repository?
The Controller handles HTTP request/response input and output, the Service handles business logic, and the Repository handles DB access. The dependency direction flows one way as Controller → Service → Repository. As a principle, the Controller shouldn’t call the Repository directly, and HTTP-specific processing shouldn’t be written in the Service.
Is it OK to return Entities directly from the Controller?
For small CRUD samples, returning Entities directly as responses works fine, but in production it’s strongly recommended to separate DTOs (Request/Response). Returning Entities directly locks the internal schema to the API spec, and can cause LazyInitializationException due to JPA’s lazy loading.
What are the minimum features a CRUD API should have?
Once the CRUD in this article is working, we recommend implementing the following in order: 1) input validation with @Valid, 2) centralized exception handling with @ControllerAdvice, 3) DTO separation, 4) Controller tests with MockMvc, 5) pagination. Adding them in this order lets you gradually grow it into a production-ready API.
What’s the correct way to respond when findById doesn’t find anything?
The convention is to return HTTP status 404 Not Found. Leaving it as the RuntimeException in this article would result in a 500, so the standard pattern is to define a dedicated exception (e.g., ItemNotFoundException) and map it to 404 with @RestControllerAdvice.
Are there ways to verify behavior other than Postman or curl?
IntelliJ IDEA’s .http files, the REST Client extension for VS Code, and browser extensions like Thunder Client are all convenient. You can also use @SpringBootTest + MockMvc to automate the same operations in code, which doubles as regression prevention.
- Production-quality exception handling → Implementing a production-grade GlobalExceptionHandler goes deeper into traceID assignment and ProblemDetail extensions
- Custom validation → For business-specific checks, see How to create custom validation annotations to package them as reusable annotations
- Calling external APIs → If you need to call your API from another service, see Choosing between RestTemplate and WebClient
Once basic CRUD is working, let’s add the following to get closer to production-grade.
- Centralized exception handling → Check out how to use
@ControllerAdvicein the exception handling article - Pagination → For a design that scales as data grows, see the pagination article
- Entity relationships → For handling multiple tables, see the JPA relationship mapping article
- Auto-generating API specs → It’s also a good idea to introduce Swagger UI via the OpenAPI/Swagger article
Summary
We’ve implemented a full CRUD API in a three-layer architecture. Separating the responsibilities of each layer makes it clear where changes need to go when you later add exception handling or validation. Try this structure in your own project first, and gradually flesh it out.