SpringプロジェクトでDTOとEntityクラスを作る際、Lombokアノテーションを習慣的に付けていませんか?
@Getter、@Setter、@NoArgsConstructor、@RequiredArgsConstructorなど便利なアノテーションが多くありますが、DTOとEntityは役割が異なるため、適用すべきアノテーションも異なります。
この記事では各アノテーションの動作を整理し、DTOとEntityにどのように適用するのが適切かをコード例と一緒に見ていきます。
まず、各アノテーションが何をするかを整理しよう#
@Getter#
すべてのフィールドに対してgetterメソッドを生成します。
@Getter
public class Product {
private String name;
private double price;
}
// 以下のコードが自動生成される
// public String getName() { return this.name; }
// public double getPrice() { return this.price; }@Setter#
すべてのフィールドに対してsetterメソッドを生成します。
@Setter
public class Product {
private String name;
private double price;
}
// 以下のコードが自動生成される
// public void setName(String name) { this.name = name; }
// public void setPrice(double price) { this.price = price; }@NoArgsConstructor#
パラメーターなしのデフォルトコンストラクターを生成します。
@NoArgsConstructor
public class Product {
private String name;
private int price;
}
// 以下のコードが自動生成される
// public Product() {}accessオプションでアクセス修飾子を設定できます。
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {
private String name;
private int price;
}
// 以下のコードが自動生成される
// protected Product() {}@RequiredArgsConstructor#
finalフィールドのみをパラメーターとして受け取るコンストラクターを生成します。
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
private String tempValue; // finalでないのでコンストラクターのパラメーターに含まれない
}
// 以下のコードが自動生成される
// public OrderService(OrderRepository orderRepository, PaymentService paymentService) {
// this.orderRepository = orderRepository;
// this.paymentService = paymentService;
// }@AllArgsConstructor#
すべてのフィールドをパラメーターとして受け取るコンストラクターを生成します。
@AllArgsConstructor
public class User {
private String username;
private int age;
private String email;
}
// 以下のコードが自動生成される
// public User(String username, int age, String email) {
// this.username = username;
// this.age = age;
// this.email = email;
// }Entityにはどのアノテーションを使うべきか?#
推奨の組み合わせ#
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String email;
@Column(nullable = false)
private String name;
@Builder
public Member(String email, String name) {
this.email = email;
this.name = name;
}
}なぜこの組み合わせなのか?#
1. @Getter - 使用
Service、ControllerなどでEntityのデータをアクセスするときはGettingメソッドが必要です。
2. @Setter - 使用しない
Entityに@Setterを開放するとどこからでもEntityの状態を変更できるようになります。
// @Setterがあるとこんなコードが可能になる - 危険!
member.setName("newName");
member.setEmail("new@email.com");こうなるとEntityの状態がいつ、どこで、なぜ変更されたかを追跡しにくくなります。
代わりに、意味のあるビジネスメソッドを通じて状態を変更すべきです。
@Entity
public class Member {
// ... フィールド省略
// setterの代わりに意味のあるメソッドを定義
public void updateProfile(String name, String email) {
this.name = name;
this.email = email;
}
public void deactivate() {
this.active = false;
this.deactivatedAt = LocalDateTime.now();
}
}setName()という名前だけではこの変更がどのビジネスコンテキストで発生するのか分かりませんが、updateProfile()やdeactivate()は意図が明確です。
3. @NoArgsConstructor(access = AccessLevel.PROTECTED) - 使用
JPAの仕様上、EntityクラスはJPAがリフレクションを通じてオブジェクトを生成するため、デフォルトコンストラクター(no-args constructor)が必須です。
しかしpublicで開放すると、どこからでも空のEntityオブジェクトを作れるため、AccessLevel.PROTECTEDで制限します。
// publicにするとこんなコードが可能になる - 不完全なオブジェクト生成の危険
Member member = new Member(); // すべてのフィールドがnull
// protectedに制限すると外部から直接呼び出し不可
// 同じパッケージまたは継承関係でのみ使用可能(JPAが使用する範囲)4. @RequiredArgsConstructor - 使用しない
EntityはSpring Beanではないため、依存性注入は不要です。finalフィールドを使う機会もほとんどないため@RequiredArgsConstructorはEntityには適していません。
またJPA EntityのフィールドをJPAがリフレクションでオブジェクトを生成した後にフィールド値をセットできなくなります。
5. @AllArgsConstructor - 使用しない
すべてのフィールドのコンストラクターを開放すると、idのように自動生成されるべきフィールドまで外部から注入できるようになります。フィールドの順序が変わるとコンパイルエラーなしに値が入れ替わるバグも発生しえます。
代わりに@Builderを使って必要なフィールドのみを明示的に設定するのが安全です。
DTOにはどのアノテーションを使うべきか?#
DTOはデータを転送することが目的のオブジェクトです。Entityとは性質が異なるため、異なるアノテーションが必要です。
リクエスト(Request)DTO#
@Getter
@NoArgsConstructor
public class MemberCreateRequest {
@NotBlank(message = "メールアドレスは必須です")
private String email;
@NotBlank(message = "名前は必須です")
private String name;
}なぜこの組み合わせなのか?
- @Getter - 使用: ControllerからServiceへデータを渡すときにフィールドの値を読む必要があります。
- @Setter - 不使用: リクエストデータは一度バインドされると変更する理由がありません。
- @NoArgsConstructor - 使用: Jacksonはデフォルトコンストラクターでオブジェクトを生成してから、リフレクションでフィールドの値をバインドします。
publicなデフォルトコンストラクターが必要です。
Jacksonのデシリアライズの流れ:
- デフォルトコンストラクターで空のオブジェクトを生成
- JSONフィールドに対応するJavaフィールドにリフレクションで値を注入
ただし@Setterが必要な場合もある - @ModelAttribute#
上で@RequestBodyでJSONを受け取るときは@Setterが必要ないと言いました。しかし**@ModelAttributeでフォームデータやクエリパラメーターをバインドする場合は話が変わります。**
SpringのDataBinderはデフォルトコンストラクターでオブジェクトを作った後、setterメソッドを呼び出して値を注入します。リフレクションでフィールドに直接アクセスするJacksonとは動作方式が異なります。
// GET /members?name=山田太郎&email=yamada@test.com
@GetMapping("/members")
public ResponseEntity search(@ModelAttribute MemberSearchRequest request) {
// ...
}@Getter
@Setter // @ModelAttributeバインドに必要!
@NoArgsConstructor
public class MemberSearchRequest {
private String name;
private String email;
}@SetterがないとDataBinderが値を注入できず、すべてのフィールドがnullのままになります。
リクエストDTOで@Setterを使うかどうかはどんな方式でデータを受け取るかによって変わります。無条件に@Setterを除外するのではなく、バインドのメカニズムを理解して必要な場合に適用することが重要です。
レスポンス(Response)DTO#
@Getter
@RequiredArgsConstructor
public class MemberResponse {
private final Long id;
private final String email;
private final String name;
public static MemberResponse from(Member member) {
return new MemberResponse(
member.getId(),
member.getEmail(),
member.getName()
);
}
}または@Builderパターンを活用することもできます。
@Getter
@Builder
public class MemberResponse {
private final Long id;
private final String email;
private final String name;
public static MemberResponse from(Member member) {
return MemberResponse.builder()
.id(member.getId())
.email(member.getEmail())
.name(member.getName())
.build();
}
}なぜこの組み合わせなのか?
- @Getter - 使用: レスポンスJSONにシリアライズするときに必要です。
- @RequiredArgsConstructor - 使用: レスポンスDTOは生成時点ですべてのデータが確定するため、
finalフィールド +@RequiredArgsConstructorで不変オブジェクトを作るのが良いです。 - @Setter - 不使用: レスポンスデータは一度生成されたら変更されるべきではありません。
- @NoArgsConstructor - 不使用: レスポンスDTOはJacksonのデシリアライズ対象でないため、デフォルトコンストラクターは不要です。
Java 16+ならRecordを検討しよう#
Java 16から導入されたrecordを使うと、Lombokなしでも不変なDTOを簡潔に作れます。
public record MemberResponse(
Long id,
String email,
String name
) {
public static MemberResponse from(Member member) {
return new MemberResponse(
member.getId(),
member.getEmail(),
member.getName()
);
}
}recordは自動的にgetter、equals()、hashCode()、toString()を生成し、すべてのフィールドがfinalです。Spring Data JPAでもDTO Projectionとしてrecordをサポートしています。
public record UserDto(String firstname, String lastname) {}
interface UserRepository extends Repository<User, Long> {
@Query("SELECT u FROM User u WHERE u.lastname = :lastname")
List<UserDto> findByLastname(String lastname);
}