コンテンツにスキップ

実装ガイドライン

「なぜこの設計か」はADRを見れば済むので書かない。ここに書くのは「このコードベースで具体的にどう書くか」だけ。

  • Java 25 / Spring Boot 4。ビルドは ./gradlew(Mavenは使わない)。
  • 層構成とパッケージ責務は architecture.md と各 package-info.java を読む。
  • 全体 @NullMarked(JSpecify)。null可の箇所だけ @Nullable。Javadocは ///(Markdownコメント)。
  • 依存方向は ArchitectureTest(ArchUnit)が機械的に強制する。
Handler (生成 XxxApi を implements)
→ UseCase.execute(...) // usecase
→ TransactionManager.execute(() -> ...) // トランザクション境界
→ Repository(port) // → infra 実装(Mapper + 行レコード変換)
→ ResponseMapper.from(domainObject) // ドメイン → 生成DTO

例外はドメイン例外として投げ、GlobalExceptionHandler がHTTP(Problem Details)へ変換する。 ハンドラー・ユースケースでHTTPステータスを直接組み立てない。

新しいドメイン/機能を起こす手順

Section titled “新しいドメイン/機能を起こす手順”
  1. ドメインモデル: 集約・値オブジェクトを domain.model に作る。生成時に不変条件を検証し、不正値を存在させない。状態を持つならsealed interface(下記「状態を持つ集約」)。 複数の集約・値オブジェクトにまたがるビジネスルールは domain.service に置く。
  2. ポート: 永続化や外部I/Oのインターフェースを domain.port に定義する。戻り値がないとき Optional、異常時の例外をJavadocで契約化する(see: TransactionManager)。
  3. ユースケース: usecase に1ユースケース1クラス(execute)。Portの呼び出し順序と結果の受け渡しに徹し、ビジネスルールの判定・計算は書かない。
  4. インフラ: Mapper インターフェースと mapper/*.xml、DB行レコードとドメインの変換を実装する。DataAccessExceptionRepositoryException にラップする。
  5. OpenAPI(スキーマファースト): docs/openapi/*.yaml にエンドポイントとスキーマを定義し、./gradlew openApiSyncToSrcgenerated/XxxApi インターフェース + DTO)を生成する(ADR-010)。生成コードは手編集しない。
  6. ハンドラー: 生成された XxxApiimplements する @RestController。入力は生成DTO、出力はMapperで生成DTOへ変換する(handler.response)。検証は後述。エラーは投げるだけ。
  7. DI登録: config/AppConfig@Bean を追加(自動スキャンは使わない)。
  8. テスト: ユースケース単体(Mockito)と統合(Testcontainers)。

ドメインサービスとビジネスロジックの配置

Section titled “ドメインサービスとビジネスロジックの配置”
処理配置先
単一集約の状態遷移・値の導出domain.model のメソッド
主体となる集約があり、他方を引数にできる操作主体側エンティティのメソッド
対等な複数オブジェクトの判定・計算domain.service
ドメインオブジェクトに属さない共通の値変換・表示domain.common

ユースケースにはビジネスルールを書かない(ADR-013)。ただし、存在確認や空判定などの軽微な分岐はよい。 ユースケース内での複数のドメインオブジェクトを比較する iffor は、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)。特定ユースケースだけ変えたいときは executeTransactionOptions を渡す(TransactionOptionsIsolationLeveldomain.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: string
    minLength: 1
    maxLength: 255
    pattern: '.*\S.*' # 空白のみを拒否
  • 構文parse(StringLocalDateUUID などの基本型)→ OASの formatdateuuid)で生成DTOがその型を持ち、Jacksonがparseする。形式不正はパース失敗 = 400(GlobalExceptionHandlerHttpMessageNotReadableExceptionMethodArgumentTypeMismatchException を変換)。
  • 意味的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();

handler.problem(RFC9457基盤)と GlobalExceptionHandler は土台として残し、再利用する。

  1. ドメイン例外を作る。業務ルール違反は BusinessRuleException を継承する。専用ハンドラーが無くても GlobalExceptionHandler のフォールバックで422になる。
  2. 個別のステータス・国際化メッセージが要るなら GlobalExceptionHandler@ExceptionHandler を足し、buildProblem(...) ヘルパーを使う(ProblemDetail を自分で組み立てない)。
  3. ProblemTypes にtype URI定数、messages.propertiesproblem.title.* / error.* キーを追加する。
  4. インフラ障害は業務エラーではない。RepositoryException(技術例外、500)として扱い、DataAccessException をラップする。

自動スキャン(@Component/@Service)は使わない。ドメイン・ユースケースにSpringアノテーションを付けない。AppConfig@Bean を書く(依存グラフがそこに集約される)。 Web層の部品(@RestController@RestControllerAdviceFilter@Configuration)だけはSpringのライフサイクルに乗せるためアノテーションで登録する。

  • ドメイン ⇔ 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対応は確実に入ることは考慮する。

TransactionSessionInitializerinfrastructure.persistence.postgres)が拡張点。 SpringTransactionManager がトランザクション開始直後・業務処理の前に initializeSession() を呼ぶ。 現在は AppConfig でno-opラムダを登録している。RLSの SET LOCAL ROLE / set_config を トランザクション境界で確実に実行するのが肝なので、@Transactional ではなくこのポートに差し込む。

対応手順(正式にRLSを入れるときに再設計する)

Section titled “対応手順(正式にRLSを入れるときに再設計する)”
  1. リクエストコンテキストの確立: 認証から userId / orgId を解決し、リクエストスコープBeanか ThreadLocal に保持する。確立は TraceIdResponseFilter の隣に専用フィルターを置く(handler層)。
  2. 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);
  3. テナント列とRLSポリシー: 対象テーブルに org_id を追加し、ポリシーを定義する。
    ALTER TABLE xxx ENABLE ROW LEVEL SECURITY;
    CREATE POLICY xxx_tenant ON xxx
    USING (org_id = current_setting('app.org_id')::uuid);
  4. ドメインに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 CONFLICTFOR 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 名・型ミスは実行時エラーになる」への対策)。 TestcontainersConfigurationdb/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の関心事(HttpStatusResponseEntity・ヘッダー)を扱う。
  • @Transactional を使う(TransactionManager を使う)。
  • ドメイン・ユースケースに @Component/@Service を付けて自動スキャンさせる。
  • ProblemDetail を各所で手組みする(GlobalExceptionHandlerbuildProblem に集約)。
  • 状態遷移・複合更新で findById(ロックなし)を使う(findByIdForUpdate)。
  • ドメイン例外にHTTPステータスを持たせる(変換はhandlerの責務)。
  • 理由のない巨大トランザクション(複数集約を漫然と1Txに束ねる。ADR-021の判断軸で必要性を示す)。
  • トランザクションの中で外部I/O(HTTP・メッセージングなど)を呼ぶ(ロック長期保持・dual write。ADR-022。外部呼び出しはTx外へ)。
  • ユースケースに値・状態の妥当性判定や複数ドメインオブジェクトを跨ぐ判定・計算を直接書く(domain/domain.serviceに書く。単純な存在確認・空判定などの軽微な分岐は対象外)。

Observability(コードを足すときの規範)

Section titled “Observability(コードを足すときの規範)”

spanは境界で自動的に付く。計装は handlerinfrastructureconfig にのみ置き、usecasedomain は計装ゼロで純粋に保つ(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の @BeanObservationRegistry を使ったデコレータで包む。usecaseクラスは純粋なまま、観測の関心事をconfigに閉じる。
  • mapper/repositoryメソッド名のspan: infrastructureTodoRepositoryImpl の各メソッドを手動Observationで包み、どのrepository操作がSQLを発行したかをspan名に出す。

トレース送信先(OTLP)の設定やローカルでの確認方法はREADME「トレースの確認」を参照(実装ではなく運用設定)。

ターミナルウィンドウ
./gradlew spotlessApply # 整形
./gradlew check # テスト・spotless・NullAway・ArchUnit
mise 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を参照。