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:

MethodPathDescription
GET/itemsRetrieve all items
GET/items/{id}Retrieve a single item
POST/itemsCreate 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:

  • POST201 Created (a new resource was created)
  • DELETE204 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:

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.