When building a REST API with Spring Boot, it’s easy to feel lost about how to connect the three layers — Controller, Service, and Repository. You might understand each annotation individually, but still have a fuzzy picture of how they all fit together.
In this article, we’ll build all four endpoints — GET, POST, PUT, and DELETE — using a simple Item entity as our example, walking through the full three-layer structure from start to finish. By the end, you’ll be ready to apply the same pattern to your own projects.
What We’re Building
Here’s the complete list of endpoints we’ll implement:
| Method | Path | Description |
|---|---|---|
| GET | /items | Retrieve all items |
| GET | /items/{id} | Retrieve a single item |
| POST | /items | Create a new item |
| PUT | /items/{id} | Update an item |
| DELETE | /items/{id} | Delete an item |
The package structure will be organized as follows:
src/main/java/com/example/demo/
├── controller/
│ └── ItemController.java
├── service/
│ └── ItemService.java
├── repository/
│ └── ItemRepository.java
└── entity/
└── Item.java
Project Setup
Go to Spring Initializr and create a project with 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 database, 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
Responsibilities of Each Layer
Before diving into the implementation, let’s clarify the role of each layer:
- Controller — The entry point that receives HTTP requests and returns responses. No business logic here.
- Service — Where business logic lives. Calls the Repository to perform data operations.
- Repository — Abstracts database access. Spring Data JPA auto-generates the implementation.
Dependencies flow in one direction: Controller → Service → Repository. If a Controller directly accesses the Repository, or business logic leaks into the Controller, changes become painful down the road.
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 works too)
}
@Entity registers the class with JPA, and @Id with @GeneratedValue configures auto-incrementing IDs.
Defining the Repository
Simply 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> {
}
This alone gives you findAll(), findById(), save(), and deleteById(). If you need custom query conditions, refer to 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 approach recommended by Spring. It makes tests easier to write compared to using @Autowired on fields.
The places where RuntimeException is thrown should, in production code, be replaced with custom exceptions and centralized using @ControllerAdvice, as covered in the exception handling article.
Implementing the Controller
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 choice of HTTP status codes is important:
- POST →
201 Created(a new resource was created) - DELETE →
204 No Content(success, but no response body) - Successful reads and updates →
200 OK
@RequestBody deserializes the request JSON into an object, and @PathVariable captures {id} from the URL.
To add input validation, just add @Valid before @RequestBody and annotate your entity fields with constraints like @NotBlank. See the validation article for details.
Testing with curl
Start the app with mvn spring-boot:run and verify behavior using curl:
# Create data (returns 201 Created)
curl -X POST http://localhost:8080/items \
-H "Content-Type: application/json" \
-d '{"name":"テスト商品","description":"説明文"}'
# Retrieve all items
curl http://localhost:8080/items
# Retrieve a single item
curl http://localhost:8080/items/1
# Update an item
curl -X PUT http://localhost:8080/items/1 \
-H "Content-Type: application/json" \
-d '{"name":"更新後の商品","description":"更新済み"}'
# Delete an item (returns 204 No Content)
curl -X DELETE http://localhost:8080/items/1
The H2 console is accessible at http://localhost:8080/h2-console. Enter jdbc:h2:mem:testdb as the JDBC URL.
Next Steps
Now that you have basic CRUD working, here are some additions to bring it closer to production quality:
- Centralized exception handling → See the exception handling article for how to use
@ControllerAdvice - Pagination → For designs that scale as data grows, see the pagination article
- Entity relationships → For working with multiple tables, see the JPA entity relationship mapping article
- Auto-generated API documentation → Consider adding Swagger UI as covered in the OpenAPI/Swagger article
Summary
We’ve implemented a complete CRUD API using a three-layer architecture. Keeping the responsibilities of each layer separate makes it straightforward to add exception handling and validation later — you always know exactly where to make changes. Start by applying this structure to your own project and build on it from there.