Spring Boot + Reactで記事投稿アプリを作成予定｜Entity・三層アーキテクチャ学習ログ

※学習中であるため間違っている箇所があるかもしれません。

今回は、Spring Bootにおける `@Entity` の役割や設計意図を深掘りしていきます。

その前提となる「永続化」と「O/Rマッピング」の概念が曖昧だったため、まずはそれらを整理しました。

---

## 永続化（Persistence）とは？

通常、Javaアプリケーションが終了すると、メモリ上に存在するデータ（オブジェクト）はすべて失われます。

このような一時的なオブジェクトの状態を、プログラム終了後も保持できるように、データベースやCSVファイルなどの永続ストレージに保存する処理を **永続化** と呼びます。

Javaにおける永続化では、`@Entity` を付与したクラスがその対象となり、Spring Boot では Jakarta Persistence（旧JPA）を通じて、オブジェクトの状態をデータベースに保存・更新する仕組みが提供されています。

---

### 主な永続化処理（Jakarta Persistence）

- `CrudRepository.save(...)`
    
    Entityを新規登録または更新する
    
- `EntityManager.persist(...)`
    
    永続化コンテキストに新規Entityを登録する（Insert）
    
- `EntityManager.merge(...)`
    
    永続化コンテキスト外のEntityを再アタッチして状態を同期する（Update）

#### ※永続化コンテキストとは？
永続化コンテキストとは、JPAにおいて `EntityManager` が管理する、永続化対象のエンティティインスタンスを一時的に保持するメモリ上の作業領域です。

このコンテキストでは、主キー（ID）をキーとして、Entityインスタンスが一意に管理されます。

同じIDで再度検索された場合、データベースではなく、このコンテキストから直接Entityが返されます（これを「1次キャッシュ」と呼びます）。

また、永続化コンテキストに管理されている状態を「**マネージド（Managed）状態**」と呼び、これにより変更の自動検知やトランザクション中の同期が可能になります。


---
## 🔸 O/Rマッピングとは？

O/Rマッピング（Object-Relational Mapping）とは、リレーショナルデータベースのテーブルと、Javaのオブジェクトを自動的に相互変換（マッピング）する技術です。

これにより、SQLを直接書かずに、Javaのコードだけでデータの保存・更新・取得を行うことが可能になります。

Spring Boot では、O/Rマッピングの実装として Spring Data JPA が提供されており、リポジトリ経由で直感的なデータ操作が実現できます。


# SpringbootのEntityに関して
### Entityとは
Entityとは@Entityをクラスへ付与することでJavaオブジェクトとしてRDBを操作するための仕組みです。
@Entityアノテーションを付与することでSpring Data Jpaの内部で永続化、O/Rマッピングを自動で行ってくれます。

Jakarta Persistenceに準拠するAPI(Repository)を利用することで、テーブルへの登録、削除、検索を行うことができます。

# Spring Bootの責務分離
Spring Bootでは各レイヤーで責務が分離されています。
以下に各レイヤーの責務を簡単にまとめます。

プレゼンテーション層、ビジネスロジック層、データアクセス層、が存在します。
## プレゼンテーション層(Controllers)
- HTTPリクエストとレスポンスを処理する
- URLを特定のメソッドにマッピング
- バリデーションを行う
- サービス層の呼び出し
- レスポンスの返却

## ビジネスロジック層(Services)
- アプリケーションのビジネスロジックを含んでいる
- ControllerとRepositoryの橋渡し
- データ処理やバリデーションを行う
- トランザクション管理

## データアクセス層(Repository)
- データベースとのやり取りを行う
- CRUD操作を実装する
- JPAや他のデータアクセス技術を使用する

### 各層の依存関係
- Controller→Service
- Service→Repository
となっており

|レイヤー|主なクラス|
|---|---|
|Controller|Controllerクラス|
|Service|Serviceクラス|
|Repository|Repositryクラス|

各層では
ControllerからはServiceだけを呼び出す
ServiceからはRepositoryだけを呼び出す
となっており、
その役目を超えてServiceでメソッドのマッピングをしたり、
Controllerにデータの処理を記載するといったことがないように、
各レイヤーの責務を完全に分離することで責務を分離します。
これを三層アーキテクチャといいます。

レイヤー間の分離ができていないと予期せぬバグやデータの漏洩にもつながるので
しっかりと理解しておきましょう。

#### 例
Controller,Service,Repositoryに関して簡単な処理を記載します。
これはArticleテーブルからタイトルを取得するだけの処理です。

##### Controller
api/GetArticle
にアクセスしてサービス層の処理を起動します。
```java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.spring_article_java_api.service.ArticleService;

@RestController
@RequestMapping("api")
public class ArticleController {

    @Autowired
    ArticleService articleservice;

    @GetMapping("GetArticle")
    public String GetArticle(){
        String title;
        //// デモ用としてid=1を使用します（実際はパスパラメータなどで受け取るのが一般的です）
        title = articleservice.getArticleTitle(1);
        return title;

    }
}
```
##### Service
サービス層が呼ばれた際に、repositoryを呼び出してテーブルからデータを取得します。
```java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import com.example.spring_article_java_api.entity.Article;
import com.example.spring_article_java_api.repository.ArticleRepository;

@Service
public class ArticleService {

    @Autowired
    ArticleRepository repository;

    public String getArticleTitle(int id){
        Article article;
        //リポジトリを呼び出して、データベースから値を取得する
        article = repository.getReferenceById(id);
        return article.getTitle();
    }
}
```

##### Repository
サービス層から呼び出された際に、取得や登録を行う。
```java
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import com.example.spring_article_java_api.entity.Article;

@Repository
public interface ArticleRepository extends JpaRepository<Article, Integer> {
//何もコードを記載しなくても基本的なコードがJpaRepositoryから継承されます。
}
```

このような形で各層から呼び出し、処理を行っていきます。

今回作成したEntityはこのRepository層で扱うクラスです。
主にデータベースとのやり取りをする際に使用するオブジェクトです。

以下に今回作成したテーブル、Entityを記載します。
実際の登録や削除処理などは次回以降実装していく予定です

Entityの設計が甘いとシステムの拡張性や保守性が低くなり、
機能追加などが難しくなる子それがあるためしっかりと設計を行いましょう。

### BaseEntity
```java
import java.time.LocalDateTime;

import org.apache.commons.lang3.StringUtils;

import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

/***
 * 全Entityに共通の監査項目（作成者、更新者、作成日、更新日、削除フラグ）を定義。
 * JPAの@MappedSuperclassによってDBテーブルに継承される。
 */
@MappedSuperclass
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public abstract class BaseEntity {
    /***
    * 作成者
    */
    @NotBlank
    @Column(name="created_by", updatable=false, length=50)
    private String createdBy;

    /***
    * 更新者
    */
    @Column(name="updated_by", length=50)
    private String updatedBy;

    /***
     * 作成日
     * 処理はサービス側で行う
     */
    @NotNull
    @Column(name="created_at", updatable=false)
    @Getter
    private LocalDateTime createdAt;

    /***
     * 更新日
     * 処理はサービス側で行う
     */
    @Column(name="updated_at")
    @Getter
    private LocalDateTime updatedAt;

    /***
    * 削除フラグ
    */
    @NotNull
    @Column(name="delete_flg", columnDefinition="TINYINT(1)")
    private boolean deleteFlg;

    /***
     * 作成者を更新
     * protectedメソッドを作成し一貫性を保持
     * @param createdBy
     */
    protected void changeCreatedBy(String createdBy){
        this.createdBy = createdBy;
    }

    /***
     * 更新者を設定
     * INSERTではNULL許容なのでここでバリデーション
     */
    protected void changeUpdatedBy(String updatedBy){
        if(StringUtils.isBlank(updatedBy)){
            throw new IllegalArgumentException("更新者の値が不正です");
        }
        this.updatedBy = updatedBy;
    };

    /***
     * 更新日を設定
     * INSERTではNULL許容なのでここでバリデーション
     */
    protected void changeUpdatedAt(LocalDateTime updatedAt){
        //作成日よりも前の値だった場合
        if(updatedAt == null) {
            throw new IllegalArgumentException("更新日付の値が不正です");
        } else if(updatedAt.isBefore(this.createdAt)){
            //値がnullの場合(初期はNULL許容なのでアノテーションはなし)
            throw new IllegalArgumentException("更新日が作成日よりも前に設定されています");
        }
        this.updatedAt = updatedAt;
    }

    /***
     * 継承先で現在時刻を設定するためのメソッド
     */
    protected void setCreatedAt(){
        this.createdAt = LocalDateTime.now();
    }

    /***
     * 削除フラグ更新
     */
    protected void changeDeleteFlg(boolean deleteFlg){
        this.deleteFlg = deleteFlg;
    }
}
```
### usersテーブル
|id|name|email|role|created_at|created_by|updated_by|updated_at|delete_flg|
|---|---|---|---|---|---|---|---|---|
|ID|名前|メールアドレス|役職|作成者|更新者|作成日|更新日|削除フラグ|
### Userエンティティ
```java
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@NoArgsConstructor
@Table(name="users")
public class User extends BaseEntity {

    /***
     * ユーザーID
     */
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Getter
    private int id;

    /***
     * ユーザー名
     */
    @NotBlank
    @Size(max=50)
    @Setter
    @Getter
    private String name;

    /***
     * メールアドレス
     */
    @NotBlank
    @Size(max=50)
    @Setter
    @Getter
    private String email;

    /***
     * 役職
     */
    @NotBlank
    @Setter
    @Column(name="role", columnDefinition="ENUM('USER', 'ADMIN')")
    @Getter
    private String role;

}
```
### articles_tagsテーブル
|tag_id|article_id|created_at|
|---|---|---|
|タグID|記事ID|作成日|
### ArticleTagエンティティ
```java
import java.time.LocalDateTime;

import jakarta.persistence.Column;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor
@Table(name="articles_tags")
public class ArticleTag {

    /***
     * 複合キー
     * タグID + 記事ID
     */
    @EmbeddedId
    private ArticleTagPK articleTagPK;
    
    /***
     * 作成日
     */
    @NotNull
    @Column(name="created_at", updatable=false)
    private LocalDateTime createdAt;

    /***
     * 初期設定用コンストラクタ
     * @param tagId
     * @param articleId
     */
    public ArticleTag(int tagId, int articleId){
        articleTagPK = new ArticleTagPK(tagId, articleId);
        this.createdAt = LocalDateTime.now();
    }
}
```
### ArticleTagPK
※埋め込み用複合キー
```java
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Embeddable
@Getter
@NoArgsConstructor
public class ArticleTagPK {
    /***
    * タグテーブル.タグID
    */
    @NotNull
    @Column(name="tag_id")
    private int tagId;

    /***
    * 記事テーブル.記事ID
    */
    @NotNull
    @Column(name="article_id")
    private int articleId;

    /***
     * 初期設定用コンストラクタ
     */
    protected ArticleTagPK(int tagId, int articleId){
        this.tagId = tagId;
        this.articleId = articleId;
    }
}
```
### articlesテーブル
|id|user_id|title|content|like_cnt|view_cnt|release_flg|
|---|---|---|---|---|---|---|
|記事ID|ユーザーID|タイトル|内容|ライク数|閲覧数|表示フラグ|

|reservation_day|created_by|updated_by|created_at|updated_at|
|---|---|---|---|---|
|予約日|作成者|更新者|作成日|更新日|

|delete_flg|
|---|
|削除フラグ|
### articleエンティティ
```java
import java.time.LocalDateTime;
import java.time.ZoneId;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Table(name="articles")
@NoArgsConstructor
public class Article extends BaseEntity {

    /***
     * 記事ID
     */
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Getter
    private int id;

    /***
    * ユーザーID
    */
    @Column(name="user_id")
    @Getter
    private int userId;

    /***
     * タイトル
     */
    @NotBlank
    @Size(max=50)
    @Column(name="title", length=50)
    @Getter
    @Setter
    private String title;

    /***
     * 内容(本文)
     */
    @NotBlank
    @Size(max=255)
    @Column(name="content", length=255)
    @Getter
    @Setter
    private String content;

    /***
     * ライク数
     */
    @NotNull
    @Column(name="like_cnt")
    @Getter
    private int likeCnt;

    /***
     * 閲覧数
     */
    @Column(name="view_cnt")
    @Getter
    private int viewCnt;

    /***
     * 表示フラグ
     */
    @NotNull
    @Column(name="release_flg")
    @Getter
    @Setter
    private Boolean releaseFlg;

    /***
     * 投稿予約日
     */
    @Column(name="reservation_day")
    @Getter
    private LocalDateTime reservationDay;

    /***
     * コンストラクタ
     * 必須項目を設定する
     */
    public Article(int userId, String title, String content, Boolean releaseFlg, String createdBy){
        //必須項目をコンストラクタで設定し、漏れがないようにする。
        //各値の整合性チェック
        validateUserId(userId);

        //Entityに値を設定する
        this.userId = userId;
        this.title = title;
        this.content = content;
        this.releaseFlg = releaseFlg;
        changeCreatedBy(createdBy);
        setCreatedAt();
    }

    /***
     * ユーザーID設定
     */
    public void changeUserId(int userId) {
        //ユーザーIDが0よりも小さい場合
        validateUserId(userId);
        this.userId = userId;
    }

    /**
     * お気に入り数設定
     */
    public void addLikeCnt() {
        //ライク数をプラス1する
        this.likeCnt++;
    }

    /**
     * 閲覧数設定
     */
    public void addViewCnt() {
        //閲覧数をプラス1する
        this.viewCnt++;
    }

    /**
     * 投稿予約日設定
     * @return
     * @exception IllegalArgumentException
     */
    public void changeReservationDay(LocalDateTime reservationDay) {
        //現在時刻よりも前の場合
        validateReservationDay(reservationDay);
        this.reservationDay = reservationDay;
    }

    /***
     * ユーザーIDのチェック
     * @param userId
     * @exception IllegalArgumentException
     */
    private void validateUserId(int userId){
        //ユーザーIDが0よりも小さい場合
        if(userId < 0){
            throw new IllegalArgumentException("ユーザーIDが0よりも小さいです");
        }
    }

    /***
     * 予約日チェック
     * @param reservationDay
     * @exception IllegalArgumentException
     */
    private void validateReservationDay(LocalDateTime reservationDay){
        //現在時刻よりも前の場合
        if(reservationDay.isBefore(LocalDateTime.now(ZoneId.of("Asia/Tokyo")))){
            throw new IllegalArgumentException("予約日が現在時刻よりも前に設定されています");
        }
    }
}
```
### tagsテーブル
|tag_id|tagname|created_by|updated_by|created_at|updated_at|delete_flg|
|---|---|---|---|---|---|---|
|タグID|タグ名称|作成者|更新者|作成日|更新日|削除フラグ|
### tagエンティティ
```java
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@NoArgsConstructor
@Getter
@Table(name="tags")
public class Tag  extends BaseEntity {

    /***
     * タグID
     */
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name="tag_id")
    private int tagId;

    /***
    * タグ名称
    */
    @NotBlank
    @Size(max=50)
    @Column(name="tagname", length=50)
    @Setter
    private String tagName;

    /***
     * 初期化用コンストラクタ
     * @param tagName
     */
    public Tag(String tagName){
        //必須項目をコンストラクタで設定
        this.tagName = tagName;
        setCreatedAt();
    }

}
```

なお、今回はポートフォリオ用途であり、REST API構成においてエンティティの責務を明確に分離するため、@ManyToOne は使用せず、外部キー（userIdなど）だけを保持する構成としました。

# アノテーション
今回使用している各アノテーションに関して記載します
## `@Getter`
getterメソッドを自動で生成してくれるアノテーションです
クラスに付与することで、すべてのプロパティにgetterメソッドが付与されます

## `@Setter`
setterメソッドを自動で生成してくれるアノテーションです
今回はsetter不要なプロパティが存在するため、
必要な個所のみ@Setterを使用しました。

## `@Id`
エンティティの主キーを指定する。

## `@GeneratedValue`
主キーの生成方法を指定する。
GenerationType.IDENTITYは
データベースのID列(自動生成される列)を使用し、主キーを割り当てる必要があることを示す。

## `@NotBlank`
null,空白(全角含む)のみの場合、エラーを発生させる。
必ず文字列が1文字以上存在していることを確認する。
※文字列(String,StringBuilderなど)以外には使用できないので注意
使用してもなにもバリデーションは行いません。

## `@NotNull`
nullの場合エラーが発生する。
それ以外はエラーになることはない。

## `@Table`
nameを指定することで明示的にテーブル名称を指定できる。

## `@Column`
nameを指定することで明示的にカラム名称を指定できる。
columnDefinitionではカラムのデータ型を指定することができる。

## `@Embeddable`
このアノテーションを付与したクラスはEntityではなく、
ほかのEntityの一部として扱うことができる埋め込み可能なクラスとして定義されます。

## `@EmbeddedId`
`@Embeddable`を付与したクラスをEntity内部に埋め込むためのアノテーションです。
今回は複合主キーを埋め込むために使用しています。

## `@MappedSuperclass`
`@MappedSuperclass`を付与したクラスを継承するエンティティに対して
マッピング情報を提供する親クラスを指定するアノテーションです。
このクラス自体に対応するテーブルは存在せず、サブクラスに対してのみマッピングが適用されます。

## `@NoArgsConstructor`
引数なしのコンストラクタを追加してくれます。
JPAを使用する場合、引数内なしのコンストラクタが存在しない場合エラーになる。
そのため、アノテーションでコンストラクタを追加しておきましょう。

継承先でマッピング情報を展開し、使用します。
今回は`updated_at`や`created_by`など共通する項目が多く存在し、
共通管理するために基本となるEntityを作成しました。

# まとめ
今回はEntityに関して簡単にまとめて、
実際にEntityを作成しました。

各アノテーションに関して深堀していこうと考えていますが、
それはポートフォリオ作成が終わり次第調査しようと思います。

次回以降の予定
- Controller：HTTPリクエストの受け口
- Service：DTOとの変換やバリデーション
- Repository：今回作成したEntityを用いたDB操作

今回の記事が少しでもspring boot初学者の助けになれば幸いです。

# 参考
今回は「Entityの設計と役割」に焦点を当てた内容ですが、  
初回記事である「[機能整理と設計メモ](https://qiita.com/suica1010/items/1910e82dea147c19cddb)」もあわせてご覧ください。
[github:MNawata-coding](https://github.com/MNawata-coding)

[次：Spring Boot + Reactで記事投稿アプリを作成予定｜Repositoryを実装・解説してみた](https://qiita.com/suica1010/items/612440c7045e7ed71385)
[前：Spring Boot + Reactで記事投稿アプリを作成予定｜ER図でデータ構造を設計してみた](https://qiita.com/suica1010/items/38364c7a94d70a784bed)