REST API を実装していると、Entity をそのままレスポンスに返したくなる気持ち、わかりますよね。でも一度やってしまうと、@JsonIgnore が増え、意図しないフィールドが外に漏れ、循環参照でスタックオーバーフロー……という体験をすることになります。

かといって toDto() / toEntity() の手書きも辛い。フィールドが増えるたびに追記し、リファクタリングで型が変わるたびに修正し、テストは面倒で後回し。このボイラープレートを MapStruct で自動生成してしまいましょう。

なぜEntityをそのままレスポンスに返してはいけないのか

Entity には DB の都合が詰まっています。パスワードハッシュ、他テーブルへのリレーション、監査カラムなど、API クライアントには不要なものが大量に含まれていますよね。DTO を挟むことでレスポンスの形を API 設計として独立させられます。

手書きの変換メソッドが増えると、フィールド追加時の更新漏れ・複数パターンの乱立・テスト不足という問題が起きがちです。MapStruct はコンパイル時にインターフェースの実装クラスを生成するため、実行時オーバーヘッドがなく、生成コードは素直な Java なのでデバッグもしやすいです。

MapStructとは

MapStruct は アノテーションプロセッサ ベースのマッピングライブラリです。ModelMapper もよく比較対象に挙がりますが、あちらはリフレクションベースの実行時マッピングです。性能やデバッグのしやすさで MapStruct を選ぶプロジェクトが多いです(詳細比較は別の記事に譲ります)。

依存の追加とAnnotation Processorの設定

Maven

<dependency>
  <groupId>org.mapstruct</groupId>
  <artifactId>mapstruct</artifactId>
  <version>1.5.5.Final</version>
</dependency>

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <configuration>
    <annotationProcessorPaths>
      <!-- Lombokを使う場合はLombokを先に書く -->
      <path>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>${lombok.version}</version>
      </path>
      <path>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct-processor</artifactId>
        <version>1.5.5.Final</version>
      </path>
    </annotationProcessorPaths>
  </configuration>
</plugin>

Lombok と MapStruct を両方使う場合は、Lombok を先に宣言するのが重要です。順序を間違えると Lombok が生成する getter/setter を MapStruct が認識できなくなります。

Gradle

dependencies {
  implementation 'org.mapstruct:mapstruct:1.5.5.Final'
  annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
  // Lombokを使う場合
  compileOnly 'org.projectlombok:lombok'
  annotationProcessor 'org.projectlombok:lombok'
}

基本的な@Mapperの使い方

まず Entity と DTO を定義します。

@Entity
public class User {
  @Id @GeneratedValue
  private Long id;
  private String userName;
  private String email;
  // getter/setter 省略
}

public class UserDto {
  private Long id;
  private String name;   // フィールド名が異なる
  private String email;
  // getter/setter 省略
}

次に Mapper インターフェースを作ります。

@Mapper(componentModel = "spring")
public interface UserMapper {

  @Mapping(source = "userName", target = "name")
  UserDto toDto(User user);

  @Mapping(source = "name", target = "userName")
  User toEntity(UserDto dto);
}

componentModel = "spring" を付けると、MapStruct が @Component を付与した実装クラスを生成し、Spring Bean として DI できるようになります。フィールド名が一致している idemail@Mapping なしで自動マッピングされます。

フィールド名が異なる場合のマッピング指定

@Mapping(source = "userName", target = "name") で明示的に対応を指定します。複数フィールドにまたがる場合はアノテーションを重ねるだけです。マッピング不要なフィールドは ignore = true で除外できます。

@Mapping(source = "userName", target = "name")
@Mapping(target = "password", ignore = true)
UserDto toDto(User user);

ネストしたオブジェクトのマッピング

UserAddress を持つ構成で考えてみましょう。子オブジェクト用の Mapper を別で定義して uses で組み合わせるのがシンプルなやり方です。

@Mapper(componentModel = "spring")
public interface AddressMapper {
  AddressDto toDto(Address address);
}

@Mapper(componentModel = "spring", uses = AddressMapper.class)
public interface UserMapper {
  UserDto toDto(User user);
}

フラット化したい場合(address.city を DTO の city フィールドに直接マッピング)は、ドット記法で指定できます。

@Mapping(source = "address.city", target = "city")
UserDto toDto(User user);

カスタム変換ロジックの追加

単純なフィールドコピーでは対応できないケースは、default メソッドと @Named で対応します。

@Mapper(componentModel = "spring")
public interface UserMapper {

  @Mapping(source = "birthDate", target = "birthDateStr", qualifiedByName = "dateToString")
  UserDto toDto(User user);

  @Named("dateToString")
  default String dateToString(LocalDate date) {
    return date == null ? null : date.format(DateTimeFormatter.ISO_LOCAL_DATE);
  }
}

インターフェースに default メソッドを書けるので、実装クラスを別途作らなくて済みます。

ListのマッピングはMapper定義一行だけ

List<UserDto> toDtoList(List<User> users);

これだけです。MapStruct が内部でループを回す実装を生成してくれます。生成コードを見ると、for-each ループで単体の toDto() を呼び出しているだけの素直なコードになっています。

Serviceへの注入

@Service
@RequiredArgsConstructor
public class UserService {
  private final UserRepository userRepository;
  private final UserMapper userMapper;

  public UserDto getUser(Long id) {
    User user = userRepository.findById(id)
        .orElseThrow(() -> new EntityNotFoundException("User not found"));
    return userMapper.toDto(user);
  }
}

componentModel = "spring" を設定していれば、通常の Spring Bean として注入できます。REST API の CRUD 実装 と組み合わせると、Controller → Service → Mapper という層が自然にきれいに分かれます。

生成コードの確認

ビルド後に target/generated-sources/annotations/ 配下に UserMapperImpl.java が生成されます。手書きと変わらない素直な Java コードなので、マッピングの挙動を確認したいときはここを見るのが一番早いです。

IntelliJ IDEA では、このディレクトリを右クリックして「Mark Directory as → Generated Sources Root」に設定しておくと、IDE が生成クラスを補完対象として認識してくれます。

MapperImplでユニットテストを書く

Spring コンテキストを起動せずにテストできます。

class UserMapperTest {

  private final UserMapper mapper = new UserMapperImpl();

  @Test
  void toDto_フィールドが正しくマッピングされる() {
    User user = new User();
    user.setId(1L);
    user.setUserName("tanaka");
    user.setEmail("[email protected]");

    UserDto dto = mapper.toDto(user);

    assertThat(dto.getId()).isEqualTo(1L);
    assertThat(dto.getName()).isEqualTo("tanaka");
    assertThat(dto.getEmail()).isEqualTo("[email protected]");
  }
}

new UserMapperImpl() で直接インスタンスを作るだけです。ネスト子 Mapper がある場合はコンストラクタ引数に渡します(new UserMapperImpl(new AddressMapperImpl()))。

ビルドが通らないときの確認ポイント

MapperImpl が生成されない場合は、maven-compiler-pluginannotationProcessorPathsmapstruct-processor が入っているかを確認しましょう。<dependencies> に追加するだけでは動きません。

「No property named ‘xxx’」というエラーは source または target のスペルミスが原因です。Lombok の getter が生成されていない場合も同じエラーになるので、依存の宣言順序も合わせて確認してください。

まとめ

MapStruct を使うと、手書きの変換コードをインターフェース定義だけに置き換えられます。コンパイル時生成なので型安全で、生成コードが素直な Java なのでデバッグも困りません。JPA のエンティティ設計ページネーション実装 と組み合わせると、API 層全体のコードがすっきり整理されます。まずは依存を追加して、一番シンプルな Mapper から試してみてください。