// 実行環境
* AdoptOpenJDK 11.0.9.1+1
* Spring Boot 2.4.0
* JUnit 5.7.0
* ArchUnit 0.14.1
レイヤーの依存関係
基本的な考え方は、23 日目の ArchUnit 実践:Onion Architecture のアーキテクチャテスト のオニオンアーキテクチャと同じ。
- 依存の方向は外側の層から内側の層への一方通行
- アダプター同士は依存しない
- Interface Adapters 層と Application Business Rules 層の関係について、制御の方向と依存の方向が逆転している
Java プロジェクトのパッケージ構成
レイヤーとパッケージの対応関係
- Enterprise Business Rules レイヤー
- domain パッケージ
- Application Business Rules レイヤー
- application パッケージ
- Interface Adapters レイヤー
- Controllers アダプター
- api パッケージ
- Gateways アダプター
- persistence パッケージ
-
Presenters アダプター- Web アプリケーションフレームワークに Spring Boot を使用する想定で、レスポンスデータのレンダリングはフレームワークに任せることになるため、実装は省略する
- Controllers アダプター
Controller の実装イメージ
package com.example.api.employee;
import com.example.application.employee.EmployeeEditInputData;
import com.example.application.employee.EmployeeEditOutputData;
import com.example.application.employee.EmployeeEditUseCase;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class EmployeeController {
private final EmployeeEditUseCase editUseCase;
public EmployeeController(final EmployeeEditUseCase editUseCase) {
this.editUseCase = editUseCase;
}
@PutMapping("/employees/{id}")
public ResponseEntity<EmployeeEditResponse> edit(
@PathVariable final int id,
@RequestBody final EmployeeEditRequest request
) {
EmployeeEditInputData inputData = request.toInputData(id);
EmployeeEditOutputData outputData = editUseCase.execute(inputData);
EmployeeEditResponse response = EmployeeEditResponse.fromOutputData(outputData);
return ResponseEntity.ok(response);
}
}
アーキテクチャテストの実装
23 日目の ArchUnit 実践:Onion Architecture のアーキテクチャテスト に、Interface Adapters 層と Application Business Rules 層の関係(ControllerはUseCaseに実処理を委譲する
)を表現するアーキテクチャテストを追加。
package com.example;
import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.AccessTarget;
import com.tngtech.archunit.core.domain.JavaAccess;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.domain.JavaMethod;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods;
import static com.tngtech.archunit.library.Architectures.onionArchitecture;
class ArchitectureTest {
// 検査対象のクラス
private static final JavaClasses CLASSES =
new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("com.example");
@Test
void パッケージの依存関係() {
onionArchitecture()
// Enterprise Business Rules
.domainModels("com.example.domain.model..")
.domainServices("com.example.domain.service..")
// Application Business Rules
.applicationServices("com.example.application..")
// Interface Adapters
.adapter("controllers", "com.example.api..")
.adapter("gateways", "com.example.persistence..")
.check(CLASSES);
}
@Test
void ControllerはUseCaseに実処理を委譲する() {
classes()
.that()
.resideInAPackage("com.example.api..")
.and().areAnnotatedWith(RestController.class)
.should(new ArchCondition<>("delegate the process to UseCases") {
@Override
public void check(final JavaClass controller, final ConditionEvents events) {
JavaConstructor constructor = controller.getConstructors()
.stream().findFirst().orElseThrow();
boolean isUseCaseInjectedIntoConstructor = constructor.getRawParameterTypes()
.stream().anyMatch(this::isUseCase);
if (! isUseCaseInjectedIntoConstructor) {
// UseCase がコンストラクタインジェクションされていない場合
events.add(SimpleConditionEvent.violated(controller, String.format(
"`%s` does not have the parameter type of UseCase.", constructor.getFullName())));
return;
}
controller.getMethods().stream()
.filter(this::isRequestHandler)
.forEach(handler -> {
boolean doesHandlerExecuteUseCase = handler.getMethodCallsFromSelf()
.stream().anyMatch(this::isUseCase);
if (! doesHandlerExecuteUseCase) {
// ハンドラーメソッドが UseCase を実行していない場合
events.add(SimpleConditionEvent.violated(controller, String.format(
"`%s` does not execute the method of UseCase.", handler.getFullName())));
}
});
}
private boolean isUseCase(final JavaClass clazz) {
return isUseCaseInterface(clazz) && clazz.getMethods().stream().anyMatch(this::isUseCaseMethod);
}
private boolean isUseCase(final JavaMethodCall methodCall) {
return isUseCaseInterface(methodCall.getTargetOwner()) && isUseCaseMethod(methodCall.getTarget());
}
private boolean isUseCaseInterface(final JavaClass clazz) {
return clazz.getPackageName().startsWith("com.example.application")
&& clazz.getSimpleName().endsWith("UseCase")
&& clazz.isInterface();
}
private <T extends HasParameterTypes & HasReturnType> boolean isUseCaseMethod(final T method) {
return method.getRawParameterTypes().size() == 1
&& method.getRawParameterTypes().get(0).getSimpleName().endsWith("InputData")
&& method.getRawReturnType().getSimpleName().endsWith("OutputData");
}
private boolean isRequestHandler(final JavaMethod method) {
return method.isAnnotatedWith(RequestMapping.class)
|| method.isAnnotatedWith(GetMapping.class)
|| method.isAnnotatedWith(PostMapping.class)
|| method.isAnnotatedWith(PutMapping.class)
|| method.isAnnotatedWith(PatchMapping.class)
|| method.isAnnotatedWith(DeleteMapping.class);
}
})
.check(CLASSES);
}
}
アーキテクチャテストの実行例
テスト失敗例①
Controller クラスが UseCase インターフェイスではなく、その具象クラスに依存していまっている、というアーキテクチャ違反を検知した想定でのテスト失敗例。
$ ./gradlew clean check
> Task :test
ArchitectureTest > ControllerはUseCaseに実処理を委譲する() FAILED
java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'classes that reside in a package 'com.example.api..' and are annotated with @RestController should delegate the process to UseCases' was violated (1 times):
`com.example.api.employee.EmployeeController.<init>(com.example.application.employee.EmployeeEditInteractor)` does not have the parameter type of UseCase.
at com.tngtech.archunit.lang.ArchRule$Assertions.assertNoViolation(ArchRule.java:94)
at com.tngtech.archunit.lang.ArchRule$Assertions.check(ArchRule.java:82)
at com.tngtech.archunit.lang.ArchRule$Factory$SimpleArchRule.check(ArchRule.java:198)
at com.tngtech.archunit.lang.syntax.ObjectsShouldInternal.check(ObjectsShouldInternal.java:81)
at com.example.ArchitectureTest.ControllerはUseCaseに実処理を委譲する(ArchitectureTest.java:675)
2 tests completed, 1 failed
テスト失敗例②
Controller のハンドラーメソッドが、UseCase のメソッドを実行していない(=UseCase に処理を委譲していない)、というアーキテクチャ違反を検知した想定でのテスト失敗例。
$ ./gradlew clean check
> Task :test
ArchitectureTest > ControllerはUseCaseに実処理を委譲する() FAILED
java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'classes that reside in a package 'com.example.api..' and are annotated with @RestController should delegate the process to UseCases' was violated (1 times):
`com.example.api.employee.EmployeeController.edit(int, com.example.api.employee.EmployeeEditRequest)` does not execute the method of UseCase.
at com.tngtech.archunit.lang.ArchRule$Assertions.assertNoViolation(ArchRule.java:94)
at com.tngtech.archunit.lang.ArchRule$Assertions.check(ArchRule.java:82)
at com.tngtech.archunit.lang.ArchRule$Factory$SimpleArchRule.check(ArchRule.java:198)
at com.tngtech.archunit.lang.syntax.ObjectsShouldInternal.check(ObjectsShouldInternal.java:81)
at com.example.ArchitectureTest.ControllerはUseCaseに実処理を委譲する(ArchitectureTest.java:673)
2 tests completed, 1 failed