GxPのやすば(@nyasba) です。
本記事はグロースエクスパートナーズアドベントカレンダー 2日目の記事です
弊社が得意とする継続的な開発のためには保守性が高いアプリケーションにしていく必要があり、今自分が関わっている案件では**DDD(ドメイン駆動設計)**を採用しています。
今日はJava/SpringプロジェクトでDDDを実践する際のアプリケーションのアーキテクチャ(レイヤ構造やパッケージ構成など)とテスト戦略についてまとめてみます。
※会社としても採用実績はありますが、あくまで個人の思想に基づく内容です。
はじめに
Springで開発している案件といっても、レイヤ構成やパッケージの切り方やそれに伴うテスト戦略は様々です。弊社内でも特に標準が定められているわけではありませんので、案件によって切り方が違うのが現状です。
パッケージの切り方自体はコードを見れば理解できるものですが、何をどこのパッケージで行うべきか、どのポイントでテストコードを書くべきかという 設計思想 をうまく伝えることが非常に難しいと感じています。そして、チームで開発する上で重要なことは、パッケージの切り方自体ではなく 設計思想 のほうです。
今まで自分の関わってきた案件では口頭で説明をしてきましたが、(自分の考えが正しいのかはおいておいて)チームメンバーに根底となる設計思想を共有する必要があると考え、記事としてまとめてみることにしました。
前提技術
前提としているアプリケーション構成です。
- Java/SpringBootでのAPIサーバ開発
- lombok
- OpenAPIでAPIドキュメントを管理し、openapi-generatorでコードを生成している
- ORMはMyBatis
- DDDの戦術的設計を行っている(戦略的設計は道半ば)
アプリケーションアーキテクチャ
よくある構造だと思いますが、以下のようなアーキテクチャ(オニオンアーキテクチャ)を採用しています。矢印は依存関係の方向を表しています。
DDDを実践しているため、ドメインがベースになった考え方をしています。ドメイン層から他の層への依存や、サービス層からインフラ層への依存などはNGです。ただ、この図だけ見ていても実装がイメージできないとおもいますので、それぞれのレイヤでどのようなコードを書いているかを例を交えて説明していきます。
※コードは雰囲気が理解できる程度に簡略化していますのでそのままでは動きません。
(1) ドメイン層
まずは、ドメイン層の説明です。ドメイン層は、ビジネスの概念やビジネスルールを管理する一番重要なレイヤーです。
主な構成要素としては、ValueObject、Entity、Repositoryなどがあります。
ValueObject
1つの値を管理するオブジェクトです。
public class UserId {
private final String value;
}
Entity
ここでのEntityはORMとは関係なく、DDDの文脈におけるEntityです。IDを持っているオブジェクトを表すものです。
public class UserEntity {
private final UserId userId;
private final UserName userName;
}
Repository
RepositoryはEntityなどドメインオブジェクトの取得や更新などのインターフェースのみをドメイン層として定義します。
public interface UserRepository {
UserEntity fetchById(UserId userId);
}
その実装クラスは、データの永続化をどのような技術で実施するのか(外部要因)に依存するため、インフラストラクチャ層で管理されます。
(2) サービス層
次はサービス層です。ここではユースケースを実現するロジックを書きます。
Entityの取得やチェック処理など実際はこんなにシンプルになることはありませんが、**ドメインのみを用いてユースケースを記載する(=引数も戻り値もドメインになる)**という点がポイントです。ドメイン以外を利用すると依存関係が崩れますし、何より業務ロジックに集中できなくなります。
@RequiredArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
public UserEntity fetchByUserId(UserId userId) {
return userRepository.fetchById(userId);
}
}
(補足) Serviceクラスのインターフェースは個人的にあまり必要性を感じないので作ってはいません。
(3) コントローラ層
コントローラ層はAPIのインターフェースに関するレイヤーです。
インターフェースを決定するドキュメントとしてOpenAPIを利用することが多く、モデルクラスはopenapi-generatorにて自動生成をしています。
Controller
APIのエンドポイントとなるクラスです。OpenAPIから生成したメソッドを一部切り出して実装しています。
リクエスト・レスポンス用のModelクラスとドメインの変換もコントローラー層の責務にすることで、サービス層がドメインに集中できるようにしています(ModelクラスをServiceに渡してしまうと依存関係が崩れます)
@RequiredArgsConstructor
@RestController
public class UserController {
private final UserService userService;
/**
* GET /api/user/{userId} : ユーザ情報取得API ログイン済のユーザ情報を取得する。
*
* @return ログイン済のユーザ情報 (status code 200) or Unauthorized (status code 401) or Forbidden (status code 403)
*/
@ApiOperation(value = "ユーザ情報取得API", nickname = "userGet", notes = "ログイン済のユーザ情報を取得する。", response = UserResponse.class, authorizations = { @Authorization(value = "bearerAuth") })
@ApiResponses(value = {
@ApiResponse(code = 200, message = "ログイン済のユーザ情報", response = UserResponse.class),
@ApiResponse(code = 401, message = "Unauthorized", response = AppError.class),
@ApiResponse(code = 403, message = "Forbidden", response = AppError.class)})
@RequestMapping(value = "/api/user/{userId}", method = RequestMethod.GET)
public ResponseEntity<UserResponse> userGet(@Valid @PathVariable("userId") String userId) {
var userEntity = userService.fetchByUserId(new UserId(userId));
return ResponseEntity.ok(UserResponseConverter.convert(userEntity));
}
}
Model
OpenAPIから自動生成したクラスをそのまま利用します。
schemas:
UserResponse:
type: "object"
properties:
id:
description: ユーザID
type: string
name:
description: ユーザ名
type: string
required:
- id
- name
↓ ↓ openapi-generatorで生成 ↓ ↓
/**
* UserResponse
*/
public class UserResponse {
@JsonProperty("id")
private String id;
@JsonProperty("name")
private String name;
public UserInfoResponse id(String id) {
this.id = id;
return this;
}
/**
* ユーザID
*
* @return id
*/
@ApiModelProperty(required = true, value = "ユーザID")
@NotNull
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public UserInfoResponse name(String name) {
this.name = name;
return this;
}
/**
* ユーザ名
*
* @return name
*/
@ApiModelProperty(required = true, value = "ユーザ名")
@NotNull
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
// 略
}
Converter
ModelとDomainオブジェクトとの変換を行うためのクラスです。必要に応じて作成しています。
public class UserResponseConverter {
public static UserResponse convert(UserEntity entity) {
return new UserResponse()
.id(entity.getUserId().getValue())
.name(entity.getUserName().getValue());
}
}
(4) インフラストラクチャ層
最後にインフラストラクチャ層です。
Mapper
ORMにはMyBatisを使っていますので、まずMapperのinterfaceとXMLを作成します
public interface UserMapper {
UserEntity selectById(@Param("userId") UserId userId);
}
<mapper namespace="jp.co.gxp.sample.infrastructure.mapper.UserMapper">
<select id="selectById" resultMap="UserEntity">
SELECT user_id, user_name
FROM users WHERE user_id = #{userId.value}
</select>
<resultMap id="UserEntity" type="jp.co.gxp.sample.domain.UserEntity">
<constructor>
<idArg resultMap="UserId" javaType="jp.co.gxp.sample.domain.UserId"/>
<arg resultMap="UserName" javaType="jp.co.gxp.sample.domain.UserName"/>
</constructor>
</resultMap>
一部省略
</mapper>
RepositoryDb(実装クラス)
その後、Repositoryの実装クラスにてMapperを呼び出すようにしています。今回の例のように、MyBatisのResultMapでEntityを生成できる場合は冗長に思えますが、1-Nの関係を持つEntityや環境変数の値を差し込む必要があるEntityでは一旦別のオブジェクトに入れてからEntityを生成する方が都合がよいケースもあります。
@RequiredArgsConstructor
@Repository
public class UserRepositoryDb implements UserRepository {
private final UserMapper userMapper;
@Override
public UserEntity findById(UserId userId) {
return batchMapper.selectById(userId);
}
}
アプリケーションアーキテクチャまとめ
これらを図にまとめるとこのような構造になります。
テスト戦略
アプリケーションのアーキテクチャが明確になったので、これに沿ってどんなテストを行うかを考えていきます。
(a) Entity
まずはEntityのテストです。DDDによりロジックをドメインに閉じ込めることにより、業務ロジックをEntityのメソッドレベルでテストすることができます。(ドメインレベルでテストができないと、サービスロジックで網羅的なテストを行う必要が出てきます)
例えば、姓名をまとめて返す処理をEntityに実装したとします(フィールド変数では姓・名それぞれを扱うように見直しました)
public class UserEntity {
private final UserId userId;
private final String lastName;
private final String firstName;
+ public String getName() {
+ return lastName + " " + firstName;
+ }
}
こういったものをService層で実装すると、userEntity.getLastName() + " " + userEntity.getFirstName()
という形になりますが、UserEntityの責務として実装することで姓名の生成ルールがドメインで管理されることになります。
テストを実施するために**Entityのテストデータ(Fixture)**を作成します
public class UserEntityFixture {
public static UserEntity USER_A() {
return new UserEntity(new UserId("A"), "GxP", "たろう");
}
}
このFixtureをもとにロジックのテストを書きます。
public class UserEntityTest {
@Test
void test() {
Assertions.assertEquals("GxP たろう", UserEntityFixture.USER_A.getName());
}
}
ちなみに、この姓名の結合ルールはミドルネームや言語特性なども考慮すると非常に複雑になりうるものです。こういったものをドメインで管理しておくことで保守性が高くなります
(b) Repository/Mapper
次にインフラストラクチャ層のテストです。これらはRepositoryのメソッドをもとにテストを実施します。
DBアクセスの場合
実際のデータベースを用いて、SQLおよびRepositoryDbの処理の検証を行います。データベースはインメモリデータベース(H2)を使うケースと、TestContainerで実際のデータベースを使うケース、いずれも弊社内での実績はありますが、複雑なSQL構文を使わない場合はインメモリデータベースで十分だと感じています。
テストコードはこのようになります
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ExtendWith(SpringExtension.class)
@MybatisTest
public class UserRepositoryDbTest {
private final UserRepository sut;
@Autowired
public UserRepositoryDbTest(UserRepository sut) {
this.sut = sut;
}
@Sql(scripts = {"classpath:data/db/user.sql"})
@Test
void findById() {
UserEntity actual = sut.fetchById(new UserId("A"));
Assertions.assertEquals(UserEntityFixture.USER_A(), actual);
}
/**
* テスト用設定
*/
@Configuration
@Import({UserRepositoryDb.class})
@MapperScan(basePackages = "jp.co.gxp.sample.infrastructure.mapper")
static class LocalTestContext {
}
}
テストデータはSQLで事前にinsertしていますが、Fixtureと整合性をとったデータセットにしておくことでシンプルなテストコードになるはずです。
外部API呼び出しの場合
外部のAPIをテストコードで呼ぶとテスト自体が安定しないため、API自体はMockを呼び出すようにします。こちらもFixtureを用いて検証します。
@Test
void apiCall() throws IOException {
MockRestServiceServer mock = MockRestServiceServer.bindTo(restTemplate).build();
mock.expect(method(HttpMethod.GET))
.andExpect(requestTo("https://test.gxp.jp/api/users/A"))
.andRespond(withStatus(HttpStatus.OK)
.contentType(MediaType.APPLICATION_JSON)
.body(new ClassPathResource("/data/user.json")));
UserEntity actual = sut.fetchById(new UserId("A"));
Assertions.assertEquals(UserEntityFixture.USER_A(), actual);
// Mockが呼ばれていることを検証
mock.verify();
}
(c) Service
Service層はユースケースロジックのテストです。RepositoryはMockにしてServiceクラス自体のテストを行います。ここでもFixtureがあることでMockの戻り値を簡単に設定できます。
@SpringBootTest
public class UserServiceTest {
private final UserService sut;
@MockBean
private UserRepository userRepositoryMock;
@Autowired
private UserServiceTest(UserService sut) {
this.sut = sut;
}
@Test
public void test() {
var userIdA = new UserId("A");
when(userRepositoryMock.fetchById(eq(userIdA)))
.thenReturn(UserEntityFixture.USER_A());
assertEquals(expected, sut.fetchByUserId(userIdA));
}
/**
* テスト用設定
*/
@Configuration
@Import({UserService.class})
static class LocalTestContext {
}
}
(d) Controller/Model
Controller/Modelについてはドキュメントから自動生成しているため、品質面でのリスクが低いことから個別のテストは実施していません Converter(DomainとModelの変換ロジック)のみ必要に応じてテストコードを書いています。
そのような方針にしていることもあり、Controllerから呼び出すServiceは1つにするようにしています。2つ呼び出すとその状態管理や整合性のチェックが必要になるためです。
テスト戦略まとめ
どの部分のテストでもFixtureをフル活用したテストになっていたかと思います。
プロダクトコードがドメイン層をベースにした依存関係で成り立っているため、テストコード側も**ドメインのテストデータ(Fixture)**をベースに成り立っています。Fixture自体はその場限りの適当なものではなく、実際に発生しうるデータセットとし、Serviceのテスト・Repositoryのテストそれぞれで共通のFixtureを利用することによりService・Repositoryを結合した観点でのテストも可能になります。
以上をまとめるとこのような形となります。
テスト対象 | 観点 | 備考 |
---|---|---|
(a) Entity | ドメインに実装された業務ロジックを検証する。 | |
(b) Repository / Mapper | SQL含めてDBアクセス部分を検証する。外部API呼び出しの検証も含む。 | DBはインメモリDBを使うことが多いが、TestContainerの利用実績もあり。外部APIはMockを利用。 |
(c) Service | ユースケースに沿った業務ロジックを検証する。 | Repository呼び出しはMockにする。 |
(d) Controller/Model | 実施しない | Converterのみ必要に応じて実施 |
APIとしての結合テスト | API全体で動作するかを確認する | 修正した部分のみ、Postmanなどで手作業で実施することが多い。呼出し元とまとめて実施することもある |
呼出し元との結合テスト | APIの呼出し元も含めて全体で動作するかを確認する |
(補足) Fixtureの管理について
実装が複雑化してくるにつれて、Fixtureのパターンも増えていきます。それをどうにかわかりやすく整理することも重要です。
IDやステータスなどのパラメータを引数にしてもらうことなども有用ではあるのですが、いろいろ悩ましい問題が残っています。Javaにデフォルト引数があったらなぁ・・とか、EntityはImutableにしたいがFixtureからはSetter使ったほうが可読性があがるんだけどなぁ・・などなど
--
いくつかの案件の経験を経て辿り着いた現時点での自分の設計でした。これが何かの参考になれば幸いです