When you set out to build a REST API with Spring Boot, it’s easy to get stuck on how the three layers—Controller, Service, and Repository—fit together. Many developers have looked up each annotation individually but still have only a vague picture of how a complete, end-to-end implementation looks.
In this article, we’ll use a simple Item entity as our example and build four endpoints (GET/POST/PUT/DELETE) in a three-layer architecture from start to finish. By the end, the goal is for you to be able to apply the same structure to your own projects.
What We’ll Build
Here is the complete list of endpoints we’ll end up with.
| 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 is organized as follows.
src/main/java/com/example/demo/
├── controller/
│ └── ItemController.java
├── service/
│ └── ItemService.java
├── repository/
│ └── ItemRepository.java
└── entity/
└── Item.java
Project Setup
Create a project on Spring Initializr and select 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 ideal 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 the Three Layers
Before diving into the implementation, let’s make the role of each layer clear.
- Controller is the entry point that receives HTTP requests and returns responses. It contains no business logic
- Service is where business logic lives. It calls the Repository to perform data operations
- Repository abstracts database access. Spring Data JPA generates the implementation automatically
Dependencies flow in one direction: Controller → Service → Repository. If the Controller uses the Repository directly, or business logic ends up mixed into the Controller, later 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;
// getter / setter は省略(Lombokの @Data でも可)
}
@Entity makes the class managed by JPA, and @Id together with @GeneratedValue configures automatic ID generation.
Defining the Repository
All you need to do is 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, findAll(), findById(), save(), and deleteById() are available. When 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 approach officially recommended by Spring. It makes tests easier to write than annotating fields with @Autowired.
In real-world projects, the places where RuntimeException is thrown are typically handled centrally with custom exceptions and @ControllerAdvice, as introduced in the exception handling article. By convention, when findById finds nothing, the HTTP response should be 404 Not Found, but leaving it as a RuntimeException results in a 500. The standard pattern is to define a dedicated exception such as ItemNotFoundException and map it to 404 with @RestControllerAdvice.
Implementing the Controller
If you want to customize the response JSON, such as date formats, excluding nulls, or converting to snake_case, 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 choosing the right HTTP status code for each operation.
- 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 captures the {id} from the URL.
To add input validation, simply place @Valid before @RequestBody and add annotations such as @NotBlank to the entity. For details, see the validation article.
Note that this article uses the entity directly in responses, but in production, we strongly recommend separating DTOs (Request/Response). Returning entities directly locks your internal schema into the API contract, and JPA’s lazy loading can also cause LazyInitializationException.
Verifying with curl
Start the application with mvn spring-boot:run and verify it works using curl.
# データ作成(201 Created が返る)
curl -X POST http://localhost:8080/items \
-H "Content-Type: application/json" \
-d '{"name":"テスト商品","description":"説明文"}'
# 全件取得
curl http://localhost:8080/items
# 1件取得
curl http://localhost:8080/items/1
# 更新
curl -X PUT http://localhost:8080/items/1 \
-H "Content-Type: application/json" \
-d '{"name":"更新後の商品","description":"更新済み"}'
# 削除(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.
Beyond curl, IntelliJ IDEA’s .http files, the VS Code REST Client extension, and Thunder Client are also convenient options. Going further, @SpringBootTest + MockMvc lets you automate the same operations as tests in code, which also helps prevent regressions.
Next Steps
Once the CRUD from this article is working, we recommend adding the following in order: 1) input validation with @Valid, 2) centralized exception handling with @ControllerAdvice, 3) DTO separation, 4) Controller tests with MockMvc, and 5) pagination. This lets you grow the API step by step into something production-ready.
- Production-grade exception handling → Implementing a GlobalExceptionHandler for Production goes deeper into adding trace IDs and extending ProblemDetail
- Custom validation → Turn domain-specific checks into reusable annotations with How to Create Custom Validation Annotations
- Calling external APIs → If you need to call the API you built from another service, see Choosing Between RestTemplate and WebClient
- Centralized exception handling → Check how to use
@ControllerAdvicein the exception handling article - Pagination → For a design that holds up as data grows, see the pagination article
- Entity relationships → For working with multiple tables, see the JPA relationship mapping article
- Auto-generating API documentation → Introducing Swagger UI via the OpenAPI/Swagger article is also recommended
Summary
We’ve implemented a complete CRUD API using a three-layer architecture. Keeping each layer’s responsibilities separate makes it clear where changes go when you later add exception handling or validation. Try this structure in your own project first, then flesh it out little by little.