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:

  • 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:

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.