実装ガイドライン
「なぜこの設計か」はADRを見れば済むので書かない。ここに書くのは「このコードベースで具体的にどう書くか」だけ。
前提(土台の決まり)
Section titled “前提(土台の決まり)”- Java 25 / Spring Boot 4。ビルドは
./gradlew(Mavenは使わない)。 - 層構成とパッケージ責務は architecture.md と各
package-info.javaを読む。 - 全体
@NullMarked(JSpecify)。null可の箇所だけ@Nullable。Javadocは///(Markdownコメント)。 - 依存方向は
ArchitectureTest(ArchUnit)が機械的に強制する。
1リクエストの流れ
Section titled “1リクエストの流れ”Handler (生成 XxxApi を implements) → UseCase.execute(...) // usecase → TransactionManager.execute(() -> ...) // トランザクション境界 → Repository(port) // → infra 実装(Mapper + 行レコード変換) → ResponseMapper.from(domainObject) // ドメイン → 生成DTO例外はドメイン例外として投げ、GlobalExceptionHandler がHTTP(Problem Details)へ変換する。
ハンドラー・ユースケースでHTTPステータスを直接組み立てない。
新しいドメイン/機能を起こす手順
Section titled “新しいドメイン/機能を起こす手順”- ドメインモデル: 集約・値オブジェクトを
domain.modelに作る。生成時に不変条件を検証し、不正値を存在させない。状態を持つならsealed interface(下記「状態を持つ集約」)。 複数の集約・値オブジェクトにまたがるビジネスルールはdomain.serviceに置く。 - ポート: 永続化や外部I/Oのインターフェースを
domain.portに定義する。戻り値がないときOptional、異常時の例外をJavadocで契約化する(see:TransactionManager)。 - ユースケース:
usecaseに1ユースケース1クラス(execute)。Portの呼び出し順序と結果の受け渡しに徹し、ビジネスルールの判定・計算は書かない。 - インフラ:
Mapperインターフェースとmapper/*.xml、DB行レコードとドメインの変換を実装する。DataAccessExceptionをRepositoryExceptionにラップする。 - OpenAPI(スキーマファースト):
docs/openapi/*.yamlにエンドポイントとスキーマを定義し、./gradlew openApiSyncToSrcでgenerated/(XxxApiインターフェース + DTO)を生成する(ADR-010)。生成コードは手編集しない。 - ハンドラー: 生成された
XxxApiをimplementsする@RestController。入力は生成DTO、出力はMapperで生成DTOへ変換する(handler.response)。検証は後述。エラーは投げるだけ。 - DI登録:
config/AppConfigに@Beanを追加(自動スキャンは使わない)。 - テスト: ユースケース単体(Mockito)と統合(Testcontainers)。
ドメインサービスとビジネスロジックの配置
Section titled “ドメインサービスとビジネスロジックの配置”| 処理 | 配置先 |
|---|---|
| 単一集約の状態遷移・値の導出 | domain.model のメソッド |
| 主体となる集約があり、他方を引数にできる操作 | 主体側エンティティのメソッド |
| 対等な複数オブジェクトの判定・計算 | domain.service |
| ドメインオブジェクトに属さない共通の値変換・表示 | domain.common |
ユースケースにはビジネスルールを書かない(ADR-013)。ただし、存在確認や空判定などの軽微な分岐はよい。
ユースケース内での複数のドメインオブジェクトを比較する if や for は、domain.service への切り出しを検討する。
// 疑うべき例(判定条件が複数のドメインオブジェクトを跨ぐ)for (var other : candidates) { if (other.altitude().overlaps(plan.altitude()) && other.period().overlaps(plan.period())) { throw new ConflictException(...); }}
// 是正後(判定をdomain.serviceへ切り出す。UseCaseに残るif文は判定結果を見るだけ)if (overlapChecker.hasConflict(plan, candidates)) { throw new ConflictException(...);}他のBeanに依存しないドメインサービスは static メソッド、依存するものはコンストラクタ注入のクラスにする。
後者は AppConfig に登録し、Springアノテーションを付けない。
public final class OverlapChecker { private OverlapChecker() {}
public static boolean overlaps(FlightPlan a, FlightPlan b) { ... }}結果を真偽や既存の値オブジェクトで表せない場合は型を作る。複数の値なら record、種類ごとに値が異なるなら sealed interface を使う。
ユースケース設計とトランザクション境界
Section titled “ユースケース設計とトランザクション境界”既定は1UseCase = 1集約 = 1トランザクション(ADR-013)。@Transactional は使わず TransactionManager.execute で境界を引く。
return transactionManager.execute(() -> { var entity = repository.findById(id).orElseThrow(() -> new NotFoundException(id)); return repository.save(entity.doSomething());});正常終了でコミット、例外が伝播すればロールバック(ドメイン例外はすべて非チェック例外なので対象)。
トランザクション属性の上書き(分離レベル・readOnly)
Section titled “トランザクション属性の上書き(分離レベル・readOnly)”既定はグローバル設定(app.transaction.* = TransactionProperties)。特定ユースケースだけ変えたいときは execute に TransactionOptions を渡す(TransactionOptions・IsolationLevel は domain.port、Spring非依存)。
まず行ロックで足りないかを問う。特定行の排他なら findByIdForUpdate(ADR-019)で済む。範囲・集計の一貫読み取り(ファントム回避)が要るときだけ分離レベルを上げる。グローバル設定は無関係なユースケースまで巻き込むため、per-callで上書きする。
// 集計の一貫読み取りで REPEATABLE_READ にしたいユースケースだけ上書き(グローバルは変えない)return transactionManager.execute( TransactionOptions.withIsolation(IsolationLevel.REPEATABLE_READ), () -> aggregateRepository.summarize(cmd));
// 読み取り専用の最適化return transactionManager.execute( TransactionOptions.forReadOnly(), () -> reportRepository.find(cmd));IsolationLevel.DEFAULT とタイムアウト:0はグローバル既定にフォールバックする。
状態を持つ集約(パターン例)
Section titled “状態を持つ集約(パターン例)”ステートマシンならsealed interfaceで状態ごとに型を分け、遷移メソッドをその状態にだけ置く(不正遷移をコンパイルで防ぐ)。取得→検証→保存の定型は共通ヘルパーに集約し、findByIdForUpdate(行ロック、ADR-019)で取得する。
すべての集約がステートマシンとは限らない。状態を持たない集約はこのパターンを使わない。
複合ユースケース(複数集約をまたぐ)— ADR-021
Section titled “複合ユースケース(複数集約をまたぐ)— ADR-021”複数集約を更新したくなったら、「整合性の強さ」と「境界」で判断する(方針は1つに絞らずケースバイケース。判断軸の根拠はADR-021)。
(a) 同一DBで強整合が必須 → 1つの execute で複数リポジトリを束ねる「複合ユースケース」として識別する。
// 理由: 計画の承認と機体の割り当ては同時に成立すべき不変条件(二重割り当て禁止)return transactionManager.execute(() -> { // ロック取得順序を一定(型/ID 順)にしてデッドロックを避ける(ADR-019) var plan = planRepository.findByIdForUpdate(cmd.planId()).orElseThrow(...); var aircraft = aircraftRepository.findByIdForUpdate(cmd.aircraftId()).orElseThrow(...); var approved = plan.approveWith(aircraft); // またがる不変条件をここで満たす planRepository.save(approved); aircraftRepository.save(aircraft.assignTo(approved.id())); return approved;});複合ユースケースには「なぜ1トランザクションが必要か(どの不変条件か)」をコメントで書く。理由のない巨大トランザクションは避ける。
(b) 別サービス/別DB、または即時整合が不要 → 結果整合にする。集約ごとにローカルトランザクションでコミットし、ドメインイベントで伝播する。
return transactionManager.execute(() -> { var saved = planRepository.save(Plan.submit(cmd)); eventPublisher.publish(new PlanSubmitted(saved.id())); return saved;});ポートを domain.port に定義し、実体はOutbox(同一トランザクションでイベント行も書き、別プロセスが配信)で実装する(ADR-006)。
結果整合が要るサービスで追加する。境界をまたぐ更新に分散トランザクション(2PC)は使わない。
判断の早見:
- またがる不変条件を即時に満たす必要があり、同一DB → (a) 複合ユースケース(1Tx)
- それ以外(別DB/サービス、または最終的整合で十分) → (b) 結果整合(イベント+Outbox)
外部連携を伴う状態遷移 — ADR-022
Section titled “外部連携を伴う状態遷移 — ADR-022”状態遷移が外部システムの成否に依存する場合(「外部へ連絡できたら遷移する」)、 トランザクションの中で外部I/Oを呼ばない(ロック長期保持・dual write・ロールバック不能)。連携の向きで分ける。
DB確定 → 外部通知(一方向): 状態遷移とイベント行を同一トランザクションでコミットし、Outboxで外部へ送る(ADR-006)。
外部の成否 → 遷移(同期依存): ステートマシンに「外部待ち」の中間状態を組み込み、3段で書く。
// 遷移1: 申請中へ(ローカルTx、ここでロック)transactionManager.execute(() -> { var plan = planRepository.findByIdForUpdate(id).orElseThrow(...); return planRepository.save(plan.requestApproval()); // → Pending});
// 外部呼び出し: トランザクション外。冪等キー・リトライ・タイムアウト前提(ADR-020)var result = externalApprovalClient.submit(id, idempotencyKey);
// 遷移2: 応答で確定(別Tx、再取得。並行で状態が変わりうる)transactionManager.execute(() -> { var plan = planRepository.findByIdForUpdate(id).orElseThrow(...); if (!(plan instanceof Plan.Pending p)) throw new InvalidTransitionException(...); return planRepository.save(result.accepted() ? p.confirm() : p.fail());});外部クライアントは domain.port にインターフェース(例 ExternalApprovalClient)として定義し、実体はinfraに置く。
呼び出しはトランザクション外で行う。非同期コールバックの場合、遷移2は別のPrimary Adapter
(コールバック受信ハンドラー)が相関IDで対象を引いて行う(本リポジトリでは未実装)。
判断: 外部の応答を遷移条件にするなら中間状態+Saga。単なる通知ならOutbox。
検証の配置(OAS制約 / 構文parse / 意味的parse)
Section titled “検証の配置(OAS制約 / 構文parse / 意味的parse)”入力検証はOAS(スキーマファースト・ADR-010)を起点に3段で考える。生成DTOに手で制約やparseを足さない。
- 単純な制約(必須・長さ・範囲・正規表現)→ OASに書く。生成DTOに
@NotNull・@Size・@Patternなどが付き、ハンドラー引数の@Validで効く。違反はGlobalExceptionHandlerが422(errors付き)に変換する。title:type: stringminLength: 1maxLength: 255pattern: '.*\S.*' # 空白のみを拒否 - 構文parse(
String→LocalDate・UUIDなどの基本型)→ OASのformat(date・uuid)で生成DTOがその型を持ち、Jacksonがparseする。形式不正はパース失敗 = 400(GlobalExceptionHandlerがHttpMessageNotReadableException・MethodArgumentTypeMismatchExceptionを変換)。 - 意味的parse(基本型 → ドメイン値オブジェクト)→ ハンドラーで変換する。値オブジェクトは生成時に不変条件を検証し、不正値を存在させない。
var todo = createXxxUseCase.execute(req.getName(), Xxx.from(req.getValue()));
API経路以外(バッチ・メッセージ受信など)で生文字列を受ける場合は ParseResult でparse結果を扱う。
var result = Xxx.parse(raw);if (result instanceof ParseResult.Err<Xxx>(var msg)) { throw ValidationProblemException.of("xxx", msg); // 422}return result.unwrap();エラー応答の追加
Section titled “エラー応答の追加”handler.problem(RFC9457基盤)と GlobalExceptionHandler は土台として残し、再利用する。
- ドメイン例外を作る。業務ルール違反は
BusinessRuleExceptionを継承する。専用ハンドラーが無くてもGlobalExceptionHandlerのフォールバックで422になる。 - 個別のステータス・国際化メッセージが要るなら
GlobalExceptionHandlerに@ExceptionHandlerを足し、buildProblem(...)ヘルパーを使う(ProblemDetailを自分で組み立てない)。 ProblemTypesにtype URI定数、messages.propertiesにproblem.title.*/error.*キーを追加する。- インフラ障害は業務エラーではない。
RepositoryException(技術例外、500)として扱い、DataAccessExceptionをラップする。
自動スキャン(@Component/@Service)は使わない。ドメイン・ユースケースにSpringアノテーションを付けない。AppConfig に @Bean を書く(依存グラフがそこに集約される)。
Web層の部品(@RestController・@RestControllerAdvice・Filter・@Configuration)だけはSpringのライフサイクルに乗せるためアノテーションで登録する。
インフラ実装の決まり
Section titled “インフラ実装の決まり”- ドメイン ⇔ DB行の変換は実装クラスに閉じる。ドメインにDB都合(行レコード・SQL)を漏らさない。
- 作成(INSERT)と更新(UPDATE)をポートで分ける場合がある(ADR-020)。
UPDATE ... RETURNINGが0行なら(並行削除など)Optional.empty()をNotFoundExceptionにする。- SQLは
mapper/*.xml。パラメータは必ず#{}。文字列結合は禁止。 - 共有するSELECT本体は
<sql>フラグメントで重複を避ける(参照用とロック用FOR UPDATEなど)。
RLS / マルチテナント対応(拡張ポイント)
Section titled “RLS / マルチテナント対応(拡張ポイント)”現状はテナント(org)・ユーザーの概念が無く、Row Level Securityも入れていない。 ただしRLS対応は確実に入ることは考慮する。
TransactionSessionInitializer(infrastructure.persistence.postgres)が拡張点。
SpringTransactionManager がトランザクション開始直後・業務処理の前に initializeSession() を呼ぶ。
現在は AppConfig でno-opラムダを登録している。RLSの SET LOCAL ROLE / set_config を
トランザクション境界で確実に実行するのが肝なので、@Transactional ではなくこのポートに差し込む。
対応手順(正式にRLSを入れるときに再設計する)
Section titled “対応手順(正式にRLSを入れるときに再設計する)”- リクエストコンテキストの確立: 認証から
userId/orgIdを解決し、リクエストスコープBeanかThreadLocalに保持する。確立はTraceIdResponseFilterの隣に専用フィルターを置く(handler層)。 TransactionSessionInitializerの実装を作り、AppConfigのno-opと差し替える。コンテキストを読んでMyBatis経由で次を実行する(接続プールに状態が漏れないよう必ずLOCAL/ 第3引数trueでトランザクションスコープに限定)。SET LOCAL ROLE app_writer;SELECT set_config('app.user_id', #{userId}, true),set_config('app.org_id', #{orgId}, true);- テナント列とRLSポリシー: 対象テーブルに
org_idを追加し、ポリシーを定義する。ALTER TABLE xxx ENABLE ROW LEVEL SECURITY;CREATE POLICY xxx_tenant ON xxxUSING (org_id = current_setting('app.org_id')::uuid); - ドメインにorg_idを持たせるか: 参照はRLSが透過的にフィルタするのでドメインは持たなくてよい。INSERT時だけ行レコードとSQLで設定する。クロスorg操作(管理用途)が要るときだけドメインに持たせて検証する。
- コンテキストの情報源(誰が・どのorgか)は認証が前提。認証が入るまで
initializeSession()はno-opのままにする。 SET ROLEをAOP(@Transactional)でやろうとすると実行順序の保証が難しい。トランザクション境界をポート化している(ADR-013)のはこのため。
- ユースケース単体: Mockitoでリポジトリをモック。トランザクションはno-op実装を渡す。
ロックして取得する処理はTransactionManager noopTx = new TransactionManager() {@Override public <T> T execute(Supplier<T> action) { return action.get(); }};
findByIdForUpdateをスタブする(findByIdではない)。 - マッパー SQL(BoundSql): DB接続なしでSQLテキストの性質を検証する(
mybatis-config-test.xmlからSqlSessionFactoryを組み、MappedStatement#getBoundSqlを見る。サンプル:AssetMapperBoundSqlTest)。 対象は<if>による句の付与・省略、ON CONFLICT・FOR UPDATEの有無、UPDATE対象列の範囲。 いずれも「機能テストが通っていても検出できない」もの。例えばFOR UPDATEの付け忘れは、単一スレッドで動く後述のDB往復テストでは落ちない。 新しいマッパーXMLを追加したらsrc/test/resources/mybatis-config-test.xmlの<mappers>にも登録すること。 - マッパー SQL(DB往復): Repository実装を実際のPostgreSQLに対して検証する(
@SpringBootTest(webEnvironment = NONE)+TestcontainersConfiguration+@Transactional。サンプル:AssetRepositoryImplIntegrationTest)。 対象は 実スキーマとの整合。列名・型の誤り、resultMapの<constructor>引数順のズレ、ENUMキャストの妥当性は、BoundSqlの文字列検査では検出できない(ADR-015が挙げるMyBatisのデメリット「Column 名・型ミスは実行時エラーになる」への対策)。TestcontainersConfigurationがdb/schema/をdocker-entrypoint-initdb.dへ配置するため、本物のDDLに対して実行される(テスト用にDDLを複製しない)。 - 統合テスト:
@SpringBootTest(webEnvironment = RANDOM_PORT)+ Testcontainers +RestClient。エラー応答はonStatus(HttpStatusCode::isError, (req,res)->{})で受けて Problem Details を検証する。 - アーキテクチャ:
ArchitectureTest(ArchUnit)が層依存を強制する。層・パッケージを足したらルールも確認する。
やってはいけないこと(ArchUnitと規約で弾かれる)
Section titled “やってはいけないこと(ArchUnitと規約で弾かれる)”domainからSpring / MyBatis /infrastructureを参照する。usecaseでHTTPの関心事(HttpStatus・ResponseEntity・ヘッダー)を扱う。@Transactionalを使う(TransactionManagerを使う)。- ドメイン・ユースケースに
@Component/@Serviceを付けて自動スキャンさせる。 ProblemDetailを各所で手組みする(GlobalExceptionHandlerのbuildProblemに集約)。- 状態遷移・複合更新で
findById(ロックなし)を使う(findByIdForUpdate)。 - ドメイン例外にHTTPステータスを持たせる(変換はhandlerの責務)。
- 理由のない巨大トランザクション(複数集約を漫然と1Txに束ねる。ADR-021の判断軸で必要性を示す)。
- トランザクションの中で外部I/O(HTTP・メッセージングなど)を呼ぶ(ロック長期保持・dual write。ADR-022。外部呼び出しはTx外へ)。
- ユースケースに値・状態の妥当性判定や複数ドメインオブジェクトを跨ぐ判定・計算を直接書く(
domain/domain.serviceに書く。単純な存在確認・空判定などの軽微な分岐は対象外)。
Observability(コードを足すときの規範)
Section titled “Observability(コードを足すときの規範)”spanは境界で自動的に付く。計装は handler・infrastructure・config にのみ置き、usecase・domain は計装ゼロで純粋に保つ(ArchUnitが io.micrometer../io.opentelemetry.. 依存を禁止)。新しくコードを足すとき、通常は何もしなくてよい。
| 足すもの | やること |
|---|---|
| handler(HTTPエンドポイント) | なし(HTTPサーバのspanは自動) |
| infrastructureのDBアクセス | なし(SQL spanはdatasource-micrometerが自動) |
| usecase | 計装コードを書かない(io.micrometer/io.opentelemetry をimportしない)。純粋に保つ |
| 外部I/O(HTTPクライアント・メッセージングなど) | 自動計装は付かない。必要なら infrastructure 側で ObservationRegistry を使いObservationを張る(usecase/domain には入れない) |
内訳をもっと見たくなったときの拡張ポイント:
- usecase span:
config/AppConfigの各UseCaseの@BeanをObservationRegistryを使ったデコレータで包む。usecaseクラスは純粋なまま、観測の関心事をconfigに閉じる。 - mapper/repositoryメソッド名のspan:
infrastructureのTodoRepositoryImplの各メソッドを手動Observationで包み、どのrepository操作がSQLを発行したかをspan名に出す。
トレース送信先(OTLP)の設定やローカルでの確認方法はREADME「トレースの確認」を参照(実装ではなく運用設定)。
仕上げ(コミット前)
Section titled “仕上げ(コミット前)”./gradlew spotlessApply # 整形./gradlew check # テスト・spotless・NullAway・ArchUnitmise run lint # OpenAPI / データモデル文書 / SQL / GitHub Actions / secrets / spotless # ※ データモデル文書の検査は npm ci 済みであることが前提(github-slugger を使う) # pre-commit(lefthook)の lint-datamodel も同じ前提messages.propertiesのキー追加忘れ、OpenAPIと実装の不一致に注意する。- 長い行(とくにコメント内の英数字列挙)はspotlessが折り返しでMarkdownコメントを壊すことがある。整形後に確認する。
本文中の ADR-0xx は組織共通ADRの番号を指す。ADR本体の場所はarchitecture.mdの関連ADRを参照。