Basic Bulletin Board Project

Seunghwan Choi·2025년 1월 5일

Java Backend

목록 보기
13/16

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

ERD Diagram

  • Each board contains multiple posts, hence the 1:N relationship with post table.
  • Each post can have multiple replies attached to it, hence the 1:N relationship with reply table.
  • Each post has 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.
  • Each post has title, content and posted_at.
  • Reply is similar to post.

Package Structure:

Board

Controller

BoardApiController:

@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);
    }
}

DB

BoardEntity

@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();
}
  • The @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.
  • The mappedBy attribute indicates the field in the PostEntity class that owns the relationship. It avoids creating a separate join table for this relationship.
  • JPA appends _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.
  • In the above case, 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.
  • Behavior without mappedby, there would be join table creation: JPA creates a join table to manage the relationship instead of a foreign key column.

BoardRepository

package com.example.simple_board.board.db;

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

public interface BoardRepository extends JpaRepository<BoardEntity, Long> {}

Model

BoardRequest

@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class BoardRequest {
    @NotBlank
    private String BoardName;
}

Purpose of BoardRequest

  1. Encapsulation of API input:
    • The 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.
  2. Mapping client data to entity:
    • The BoardRequest provides a clear mechanism to map incoming data to the BoardEntity.
    var entity = BoardEntity.builder()
        .boardName(boardRequest.getBoardName())
        .status("REGISTERED")
        .build();
    • This prevents the direct use of 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)
  3. Separation of concerns:
    • The BoardEntity is tightly coupled with the database schema and persistence logic, while BoardRequest is designed specifically for client-server communication.
    • By using BoardRequest, we decouple the database entity from the API layer, making the system more maintainable and adaptable to changes.

Service

BoardService

@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
    }
}

Post

Controller

PostApiController

@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);
    }
}

DB

PostEntity

@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.

    • Without @Transient annotation, JPA will treat the field as a persistent field, giving rise to the following issues:
      - Database Mapping: JPA will attempt to map the field to a column in the database.
      • If the column does not exist in the database, it may cause:
        • Schema mismatch errors (if schema validation is enabled)
        • Runtime exceptions when querying or persisting the entity.
    • By using @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)

Model

PostRequest

@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;
}

PostViewRequst

@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class PostViewRequest {
    @NotNull
    private Long postId;

    @NotNull
    private String password;

}

Why have both PostRequest and PostViewRequest

  • Having separate 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.
  • The fields and validation rules for creating a post and viewing a post are fundamentally different. Separate classes allow tailored validation rules for each operation, ensuring correctness and reducing risk of errors.
  • Future scalability: As the application grows, additional operations may require furthre distinctions:
    - New features might demand updates to PostViewRequest (e.g. including authToken.
    • PostRequest can evolve independently (e.g. adding tags or categories for posts)

Service

PostService

@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());
                        }
                );
    }
}

Circular Reference Issue

  • Circular references occur when two entities reference each other directly, resulting in infinite loops when serializing objects (e.g., converting to JSON or logging).
  • In our case, BoardEntity references PostEntity through postList:
//Within BoardEntity:
@OneToMany(
            mappedBy = "board" 
    )
    private List<PostEntity> postList = List.of();
  • And PostEntity references BoardEntity through the board field:
//Within PostEntity:
@ManyToOne
//@JsonIgnore
//@ToString.Exclude
private BoardEntity board;
  • Annotations like @JsonIgnore or @ToString.Exclude can temporarily mitigate the issue but these solutions are not ideal because:
    - Loss of data: Prevent certain fields from being included in the serialized output (e.g. ignoring board in PostEntity may hide useful data.)
    • Couple logic: Tie serialization and logging behavior to the entity design, which violates separation of concerns.
  • The proper solution is to introduce the DTOs (Data Transfer Objects) like BoardDto, which separate the serialized structure from the entity logic. This approach ensures flexibility, eliminates circular references, and adheres to best practices.

Introducing BoardDto & PostDto

@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;
}
  • When using 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.
  • By having 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.
  • Even though the underlying JPA relationships remain intact for database operations and domain logic, 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.
  • Notably, returning raw DTOs to the client has several limitations and is not considered ideal in modern software design. Instead DTOs should be wrapped in a standardized API response object.

Setting up JPA relationship between Post and Reply

ReplyEntity

  • Initially, in ReplyEntity,
private Long postId;
  • With JPA relationship,
@ManyToOne
@ToString.Ignore
@JsonIgnore
private PostEntity post;

PostEntity

  • Initially, in PostEntity,
private List<ReplyEntity> replyList = List.of();
  • With JPA relationship,
@OneToMany( mappedby = "post" )
private List<ReplyEntity> replyList = List.of();

PostService

  • In 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());
                        }
                );
    }
  • After setting up the JPA relationship between 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());
                        }
                );
    }

Post Deletion Issue

  • In current implementation, when a user deletes a post, it does not actually deletes it but marks it as 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);
    }
  • As a result, the response looks like below:
{
    "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"
        }
    ]
}
  • We can fix this issue such that only REGISTERED posts are returned, by adding an annotation in BoardEntity:
@Where(clause = "status = 'REGISTERED'")
@OrderBy("id desc")
private List<PostEntity> postList = List.of();

Pagination of posts

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.
}
  • The purpose of 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:
    - Automatically injects pagination parameters (page and size) into the method.
    • Default values:
      • page = 0 -> First page (0-based index)
        • size = 10 -> Each page contains up to 10 posts
  • Api<List<PostEntity>>
    - Wraps the paginated list of posts inside an API response wrapper, which includes:
    - body -> List of PostEntity objects (actual data)
    - pagination -> Pagination metadata

Next, 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;
    }
  • Now, the above method returns 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.)
  • The postRepository.findAll(pageable) returns a Page<PostEntity> object, list has access to the methods from the Page<T> interface in Spring Data JPA.

Outcome of pagination

  • For the 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:
  • For the GET request with page number 1 and page size 5, we get:
  • We can also sort the elements in the page by adding the following to the annoation:
@PageableDefault(page = 0, size = 5, sort = "id", direction = Sort.Direction.DESC)
  • This will return the elements in descending order of id.

0개의 댓글