In this post, we create a basic bulletin board with simple CRUD functionalities. Below is the ERD diagram for this project.

user_name and email related to the person who created the post, as well as a password required to view, edit and delete the post. title, content and posted_at.
@RestController
@RequestMapping("/api/board")
@RequiredArgsConstructor
public class BoardApiController {
private final BoardService boardService;
@PostMapping("")
public BoardEntity create(
@Valid
@RequestBody BoardRequest boardRequest
){
return boardService.create(boardRequest);
}
@GetMapping("/id/{id}")
public BoardEntity view(
@PathVariable Long id
){
return boardService.view(id);
}
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
@Entity(name = "board")
public class BoardEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
private String boardName;
private String status;
@OneToMany(
mappedBy = "board" //PostEntity 안에 board라는 변수가 있어야 함
)
private List<PostEntity> postList = List.of();
}
@OneToMany annotation in BoardEntity represents a one-to-many relationship in the context of Object-Relational Mapping (ORM) using JPA. The relationship signifies that one BoardEntity can be associated with multiple PostEntity objects. mappedBy attribute indicates the field in the PostEntity class that owns the relationship. It avoids creating a separate join table for this relationship. _id to the field name specified in mappedBy when generating the foreign key column name in the database. This naming rule ensures that the database reflects the relationship between the entities.mappedby = "board" must match the field private BoardEntity board; in PostEntity. And since the parent side is not the owning side, no column related to the child entity is added to the parent entity's table. mappedby, there would be join table creation: JPA creates a join table to manage the relationship instead of a foreign key column.package com.example.simple_board.board.db;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BoardRepository extends JpaRepository<BoardEntity, Long> {}
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class BoardRequest {
@NotBlank
private String BoardName;
}
BoardRequest class encapsulates the data that the API expects in a structured format. When a client sends a request to create a new board, the BoardRequest class defines the structure of that data.BoardRequest provides a clear mechanism to map incoming data to the BoardEntity. var entity = BoardEntity.builder()
.boardName(boardRequest.getBoardName())
.status("REGISTERED")
.build();BoardEntity as an API input model, which could expose database-specific fields (id, postList, etc) unnecessarily or allow manipulation of fields that should not be modified by the client (e.g. `status)BoardEntity is tightly coupled with the database schema and persistence logic, while BoardRequest is designed specifically for client-server communication. BoardRequest, we decouple the database entity from the API layer, making the system more maintainable and adaptable to changes. @Service
@RequiredArgsConstructor
public class BoardService {
private final BoardRepository boardRepository;
public BoardEntity create(
BoardRequest boardRequest
){
var entity = BoardEntity.builder()
.boardName(boardRequest.getBoardName())
.status("REGISTERED")
.build();
return boardRepository.save(entity);
}
public BoardEntity view(Long id) {
return boardRepository.findById(id).get(); //assume exists
}
}
@RestController
@RequestMapping("/api/post")
@RequiredArgsConstructor
public class PostApiController {
private final PostService postService;
@PostMapping("")
public PostEntity create(
@Valid
@RequestBody PostRequest postRequest
){
return postService.create(postRequest);
}
@PostMapping("/view")
public PostEntity view(
@Valid
@RequestBody PostViewRequest postViewRequest
){
return postService.view(postViewRequest);
}
@GetMapping("/all")
public List<PostEntity> list(){
return postService.all();
}
@PostMapping("/delete")
public void delete(
@Valid
@RequestBody PostViewRequest postViewRequest
){
postService.delete(postViewRequest);
}
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
@Entity(name = "post")
public class PostEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JsonIgnore
@ToString.Exclude
private BoardEntity board;
private String userName;
private String password;
private String email;
private String status;
private String title;
@Column(columnDefinition = "TEXT")
private String content;
private LocalDateTime postDate;
@Transient
private List<ReplyEntity> replyList = List.of();
}
@Entity(name = "post"): Marks this as a JPA entity, meaning it is mapped to a database table. The name = "post" specifies that this entity corresponds to the post table in the database.
@ToString.Exclude: Excludes the board field from the toString() output to prevent circular references.
@JsonIgnore: Prevents this field from being serialized into JSON when the object is converted. Avoids circular references during serialization (e.g. BoardEntity referencing PostEntity and vice versa)
Column(columnDefinition = "TEXT"): Specifies that the content field should be stored as TEXT column in the database. Ensures the database can handle large text conetnt, such as long posts or articles.
- Without @Column(columnDefinition = "TEXT"), JPA will map the content field to a column with the type VARCHAR by default. The maximum length VARCHAR varies by database and is typically limited to 255 characters unless explicitly configured.
@Persisent marks a field in the entity class that should not stored in the database. The field exists only in the Java application's memory and is excluded from all databasea operations like INSERT, UPDATE, SELECT, or DELETE.
- Examples include derived fields (e.g. calculated values based on other fields), Fields used for temporary operations or user interaction, Non-persistent fields in a DTO or view layer.
@Transient annotation, JPA will treat the field as a persistent field, giving rise to the following issues:@Transient, it ensures that replies (ReplyEntity) are not directly mapped but stored in-memory, suggesting replies are handled separately (e.g. fetched via a service or repository)@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class PostRequest {
private Long boardId = 1L;
@NotBlank
private String userName;
@NotBlank
@Size(min = 4, max = 4)
private String password;
@NotBlank
@Email
private String email;
@NotBlank
private String title;
@NotBlank
private String content;
}
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class PostViewRequest {
@NotNull
private Long postId;
@NotNull
private String password;
}
PostRequest and PostViewRequest classes is a design decision that aligns with the Single Responsibility Principle (SRP) and enhances the clarity, security, and maintainability of the codebase. PostRequest represents the data required to create a new post.PostViewRequest represents the data required to view or interact with an existing post securely. PostViewRequest (e.g. including authToken.PostRequest can evolve independently (e.g. adding tags or categories for posts)@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
private final ReplyService replyService;
private final BoardRepository boardRepository;
public PostEntity create(
PostRequest postRequest
){
var boardEntity = boardRepository.findById(postRequest.getBoardId()).get(); //temporary
var entity = PostEntity.builder()
.board(boardEntity) //temporarily fixed to 1
.userName(postRequest.getUserName())
.password(postRequest.getPassword())
.email(postRequest.getEmail())
.status("REGISTERED")
.title(postRequest.getTitle())
.content(postRequest.getContent())
.postDate(LocalDateTime.now())
.build();
return postRepository.save(entity);
}
public PostEntity view(PostViewRequest postViewRequest) {
return postRepository.findFirstByIdAndStatusOrderByIdDesc(postViewRequest.getPostId(), "REGISTERED")
.map( it -> {
if(!it.getPassword().equals(postViewRequest.getPassword())){
var format = "Incorrect Password %s vs %s";
throw new RuntimeException(String.format(format, it.getPassword(), postViewRequest.getPassword()));
}
//RETURN replies to the corresponding post also
var replyList = replyService.findAllByPostId(it.getId());
it.setReplyList(replyList);
return it;
}).orElseThrow(
()->{
return new RuntimeException("Such post does not exist: " + postViewRequest.getPostId());
}
);
}
public List<PostEntity> all() {
return postRepository.findAll();
}
public void delete(PostViewRequest postViewRequest) {
postRepository.findById(postViewRequest.getPostId())
.map(it -> {
if(!it.getPassword().equals(postViewRequest.getPassword())){
var format = "Incorrect Password %s vs %s";
throw new RuntimeException(String.format(format, it.getPassword(), postViewRequest.getPassword()));
}
it.setStatus("UNREGISTERED");
postRepository.save(it);
return it;
}).orElseThrow(
() -> {
return new RuntimeException("Such post does not exist: " + postViewRequest.getPostId());
}
);
}
}
BoardEntity references PostEntity through postList://Within BoardEntity:
@OneToMany(
mappedBy = "board"
)
private List<PostEntity> postList = List.of();
PostEntity references BoardEntity through the board field://Within PostEntity:
@ManyToOne
//@JsonIgnore
//@ToString.Exclude
private BoardEntity board;
@JsonIgnore or @ToString.Exclude can temporarily mitigate the issue but these solutions are not ideal because:board in PostEntity may hide useful data.)BoardDto, which separate the serialized structure from the entity logic. This approach ensures flexibility, eliminates circular references, and adheres to best practices.@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class BoardDto {
private long id;
private String boardName;
private String status;
private List<PostDto> postList = List.of();
}
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class PostDto {
private Long id;
private Long boardId;
private String userName;
private String password;
private String email;
private String status;
private String title;
private String content;
private LocalDateTime postDate;
}
BoardDto, the risk of entering an infinite loop during serialization, even with JPA relationships, is eliminated. This is because BoardDto serves as a separate, lightweight representation of the BoardEntity object, designed specifically for transferring data between the application and the client. BoardDto and PostDto, we break the direct linkage between BoardEntity and PostEntity in the serialized output. Instead of serializing entities with their circular references, we map the entity data into the BoardDto structure, which includes only the fields necessary for the client. This ensures that serialization stops at the BoardDto and PostDto level, preventing recursive traversal into related entities. BoardDto abstracts those relationships for the client layer, eliminating the possibility of an infinite loop. This approach adheres to best practices including separation of concerns and decoupling of persistence logic from API representation.ReplyEntity,private Long postId;
@ManyToOne
@ToString.Ignore
@JsonIgnore
private PostEntity post;
PostEntity,private List<ReplyEntity> replyList = List.of();
@OneToMany( mappedby = "post" )
private List<ReplyEntity> replyList = List.of();
view(PostViewRequest postViewRequest), there was an additional step to query all replies corresponding to the post we want to view, by using findAllByPostId(Long id) method specified in ReplyService.public PostEntity view(PostViewRequest postViewRequest) {
return postRepository.findFirstByIdAndStatusOrderByIdDesc(postViewRequest.getPostId(), "REGISTERED")
.map( it -> {
if(!it.getPassword().equals(postViewRequest.getPassword())){
var format = "Incorrect Password %s vs %s";
throw new RuntimeException(String.format(format, it.getPassword(), postViewRequest.getPassword()));
}
//RETURN replies to the corresponding post also
var replyList = replyService.findAllByPostId(it.getId());
it.setReplyList(replyList);
return it;
}).orElseThrow(
()->{
return new RuntimeException("Such post does not exist: " + postViewRequest.getPostId());
}
);
}
Post and Reply, we no longer need the above additional step to query all replies that correspond to the post of interest:public PostEntity view(PostViewRequest postViewRequest) {
return postRepository.findFirstByIdAndStatusOrderByIdDesc(postViewRequest.getPostId(), "REGISTERED")
.map( it -> {
if(!it.getPassword().equals(postViewRequest.getPassword())){
var format = "Incorrect Password %s vs %s";
throw new RuntimeException(String.format(format, it.getPassword(), postViewRequest.getPassword()));
}
//RETURN replies to the corresponding post also
//Don't need this anymore after setting up the JPA relationship between post and reply
//var replyList = replyService.findAllByPostId(it.getId());
//it.setReplyList(replyList);
return it;
}).orElseThrow(
()->{
return new RuntimeException("Such post does not exist: " + postViewRequest.getPostId());
}
);
}
UNREGISTERED. Consequently, with a GET request to http://localhost:8080/api/board/id/1, which invokes view() in BoardApiController and BoardService, there is no additional step to filter out UNREGISTERED posts in a board. BoardApiController:@GetMapping("/id/{id}")
public BoardDto view(
@PathVariable Long id
){
return boardService.view(id);
}
BoardService:public BoardDto view(Long id) {
var entity = boardRepository.findById(id).get(); //assume exists, unsafe implementation
return boardConverter.toDto(entity);
}
{
"id": 1,
"board_name": "QnA Board",
"status": "REGISTERED",
"post_list": [
{
"id": 1,
"board_id": 1,
"user_name": "Choi",
"password": "1111",
"email": "choi@gmail.com",
"status": "UNREGISTERED",
"title": "Making enquiry!",
"content": "My order was not delivered",
"post_date": "2025-01-01T16:16:47"
},
{
"id": 2,
"board_id": 1,
"user_name": "Choi",
"password": "1111",
"email": "choi@gmail.com",
"status": "REGISTERED",
"title": "Question about my product",
"content": "The product I ordered is not being delivered",
"post_date": "2025-01-05T17:06:36"
}
]
}
REGISTERED posts are returned, by adding an annotation in BoardEntity:@Where(clause = "status = 'REGISTERED'")
@OrderBy("id desc")
private List<PostEntity> postList = List.of();
The current implementation of retrieving posts is not ideal since the server returns all existing (and 'REGISTERED' posts), which might cause an issue when the number of posts increases. Ideally, we would only want to retrieve posts corresponding to the page they belong to. To implement pagination, we create a new pacakge common where we define Api.java and Pagination.java.
Api:@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Api<T> {
private T body;
private Pagination pagination;
}
The purpose of Api<T> class is a generic wrapper used for structuring API responses in a standardized format. It allows returning both data (body) and pagination metadata (pagination) in a single response object.
Pagination:
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Pagination {
private Integer page; //The current page index
private Integer size; //The number of elements per page
private Integer currentElements; //The number of elements present in current page (useful when the last page has fewer elements
private Integer totalPage; //Total number of pages available
private Long totalElements; //Total count of elements in the dataset. totalelement can be long, Integer might not be sufficient to hold this data.
}
Pagination class is to serve as a data transfer object (DTO) that holds pagination-related metadata. It is used to send pagination details in API responses when dealing with paginated results.First, we modify the list() method in PostApiController, which previously retrieved all posts:
@GetMapping("/all")
public Api<List<PostEntity>> list(
@PageableDefault(page = 0, size = 10)
Pageable pageable
){
return postService.all(pageable);
}
PageableDefault(page = 0, size = 10) Pageable pageable:page = 0 -> First page (0-based index)size = 10 -> Each page contains up to 10 postsApi<List<PostEntity>>body -> List of PostEntity objects (actual data)pagination -> Pagination metadataNext, we modify the postService.all() method:
public Api<List<PostEntity>> all(Pageable pageable) {
var list = postRepository.findAll(pageable);
var pagination = Pagination.builder()
.page(list.getNumber()) // Current page index
.size(list.getSize()) // Page size (number of elements per page)
.currentElements(list.getNumberOfElements()) // Number of elements in the current page
.totalElements(list.getTotalElements()) //Total elements in the dataset
.totalPage(list.getTotalPages()) //Total number of pages
.build();
var response = Api.<List<PostEntity>>builder()
.body(list.toList())
.pagination(pagination)
.build();
return response;
}
Api<List<PostEntity>>, meaning body is a list of PostEntity objects (the actual posts) and pagination is the metadata about pagination (current page, total pages, etc.)postRepository.findAll(pageable) returns a Page<PostEntity> object, list has access to the methods from the Page<T> interface in Spring Data JPA. GET request http://localhost:8080/api/post/all?page=0&size=5 where we are fetching page number 0 with page size 5, we get the following:
GET request with page number 1 and page size 5, we get:
@PageableDefault(page = 0, size = 5, sort = "id", direction = Sort.Direction.DESC)