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/postscreate a post,GET /api/postslist with pagination,GET/PUT/DELETE /api/posts/{id}.POST /api/posts/{id}/commentsadd a comment,GET /api/posts/{id}/commentslist a post’s comments.- DTOs for every request and response — entities never cross the HTTP boundary.
- Bean Validation on input, a global
@RestControllerAdvicefor 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-viewfrom 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
@ManyToOneside isLAZYand 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.