Skip to content
Spring Boot projects 7 min read

Project: Blog REST API

This capstone walks end-to-end through a realistic Blog REST API: a Post resource with nested Comments, mapped through DTOs, persisted with Spring Data JPA, validated on input, paginated on output, and protected by centralized exception handling. Each step links to the topic page that explains it in depth, so treat this as a guided tour that assembles the whole catalog into one running application. By the end you have a layered API you could deploy.

What we are building

A small but production-shaped service:

  • POST /api/posts create a post, GET /api/posts list with pagination, GET/PUT/DELETE /api/posts/{id}.
  • POST /api/posts/{id}/comments add a comment, GET /api/posts/{id}/comments list a post’s comments.
  • DTOs for every request and response — entities never cross the HTTP boundary.
  • Bean Validation on input, a global @RestControllerAdvice for errors, and slice tests.
HTTP → Controller → Service → Repository → Database
       (DTO)        (rules,    (entity)
                     mapping)

Step 1 — Project setup

Generate the project from Spring Initializr with the Web, Spring Data JPA, Validation, Lombok, and H2 starters. The key dependencies in pom.xml:

<dependencies>
    <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>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Point at an in-memory H2 database for development in application.yml:

spring:
  datasource:
    url: jdbc:h2:mem:blog;DB_CLOSE_DELAY=-1
  jpa:
    hibernate:
      ddl-auto: update
    open-in-view: false

Tip: Disable open-in-view from day one. It hides lazy-loading boundaries and is a common source of the N+1 problem in real apps.

Step 2 — Entities and relationships

A Post has many Comments — a bidirectional one-to-many relationship. See Entity Mapping and Primary Keys for the annotations used here.

import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "posts")
@Getter @Setter
@NoArgsConstructor
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false, length = 5000)
    private String body;

    private String author;

    private Instant createdAt = Instant.now();

    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Comment> comments = new ArrayList<>();

    public void addComment(Comment c) {
        comments.add(c);
        c.setPost(this);
    }
}
@Entity
@Table(name = "comments")
@Getter @Setter
@NoArgsConstructor
public class Comment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 2000)
    private String text;

    private String author;

    private Instant createdAt = Instant.now();

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id", nullable = false)
    private Post post;
}

Note: The @ManyToOne side is LAZY and owns the foreign key. Always set fetch types deliberately — eager many-to-one associations multiply queries fast.

Step 3 — DTOs

Entities stay inside the persistence layer; the API speaks in records. This is the DTO pattern — see Entity vs DTO for why exposing entities is a trap. Validation constraints live on the request DTOs (validation intro).

import jakarta.validation.constraints.*;
import java.time.Instant;

public record PostRequest(
        @NotBlank @Size(max = 200) String title,
        @NotBlank @Size(max = 5000) String body,
        @NotBlank String author) {}

public record PostResponse(
        Long id, String title, String body, String author,
        int commentCount, Instant createdAt) {}

public record CommentRequest(
        @NotBlank @Size(max = 2000) String text,
        @NotBlank String author) {}

public record CommentResponse(
        Long id, String text, String author, Instant createdAt) {}

Step 4 — Mapping

For a small project, manual mapping is clear and dependency-free — see Manual Mapping.

class PostMapper {
    static PostResponse toResponse(Post p) {
        return new PostResponse(p.getId(), p.getTitle(), p.getBody(),
                p.getAuthor(), p.getComments().size(), p.getCreatedAt());
    }
    static CommentResponse toResponse(Comment c) {
        return new CommentResponse(c.getId(), c.getText(), c.getAuthor(), c.getCreatedAt());
    }
}

As the project grows, switch to compile-time mapping with MapStruct, which generates the same code from an interface:

import org.mapstruct.Mapper;

@Mapper(componentModel = "spring")
public interface PostMapperMs {
    @org.mapstruct.Mapping(target = "commentCount", expression = "java(post.getComments().size())")
    PostResponse toResponse(Post post);
}

Step 5 — Repositories

Spring Data JPA generates the implementations — see Repositories and Derived Queries. The comment query is paged by post.

import org.springframework.data.domain.*;
import org.springframework.data.jpa.repository.JpaRepository;

public interface PostRepository extends JpaRepository<Post, Long> {
}

public interface CommentRepository extends JpaRepository<Comment, Long> {
    Page<Comment> findByPostId(Long postId, Pageable pageable);
}

Step 6 — Custom exceptions

A thin domain exception keeps the service expressive — see Custom Exceptions.

public class NotFoundException extends RuntimeException {
    public NotFoundException(String resource, Long id) {
        super(resource + " " + id + " not found");
    }
}

Step 7 — Service layer

The service owns business rules, transaction boundaries, and the entity ↔ DTO mapping. Notice @Transactional(readOnly = true) on queries and a default read-write transaction on writes — see Transactions.

import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional
public class PostService {

    private final PostRepository posts;
    private final CommentRepository comments;

    @Transactional(readOnly = true)
    public Page<PostResponse> list(Pageable pageable) {
        return posts.findAll(pageable).map(PostMapper::toResponse);
    }

    @Transactional(readOnly = true)
    public PostResponse get(Long id) {
        return PostMapper.toResponse(find(id));
    }

    public PostResponse create(PostRequest req) {
        Post p = new Post();
        p.setTitle(req.title());
        p.setBody(req.body());
        p.setAuthor(req.author());
        return PostMapper.toResponse(posts.save(p));
    }

    public PostResponse update(Long id, PostRequest req) {
        Post p = find(id);
        p.setTitle(req.title());
        p.setBody(req.body());
        p.setAuthor(req.author());
        return PostMapper.toResponse(p);   // dirty checking flushes the change
    }

    public void delete(Long id) {
        if (!posts.existsById(id)) throw new NotFoundException("Post", id);
        posts.deleteById(id);
    }

    public CommentResponse addComment(Long postId, CommentRequest req) {
        Post p = find(postId);
        Comment c = new Comment();
        c.setText(req.text());
        c.setAuthor(req.author());
        p.addComment(c);
        return PostMapper.toResponse(comments.save(c));
    }

    @Transactional(readOnly = true)
    public Page<CommentResponse> listComments(Long postId, Pageable pageable) {
        if (!posts.existsById(postId)) throw new NotFoundException("Post", postId);
        return comments.findByPostId(postId, pageable).map(PostMapper::toResponse);
    }

    private Post find(Long id) {
        return posts.findById(id).orElseThrow(() -> new NotFoundException("Post", id));
    }
}

Step 8 — Controllers

Thin controllers map HTTP to service calls and choose status codes. @Valid triggers Bean Validation; the Pageable parameter is resolved from ?page=&size=&sort= automatically — see Pagination with JPA.

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.*;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.net.URI;

@RestController
@RequestMapping("/api/posts")
@RequiredArgsConstructor
public class PostController {

    private final PostService service;

    @GetMapping
    public Page<PostResponse> list(@PageableDefault(size = 10, sort = "createdAt") Pageable pageable) {
        return service.list(pageable);
    }

    @GetMapping("/{id}")
    public PostResponse get(@PathVariable Long id) {
        return service.get(id);
    }

    @PostMapping
    public ResponseEntity<PostResponse> create(@Valid @RequestBody PostRequest body) {
        PostResponse created = service.create(body);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}").buildAndExpand(created.id()).toUri();
        return ResponseEntity.created(location).body(created);
    }

    @PutMapping("/{id}")
    public PostResponse update(@PathVariable Long id, @Valid @RequestBody PostRequest body) {
        return service.update(id, body);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        service.delete(id);
        return ResponseEntity.noContent().build();
    }

    @PostMapping("/{id}/comments")
    @ResponseStatus(org.springframework.http.HttpStatus.CREATED)
    public CommentResponse addComment(@PathVariable Long id, @Valid @RequestBody CommentRequest body) {
        return service.addComment(id, body);
    }

    @GetMapping("/{id}/comments")
    public Page<CommentResponse> comments(@PathVariable Long id,
                                          @PageableDefault(size = 20) Pageable pageable) {
        return service.listComments(id, pageable);
    }
}

Step 9 — Global exception handling

One @RestControllerAdvice turns exceptions into consistent RFC 7807 ProblemDetail responses — see Controller Advice and Handling Validation Errors.

import org.springframework.http.*;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(NotFoundException.class)
    public ProblemDetail handleNotFound(NotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        pd.setTitle("Validation failed");
        ex.getBindingResult().getFieldErrors()
          .forEach(e -> pd.setProperty(e.getField(), e.getDefaultMessage()));
        return pd;
    }
}

The API in action

Create a post:

curl -i -X POST http://localhost:8080/api/posts \
  -H "Content-Type: application/json" \
  -d '{"title":"Hello Spring","body":"My first post","author":"ada"}'
HTTP/1.1 201 Created
Location: http://localhost:8080/api/posts/1

{ "id": 1, "title": "Hello Spring", "body": "My first post",
  "author": "ada", "commentCount": 0, "createdAt": "2026-06-13T10:00:00Z" }

Paginated list (GET /api/posts?page=0&size=2):

{
  "content": [ { "id": 1, "title": "Hello Spring", "commentCount": 0 } ],
  "totalElements": 1,
  "totalPages": 1,
  "number": 0,
  "size": 2
}

Validation failure (POST with {"title":"","body":"x","author":""}):

{ "type": "about:blank", "title": "Validation failed", "status": 400,
  "title": "must not be blank", "author": "must not be blank" }

Step 10 — Tests

Slice the controller with @WebMvcTest and a mocked service to verify HTTP behavior without a database:

import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.ArgumentMatchers.any;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(PostController.class)
class PostControllerTest {

    @Autowired MockMvc mvc;
    @MockBean PostService service;

    @Test
    void rejectsBlankTitle() throws Exception {
        mvc.perform(post("/api/posts")
                .contentType("application/json")
                .content("{\"title\":\"\",\"body\":\"x\",\"author\":\"a\"}"))
           .andExpect(status().isBadRequest());
    }

    @Test
    void createsPost() throws Exception {
        Mockito.when(service.create(any()))
               .thenReturn(new PostResponse(1L, "T", "B", "a", 0, java.time.Instant.now()));
        mvc.perform(post("/api/posts")
                .contentType("application/json")
                .content("{\"title\":\"T\",\"body\":\"B\",\"author\":\"a\"}"))
           .andExpect(status().isCreated())
           .andExpect(jsonPath("$.id").value(1));
    }
}

Test the repository against a real (in-memory) database with @DataJpaTest, and the full stack with @SpringBootTest. See Testing Intro for the strategy.

Where to go next

This API is feature-complete for a blog. To make it production-ready, add JWT authentication so only owners edit their posts, swap H2 for PostgreSQL with Flyway migrations, document it with Swagger/OpenAPI, and containerize it for deploy with Docker. The E-Commerce Backend project builds on all of these.

Last updated June 13, 2026
Was this helpful?