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.

MethodPathDescription
GET/itemsGet all items
GET/items/{id}Get one item
POST/itemsCreate 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.

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

Once basic CRUD is working, let’s add the following to get closer to production-grade.

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.