Skip to content

第22章:Axon CQRS/ES の実装【ORM版】

本章では、財務会計システムに CQRS(Command Query Responsibility Segregation)Event Sourcing を実装します。Axon Framework を使用し、コマンド(書き込み)とクエリ(読み取り)を分離し、すべての状態変更をイベントとして記録するアーキテクチャを構築します。

研究 1 で構築したヘキサゴナルアーキテクチャの考え方を踏襲しつつ、ドメインモデルをフレームワークから分離し、Axon Aggregate Adapter を介して連携させます。Read Model の永続化には Spring Data JPA を使用します。


22.1 CQRS/ES アーキテクチャの基礎

22.1.1 CQRS とは

CQRS(Command Query Responsibility Segregation)は、コマンド(書き込み)とクエリ(読み取り)の責務を分離するアーキテクチャパターンです。

uml diagram

CQRS の利点:

観点 説明
スケーラビリティ 読み取りと書き込みを独立してスケール可能
パフォーマンス 読み取りに最適化されたモデルで高速クエリ
複雑性の分離 書き込みロジックと読み取りロジックを独立して開発
監査対応 Event Sourcing と組み合わせて完全な履歴を保持

財務会計システムでの CQRS の意義:

  • 監査証跡: すべての仕訳変更履歴を完全に保持
  • 複雑なクエリ: 試算表、財務諸表など多様なビューを効率的に生成
  • データ整合性: イベントベースで一貫性を保証

22.1.2 Event Sourcing とは

Event Sourcing は、アプリケーションの状態をイベントの連続として保存するパターンです。

uml diagram

Event Sourcing の利点:

観点 説明
完全な履歴 すべての状態変更が記録される
監査証跡 いつ、誰が、何を変更したかが明確
時間旅行 過去の任意の時点の状態を再構築可能
イベント駆動 他システムとの連携が容易

財務会計システムでの Event Sourcing の意義:

  • 訂正仕訳の追跡: 修正履歴を完全に保持
  • 期末決算処理: 任意の時点での残高を再計算可能
  • 内部統制: SOX 法対応の監査証跡を自動生成

22.1.3 ヘキサゴナルアーキテクチャとの統合

uml diagram

設計原則:

  1. ドメインモデルの純粋性: ドメインモデルは Axon に依存しない純粋な Java コード
  2. Aggregate Adapter: Axon Framework 用のアダプターを Application Layer に配置
  3. イベントは公開 API: イベントは他の Context から参照される公開 API として定義

22.2 技術スタック

カテゴリ 技術 バージョン
言語 Java 21
フレームワーク Spring Boot 3.4.1
CQRS/ES Axon Framework 4.10.3
ORM Spring Data JPA 3.4.1
API ドキュメント SpringDoc OpenAPI 2.7.0
データベース H2 (開発) / PostgreSQL (本番) -

22.2.1 build.gradle.kts

コード例: build.gradle.kts
dependencies {
    // Spring Boot
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")

    // Axon Framework
    implementation("org.axonframework:axon-spring-boot-starter:4.10.3") {
        exclude(group = "org.axonframework", module = "axon-server-connector")
    }

    // OpenAPI
    implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0")

    // Database
    runtimeOnly("com.h2database:h2")
    runtimeOnly("org.postgresql:postgresql")

    // Test
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.axonframework:axon-test:4.10.3")
}

22.2.2 ディレクトリ構造

コード例: ディレクトリ構成
src/main/java/com/example/accounting/
├── app/                              # アプリケーション共通
│   └── RootController.java
├── config/                           # 設定クラス
│   ├── AxonConfig.java
│   ├── JpaConfig.java
│   └── OpenApiConfig.java
├── journal/                          # Journal Bounded Context(仕訳)
│   ├── api/
│   │   └── events/                   # 公開イベント API
│   │       ├── JournalEntryEvent.java     # sealed interface
│   │       ├── JournalEntryCreatedEvent.java
│   │       ├── JournalEntryApprovedEvent.java
│   │       └── JournalEntryPostedEvent.java
│   ├── adapter/
│   │   ├── inbound/rest/journals/    # Inbound Adapter (REST)
│   │   │   ├── JournalsController.java
│   │   │   └── protocol/             # Request/Response DTO
│   │   └── outbound/persistence/     # Outbound Adapter (JPA)
│   │       ├── entity/
│   │       │   ├── JournalEntryEntity.java
│   │       │   └── JournalLineEntity.java
│   │       └── repository/
│   │           └── JournalEntryJpaRepository.java
│   ├── application/
│   │   ├── aggregate/                # Axon Aggregate Adapter
│   │   │   └── JournalEntryAggregateAdapter.java
│   │   ├── policy/                   # イベントハンドラー(Choreography)
│   │   │   └── AccountEventHandler.java
│   │   └── query/                    # Projection
│   │       └── JournalEntryProjection.java
│   └── domain/
│       └── model/aggregate/journal/  # 純粋なドメインモデル
│           ├── JournalEntry.java
│           ├── JournalEntryCommands.java
│           ├── JournalEntryStatus.java
│           └── JournalLine.java
└── account/                          # Account Bounded Context(勘定科目)
    ├── api/events/
    │   ├── AccountEvent.java
    │   └── AccountBalanceUpdatedEvent.java
    ├── application/
    │   ├── aggregate/
    │   │   └── AccountAggregateAdapter.java
    │   └── policy/
    │       └── JournalEventHandler.java
    └── domain/model/aggregate/account/
        ├── Account.java
        └── AccountCommands.java

22.3 Axon 設定クラス(JPA版)

22.3.1 AxonConfig.java

コード例: AxonConfig.java
package com.example.accounting.config;

import org.axonframework.common.jpa.EntityManagerProvider;
import org.axonframework.eventsourcing.eventstore.EmbeddedEventStore;
import org.axonframework.eventsourcing.eventstore.EventStorageEngine;
import org.axonframework.eventsourcing.eventstore.EventStore;
import org.axonframework.eventsourcing.eventstore.jpa.JpaEventStorageEngine;
import org.axonframework.serialization.Serializer;
import org.axonframework.serialization.json.JacksonSerializer;
import org.axonframework.spring.config.SpringAxonConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;

/**
 * Axon Framework 設定(JPA版)
 */
@Configuration
public class AxonConfig {

    /**
     * JPA ベースの Event Storage Engine
     */
    @Bean
    public EventStorageEngine eventStorageEngine(
            EntityManagerProvider entityManagerProvider,
            Serializer serializer) {
        return JpaEventStorageEngine.builder()
                .entityManagerProvider(entityManagerProvider)
                .snapshotSerializer(serializer)
                .eventSerializer(serializer)
                .build();
    }

    /**
     * Event Store
     */
    @Bean
    public EventStore eventStore(EventStorageEngine eventStorageEngine) {
        return EmbeddedEventStore.builder()
                .storageEngine(eventStorageEngine)
                .build();
    }

    /**
     * JSON シリアライザー
     */
    @Bean
    public Serializer eventSerializer() {
        return JacksonSerializer.defaultSerializer();
    }
}

22.3.2 JpaConfig.java

コード例: JpaConfig.java
package com.example.accounting.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

/**
 * JPA 設定
 */
@Configuration
@EnableJpaRepositories(basePackages = {
    "com.example.accounting.journal.adapter.outbound.persistence.repository",
    "com.example.accounting.account.adapter.outbound.persistence.repository"
})
@EnableJpaAuditing
public class JpaConfig {
}

22.4 ドメインモデルとイベント設計

22.4.1 Journal Bounded Context

仕訳の作成から転記完了までのライフサイクルを管理します。

状態遷移図

uml diagram

イベント一覧

イベント 説明
JournalEntryCreatedEvent 仕訳が作成された
JournalEntrySubmittedEvent 仕訳が承認申請された
JournalEntryApprovedEvent 仕訳が承認された
JournalEntryRejectedEvent 仕訳が却下された
JournalEntryRevisedEvent 却下された仕訳が修正された
JournalEntryPostedEvent 仕訳が転記された(元帳に反映)
JournalEntryCancelledEvent 仕訳がキャンセルされた

22.4.2 イベント定義(API Layer)

sealed interface によるイベントの型安全性

コード例: JournalEntryEvent.java
package com.example.accounting.journal.api.events;

/**
 * JournalEntry イベントの sealed interface
 * すべての JournalEntry イベントの親インターフェース
 */
public sealed interface JournalEntryEvent permits
        JournalEntryCreatedEvent,
        JournalEntrySubmittedEvent,
        JournalEntryApprovedEvent,
        JournalEntryRejectedEvent,
        JournalEntryRevisedEvent,
        JournalEntryPostedEvent,
        JournalEntryCancelledEvent {

    String journalEntryId();
}

イベント record の実装

コード例: JournalEntryCreatedEvent.java
package com.example.accounting.journal.api.events;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;

/**
 * 仕訳作成イベント
 */
public record JournalEntryCreatedEvent(
        String journalEntryId,
        String journalNumber,
        LocalDate transactionDate,
        String description,
        List<JournalLineData> lines,
        String createdBy,
        LocalDateTime createdAt
) implements JournalEntryEvent {

    /**
     * 仕訳明細データ
     */
    public record JournalLineData(
            String accountCode,
            String accountName,
            BigDecimal debitAmount,
            BigDecimal creditAmount
    ) {
    }
}
コード例: JournalEntryApprovedEvent.java
package com.example.accounting.journal.api.events;

import java.time.LocalDateTime;

/**
 * 仕訳承認イベント
 */
public record JournalEntryApprovedEvent(
        String journalEntryId,
        String approvedBy,
        String comment,
        LocalDateTime approvedAt
) implements JournalEntryEvent {
}
コード例: JournalEntryPostedEvent.java
package com.example.accounting.journal.api.events;

import java.time.LocalDateTime;

/**
 * 仕訳転記イベント
 */
public record JournalEntryPostedEvent(
        String journalEntryId,
        String fiscalPeriod,
        String postedBy,
        LocalDateTime postedAt
) implements JournalEntryEvent {
}

22.4.3 コマンド定義(Domain Layer)

コード例: JournalEntryCommands.java
package com.example.accounting.journal.domain.model.aggregate.journal;

import org.axonframework.modelling.command.TargetAggregateIdentifier;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

/**
 * JournalEntry 集約へのコマンド定義
 */
public final class JournalEntryCommands {

    private JournalEntryCommands() {
    }

    /**
     * 仕訳作成コマンド
     */
    public record CreateJournalEntryCommand(
            @TargetAggregateIdentifier
            String journalEntryId,
            LocalDate transactionDate,
            String description,
            List<JournalLineInput> lines,
            String createdBy
    ) {
        public record JournalLineInput(
                String accountCode,
                String accountName,
                BigDecimal debitAmount,
                BigDecimal creditAmount
        ) {
        }
    }

    /**
     * 仕訳承認申請コマンド
     */
    public record SubmitJournalEntryCommand(
            @TargetAggregateIdentifier
            String journalEntryId,
            String submittedBy
    ) {
    }

    /**
     * 仕訳承認コマンド
     */
    public record ApproveJournalEntryCommand(
            @TargetAggregateIdentifier
            String journalEntryId,
            String approvedBy,
            String comment
    ) {
    }

    /**
     * 仕訳却下コマンド
     */
    public record RejectJournalEntryCommand(
            @TargetAggregateIdentifier
            String journalEntryId,
            String rejectedBy,
            String reason
    ) {
    }

    /**
     * 仕訳転記コマンド
     */
    public record PostJournalEntryCommand(
            @TargetAggregateIdentifier
            String journalEntryId,
            String fiscalPeriod,
            String postedBy
    ) {
    }

    /**
     * 仕訳キャンセルコマンド
     */
    public record CancelJournalEntryCommand(
            @TargetAggregateIdentifier
            String journalEntryId,
            String cancelledBy,
            String reason
    ) {
    }
}

22.4.4 ドメインモデル(純粋な Java)

コード例: JournalEntry.java
package com.example.accounting.journal.domain.model.aggregate.journal;

import com.example.accounting.journal.api.events.*;
import com.example.accounting.journal.api.events.JournalEntryCreatedEvent.JournalLineData;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;

/**
 * JournalEntry ドメインモデル(Axon 非依存)
 * 純粋なビジネスロジックのみを含む
 */
public record JournalEntry(
        String journalEntryId,
        String journalNumber,
        LocalDate transactionDate,
        String description,
        List<JournalLine> lines,
        JournalEntryStatus status
) {

    // ======== ファクトリメソッド ========

    /**
     * 仕訳作成
     */
    public static JournalEntryCreatedEvent create(
            String journalEntryId,
            String journalNumber,
            LocalDate transactionDate,
            String description,
            List<JournalLineData> lines,
            String createdBy
    ) {
        // バリデーション: 借方・貸方の合計が一致すること
        BigDecimal totalDebit = lines.stream()
                .map(JournalLineData::debitAmount)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        BigDecimal totalCredit = lines.stream()
                .map(JournalLineData::creditAmount)
                .reduce(BigDecimal.ZERO, BigDecimal::add);

        if (totalDebit.compareTo(totalCredit) != 0) {
            throw new IllegalArgumentException(
                    "Debit and credit amounts must be equal. Debit: " + totalDebit + ", Credit: " + totalCredit
            );
        }

        if (lines.isEmpty()) {
            throw new IllegalArgumentException("Journal entry must have at least one line");
        }

        return new JournalEntryCreatedEvent(
                journalEntryId,
                journalNumber,
                transactionDate,
                description,
                lines,
                createdBy,
                LocalDateTime.now()
        );
    }

    /**
     * イベントからの再構築
     */
    public static JournalEntry from(JournalEntryCreatedEvent event) {
        List<JournalLine> lines = event.lines().stream()
                .map(data -> new JournalLine(
                        data.accountCode(),
                        data.accountName(),
                        data.debitAmount(),
                        data.creditAmount()
                ))
                .toList();

        return new JournalEntry(
                event.journalEntryId(),
                event.journalNumber(),
                event.transactionDate(),
                event.description(),
                lines,
                JournalEntryStatus.DRAFT
        );
    }

    // ======== 状態遷移メソッド ========

    /**
     * 承認申請
     */
    public JournalEntrySubmittedEvent submit(String submittedBy) {
        if (status != JournalEntryStatus.DRAFT) {
            throw new IllegalStateException("Only draft journal entries can be submitted");
        }
        return new JournalEntrySubmittedEvent(journalEntryId, submittedBy, LocalDateTime.now());
    }

    /**
     * 承認
     */
    public JournalEntryApprovedEvent approve(String approvedBy, String comment) {
        if (status != JournalEntryStatus.PENDING_APPROVAL) {
            throw new IllegalStateException("Only pending journal entries can be approved");
        }
        return new JournalEntryApprovedEvent(journalEntryId, approvedBy, comment, LocalDateTime.now());
    }

    /**
     * 却下
     */
    public JournalEntryRejectedEvent reject(String rejectedBy, String reason) {
        if (status != JournalEntryStatus.PENDING_APPROVAL) {
            throw new IllegalStateException("Only pending journal entries can be rejected");
        }
        return new JournalEntryRejectedEvent(journalEntryId, rejectedBy, reason, LocalDateTime.now());
    }

    /**
     * 転記
     */
    public JournalEntryPostedEvent post(String fiscalPeriod, String postedBy) {
        if (status != JournalEntryStatus.APPROVED) {
            throw new IllegalStateException("Only approved journal entries can be posted");
        }
        return new JournalEntryPostedEvent(journalEntryId, fiscalPeriod, postedBy, LocalDateTime.now());
    }

    /**
     * キャンセル
     */
    public JournalEntryCancelledEvent cancel(String cancelledBy, String reason) {
        if (status == JournalEntryStatus.POSTED) {
            throw new IllegalStateException("Posted journal entries cannot be cancelled");
        }
        if (status == JournalEntryStatus.CANCELLED) {
            throw new IllegalStateException("Journal entry is already cancelled");
        }
        return new JournalEntryCancelledEvent(journalEntryId, cancelledBy, reason, LocalDateTime.now());
    }

    // ======== イベント適用メソッド ========

    /**
     * イベントを適用して新しい状態を生成
     */
    public JournalEntry apply(JournalEntryEvent event) {
        return switch (event) {
            case JournalEntryCreatedEvent e -> from(e);
            case JournalEntrySubmittedEvent e -> withStatus(JournalEntryStatus.PENDING_APPROVAL);
            case JournalEntryApprovedEvent e -> withStatus(JournalEntryStatus.APPROVED);
            case JournalEntryRejectedEvent e -> withStatus(JournalEntryStatus.REJECTED);
            case JournalEntryRevisedEvent e -> withStatus(JournalEntryStatus.DRAFT);
            case JournalEntryPostedEvent e -> withStatus(JournalEntryStatus.POSTED);
            case JournalEntryCancelledEvent e -> withStatus(JournalEntryStatus.CANCELLED);
        };
    }

    /**
     * 状態更新ヘルパー
     */
    private JournalEntry withStatus(JournalEntryStatus newStatus) {
        return new JournalEntry(journalEntryId, journalNumber, transactionDate, description, lines, newStatus);
    }
}
コード例: JournalEntryStatus.java
package com.example.accounting.journal.domain.model.aggregate.journal;

/**
 * 仕訳ステータス
 */
public enum JournalEntryStatus {
    DRAFT,             // 下書き
    PENDING_APPROVAL,  // 承認待ち
    APPROVED,          // 承認済み
    REJECTED,          // 却下
    POSTED,            // 転記済み
    CANCELLED          // キャンセル
}
コード例: JournalLine.java
package com.example.accounting.journal.domain.model.aggregate.journal;

import java.math.BigDecimal;

/**
 * 仕訳明細(Value Object)
 */
public record JournalLine(
        String accountCode,
        String accountName,
        BigDecimal debitAmount,
        BigDecimal creditAmount
) {
    public JournalLine {
        if (debitAmount == null) debitAmount = BigDecimal.ZERO;
        if (creditAmount == null) creditAmount = BigDecimal.ZERO;

        if (debitAmount.compareTo(BigDecimal.ZERO) < 0 || creditAmount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Amounts cannot be negative");
        }
    }
}

22.5 Aggregate Adapter と Policy Handler

22.5.1 JournalEntry Aggregate Adapter

コード例: JournalEntryAggregateAdapter.java
package com.example.accounting.journal.application.aggregate;

import com.example.accounting.journal.api.events.*;
import com.example.accounting.journal.api.events.JournalEntryCreatedEvent.JournalLineData;
import com.example.accounting.journal.domain.model.aggregate.journal.JournalEntry;
import com.example.accounting.journal.domain.model.aggregate.journal.JournalEntryCommands.*;
import org.axonframework.commandhandling.CommandHandler;
import org.axonframework.eventsourcing.EventSourcingHandler;
import org.axonframework.modelling.command.AggregateIdentifier;
import org.axonframework.modelling.command.AggregateLifecycle;
import org.axonframework.spring.stereotype.Aggregate;

import java.util.UUID;

/**
 * JournalEntry Aggregate Adapter(Axon Framework 用)
 */
@Aggregate
public class JournalEntryAggregateAdapter {

    @AggregateIdentifier
    private String journalEntryId;

    private JournalEntry journalEntry;

    /**
     * Axon が使用するデフォルトコンストラクタ
     */
    protected JournalEntryAggregateAdapter() {
    }

    // ======== Command Handlers ========

    /**
     * 集約作成コマンドハンドラー(コンストラクタ)
     */
    @CommandHandler
    public JournalEntryAggregateAdapter(CreateJournalEntryCommand command) {
        // 仕訳番号を生成
        String journalNumber = generateJournalNumber();

        // コマンドの明細をイベント用データに変換
        var lineDataList = command.lines().stream()
                .map(line -> new JournalLineData(
                        line.accountCode(),
                        line.accountName(),
                        line.debitAmount(),
                        line.creditAmount()
                ))
                .toList();

        // ドメインモデルのファクトリメソッドを呼び出し
        var event = JournalEntry.create(
                command.journalEntryId(),
                journalNumber,
                command.transactionDate(),
                command.description(),
                lineDataList,
                command.createdBy()
        );

        // イベントを発行
        AggregateLifecycle.apply(event);
    }

    /**
     * 承認申請コマンドハンドラー
     */
    @CommandHandler
    public void handle(SubmitJournalEntryCommand command) {
        var event = journalEntry.submit(command.submittedBy());
        AggregateLifecycle.apply(event);
    }

    /**
     * 承認コマンドハンドラー
     */
    @CommandHandler
    public void handle(ApproveJournalEntryCommand command) {
        var event = journalEntry.approve(command.approvedBy(), command.comment());
        AggregateLifecycle.apply(event);
    }

    /**
     * 却下コマンドハンドラー
     */
    @CommandHandler
    public void handle(RejectJournalEntryCommand command) {
        var event = journalEntry.reject(command.rejectedBy(), command.reason());
        AggregateLifecycle.apply(event);
    }

    /**
     * 転記コマンドハンドラー
     */
    @CommandHandler
    public void handle(PostJournalEntryCommand command) {
        var event = journalEntry.post(command.fiscalPeriod(), command.postedBy());
        AggregateLifecycle.apply(event);
    }

    /**
     * キャンセルコマンドハンドラー
     */
    @CommandHandler
    public void handle(CancelJournalEntryCommand command) {
        var event = journalEntry.cancel(command.cancelledBy(), command.reason());
        AggregateLifecycle.apply(event);
    }

    // ======== Event Sourcing Handlers(状態復元)========

    @EventSourcingHandler
    public void on(JournalEntryCreatedEvent event) {
        this.journalEntryId = event.journalEntryId();
        this.journalEntry = JournalEntry.from(event);
    }

    @EventSourcingHandler
    public void on(JournalEntrySubmittedEvent event) {
        this.journalEntry = journalEntry.apply(event);
    }

    @EventSourcingHandler
    public void on(JournalEntryApprovedEvent event) {
        this.journalEntry = journalEntry.apply(event);
    }

    @EventSourcingHandler
    public void on(JournalEntryRejectedEvent event) {
        this.journalEntry = journalEntry.apply(event);
    }

    @EventSourcingHandler
    public void on(JournalEntryPostedEvent event) {
        this.journalEntry = journalEntry.apply(event);
    }

    @EventSourcingHandler
    public void on(JournalEntryCancelledEvent event) {
        this.journalEntry = journalEntry.apply(event);
    }

    // ======== Helper Methods ========

    private String generateJournalNumber() {
        // 実際には連番生成サービスを使用
        return "JE-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
    }
}

Axon アノテーション解説:

アノテーション 説明
@Aggregate Event Sourcing 集約であることを宣言
@AggregateIdentifier 集約の識別子フィールド
@CommandHandler コマンドを処理するメソッド
@EventSourcingHandler イベントから状態を復元するメソッド
AggregateLifecycle.apply() イベントを発行するメソッド

22.5.2 Policy Handler(Choreography パターン)

uml diagram

コード例: JournalEventHandler.java(Account Context 内)
package com.example.accounting.account.application.policy;

import com.example.accounting.account.domain.model.aggregate.account.AccountCommands.*;
import com.example.accounting.journal.api.events.JournalEntryPostedEvent;
import org.axonframework.commandhandling.gateway.CommandGateway;
import org.axonframework.eventhandling.EventHandler;
import org.springframework.stereotype.Component;

/**
 * Journal イベントに反応して勘定科目残高を更新する Policy Handler
 */
@Component
public class JournalEventHandler {

    private final CommandGateway commandGateway;

    public JournalEventHandler(CommandGateway commandGateway) {
        this.commandGateway = commandGateway;
    }

    /**
     * Policy: "When a journal entry is posted, update account balances"
     */
    @EventHandler
    public void on(JournalEntryPostedEvent event) {
        // 仕訳の各明細に対して残高更新コマンドを発行
        // 実際には JournalEntryPostedEvent に明細情報を含めるか、
        // Read Model から取得する
        var command = new UpdateAccountBalanceCommand(
                event.journalEntryId(),
                event.fiscalPeriod()
        );
        commandGateway.send(command);
    }
}

22.6 Projection と Read Model(JPA版)

22.6.1 JournalEntry Projection

コード例: JournalEntryProjection.java
package com.example.accounting.journal.application.query;

import com.example.accounting.journal.adapter.outbound.persistence.entity.JournalEntryEntity;
import com.example.accounting.journal.adapter.outbound.persistence.entity.JournalLineEntity;
import com.example.accounting.journal.adapter.outbound.persistence.repository.JournalEntryJpaRepository;
import com.example.accounting.journal.api.events.*;
import org.axonframework.eventhandling.EventHandler;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.stream.Collectors;

/**
 * JournalEntry Projection(JPA を使用した Read Model の更新)
 */
@Component
@Transactional
public class JournalEntryProjection {

    private final JournalEntryJpaRepository repository;

    public JournalEntryProjection(JournalEntryJpaRepository repository) {
        this.repository = repository;
    }

    @EventHandler
    public void on(JournalEntryCreatedEvent event) {
        var entity = new JournalEntryEntity();
        entity.setJournalEntryId(event.journalEntryId());
        entity.setJournalNumber(event.journalNumber());
        entity.setTransactionDate(event.transactionDate());
        entity.setDescription(event.description());
        entity.setStatus("DRAFT");
        entity.setCreatedBy(event.createdBy());
        entity.setCreatedAt(event.createdAt());

        // 明細をエンティティに変換
        var lineEntities = event.lines().stream()
                .map(line -> {
                    var lineEntity = new JournalLineEntity();
                    lineEntity.setAccountCode(line.accountCode());
                    lineEntity.setAccountName(line.accountName());
                    lineEntity.setDebitAmount(line.debitAmount());
                    lineEntity.setCreditAmount(line.creditAmount());
                    lineEntity.setJournalEntry(entity);
                    return lineEntity;
                })
                .collect(Collectors.toList());

        entity.setLines(lineEntities);
        repository.save(entity);
    }

    @EventHandler
    public void on(JournalEntrySubmittedEvent event) {
        repository.findById(event.journalEntryId()).ifPresent(entity -> {
            entity.setStatus("PENDING_APPROVAL");
            entity.setSubmittedBy(event.submittedBy());
            entity.setSubmittedAt(event.submittedAt());
            repository.save(entity);
        });
    }

    @EventHandler
    public void on(JournalEntryApprovedEvent event) {
        repository.findById(event.journalEntryId()).ifPresent(entity -> {
            entity.setStatus("APPROVED");
            entity.setApprovedBy(event.approvedBy());
            entity.setApprovalComment(event.comment());
            entity.setApprovedAt(event.approvedAt());
            repository.save(entity);
        });
    }

    @EventHandler
    public void on(JournalEntryRejectedEvent event) {
        repository.findById(event.journalEntryId()).ifPresent(entity -> {
            entity.setStatus("REJECTED");
            entity.setRejectedBy(event.rejectedBy());
            entity.setRejectionReason(event.reason());
            entity.setRejectedAt(event.rejectedAt());
            repository.save(entity);
        });
    }

    @EventHandler
    public void on(JournalEntryPostedEvent event) {
        repository.findById(event.journalEntryId()).ifPresent(entity -> {
            entity.setStatus("POSTED");
            entity.setFiscalPeriod(event.fiscalPeriod());
            entity.setPostedBy(event.postedBy());
            entity.setPostedAt(event.postedAt());
            repository.save(entity);
        });
    }

    @EventHandler
    public void on(JournalEntryCancelledEvent event) {
        repository.findById(event.journalEntryId()).ifPresent(entity -> {
            entity.setStatus("CANCELLED");
            entity.setCancelledBy(event.cancelledBy());
            entity.setCancellationReason(event.reason());
            entity.setCancelledAt(event.cancelledAt());
            repository.save(entity);
        });
    }
}

22.6.2 Read Model Entity(JPA)

コード例: JournalEntryEntity.java
package com.example.accounting.journal.adapter.outbound.persistence.entity;

import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

/**
 * JournalEntry Read Model Entity(JPA)
 */
@Entity
@Table(name = "journal_entries")
public class JournalEntryEntity {

    @Id
    @Column(name = "journal_entry_id", length = 36)
    private String journalEntryId;

    @Column(name = "journal_number", length = 20, nullable = false)
    private String journalNumber;

    @Column(name = "transaction_date", nullable = false)
    private LocalDate transactionDate;

    @Column(name = "description", length = 500)
    private String description;

    @Column(name = "status", length = 20, nullable = false)
    private String status;

    @Column(name = "fiscal_period", length = 10)
    private String fiscalPeriod;

    @Column(name = "created_by", length = 100, nullable = false)
    private String createdBy;

    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;

    @Column(name = "submitted_by", length = 100)
    private String submittedBy;

    @Column(name = "submitted_at")
    private LocalDateTime submittedAt;

    @Column(name = "approved_by", length = 100)
    private String approvedBy;

    @Column(name = "approval_comment", length = 500)
    private String approvalComment;

    @Column(name = "approved_at")
    private LocalDateTime approvedAt;

    @Column(name = "rejected_by", length = 100)
    private String rejectedBy;

    @Column(name = "rejection_reason", length = 500)
    private String rejectionReason;

    @Column(name = "rejected_at")
    private LocalDateTime rejectedAt;

    @Column(name = "posted_by", length = 100)
    private String postedBy;

    @Column(name = "posted_at")
    private LocalDateTime postedAt;

    @Column(name = "cancelled_by", length = 100)
    private String cancelledBy;

    @Column(name = "cancellation_reason", length = 500)
    private String cancellationReason;

    @Column(name = "cancelled_at")
    private LocalDateTime cancelledAt;

    @OneToMany(mappedBy = "journalEntry", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<JournalLineEntity> lines = new ArrayList<>();

    // Getters and Setters
    public String getJournalEntryId() { return journalEntryId; }
    public void setJournalEntryId(String journalEntryId) { this.journalEntryId = journalEntryId; }

    public String getJournalNumber() { return journalNumber; }
    public void setJournalNumber(String journalNumber) { this.journalNumber = journalNumber; }

    public LocalDate getTransactionDate() { return transactionDate; }
    public void setTransactionDate(LocalDate transactionDate) { this.transactionDate = transactionDate; }

    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }

    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }

    public String getFiscalPeriod() { return fiscalPeriod; }
    public void setFiscalPeriod(String fiscalPeriod) { this.fiscalPeriod = fiscalPeriod; }

    public String getCreatedBy() { return createdBy; }
    public void setCreatedBy(String createdBy) { this.createdBy = createdBy; }

    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }

    public String getSubmittedBy() { return submittedBy; }
    public void setSubmittedBy(String submittedBy) { this.submittedBy = submittedBy; }

    public LocalDateTime getSubmittedAt() { return submittedAt; }
    public void setSubmittedAt(LocalDateTime submittedAt) { this.submittedAt = submittedAt; }

    public String getApprovedBy() { return approvedBy; }
    public void setApprovedBy(String approvedBy) { this.approvedBy = approvedBy; }

    public String getApprovalComment() { return approvalComment; }
    public void setApprovalComment(String approvalComment) { this.approvalComment = approvalComment; }

    public LocalDateTime getApprovedAt() { return approvedAt; }
    public void setApprovedAt(LocalDateTime approvedAt) { this.approvedAt = approvedAt; }

    public String getRejectedBy() { return rejectedBy; }
    public void setRejectedBy(String rejectedBy) { this.rejectedBy = rejectedBy; }

    public String getRejectionReason() { return rejectionReason; }
    public void setRejectionReason(String rejectionReason) { this.rejectionReason = rejectionReason; }

    public LocalDateTime getRejectedAt() { return rejectedAt; }
    public void setRejectedAt(LocalDateTime rejectedAt) { this.rejectedAt = rejectedAt; }

    public String getPostedBy() { return postedBy; }
    public void setPostedBy(String postedBy) { this.postedBy = postedBy; }

    public LocalDateTime getPostedAt() { return postedAt; }
    public void setPostedAt(LocalDateTime postedAt) { this.postedAt = postedAt; }

    public String getCancelledBy() { return cancelledBy; }
    public void setCancelledBy(String cancelledBy) { this.cancelledBy = cancelledBy; }

    public String getCancellationReason() { return cancellationReason; }
    public void setCancellationReason(String cancellationReason) { this.cancellationReason = cancellationReason; }

    public LocalDateTime getCancelledAt() { return cancelledAt; }
    public void setCancelledAt(LocalDateTime cancelledAt) { this.cancelledAt = cancelledAt; }

    public List<JournalLineEntity> getLines() { return lines; }
    public void setLines(List<JournalLineEntity> lines) { this.lines = lines; }
}
コード例: JournalLineEntity.java
package com.example.accounting.journal.adapter.outbound.persistence.entity;

import jakarta.persistence.*;
import java.math.BigDecimal;

/**
 * JournalLine Read Model Entity(JPA)
 */
@Entity
@Table(name = "journal_lines")
public class JournalLineEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "journal_entry_id", nullable = false)
    private JournalEntryEntity journalEntry;

    @Column(name = "account_code", length = 10, nullable = false)
    private String accountCode;

    @Column(name = "account_name", length = 100, nullable = false)
    private String accountName;

    @Column(name = "debit_amount", precision = 15, scale = 2, nullable = false)
    private BigDecimal debitAmount;

    @Column(name = "credit_amount", precision = 15, scale = 2, nullable = false)
    private BigDecimal creditAmount;

    // Getters and Setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public JournalEntryEntity getJournalEntry() { return journalEntry; }
    public void setJournalEntry(JournalEntryEntity journalEntry) { this.journalEntry = journalEntry; }

    public String getAccountCode() { return accountCode; }
    public void setAccountCode(String accountCode) { this.accountCode = accountCode; }

    public String getAccountName() { return accountName; }
    public void setAccountName(String accountName) { this.accountName = accountName; }

    public BigDecimal getDebitAmount() { return debitAmount; }
    public void setDebitAmount(BigDecimal debitAmount) { this.debitAmount = debitAmount; }

    public BigDecimal getCreditAmount() { return creditAmount; }
    public void setCreditAmount(BigDecimal creditAmount) { this.creditAmount = creditAmount; }
}

22.6.3 JPA Repository インターフェース

コード例: JournalEntryJpaRepository.java
package com.example.accounting.journal.adapter.outbound.persistence.repository;

import com.example.accounting.journal.adapter.outbound.persistence.entity.JournalEntryEntity;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.time.LocalDate;
import java.util.List;
import java.util.Optional;

/**
 * JournalEntry JPA Repository
 */
@Repository
public interface JournalEntryJpaRepository extends JpaRepository<JournalEntryEntity, String> {

    /**
     * ID で検索(明細を EAGER ロード)
     */
    @EntityGraph(attributePaths = {"lines"})
    Optional<JournalEntryEntity> findWithLinesById(String journalEntryId);

    /**
     * ステータスで検索
     */
    List<JournalEntryEntity> findByStatus(String status);

    /**
     * 期間で検索
     */
    List<JournalEntryEntity> findByTransactionDateBetween(LocalDate startDate, LocalDate endDate);

    /**
     * 会計期間で検索
     */
    List<JournalEntryEntity> findByFiscalPeriod(String fiscalPeriod);

    /**
     * 勘定科目コードを含む仕訳を検索
     */
    @Query("SELECT DISTINCT je FROM JournalEntryEntity je JOIN je.lines l WHERE l.accountCode = :accountCode")
    List<JournalEntryEntity> findByAccountCode(@Param("accountCode") String accountCode);

    /**
     * 仕訳番号で検索
     */
    Optional<JournalEntryEntity> findByJournalNumber(String journalNumber);
}

22.7 REST API と統合テスト

22.7.1 REST Controller(Inbound Adapter)

コード例: JournalsController.java
package com.example.accounting.journal.adapter.inbound.rest.journals;

import com.example.accounting.journal.adapter.inbound.rest.journals.protocol.*;
import com.example.accounting.journal.adapter.outbound.persistence.repository.JournalEntryJpaRepository;
import com.example.accounting.journal.api.events.JournalEntryCreatedEvent.JournalLineData;
import com.example.accounting.journal.domain.model.aggregate.journal.JournalEntryCommands.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.axonframework.commandhandling.gateway.CommandGateway;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.UUID;
import java.util.concurrent.CompletableFuture;

/**
 * Journal REST Controller(Inbound Adapter)
 */
@RestController
@RequestMapping("/api/journals")
@Tag(name = "Journals", description = "Journal Entry management API")
public class JournalsController {

    private final CommandGateway commandGateway;
    private final JournalEntryJpaRepository repository;

    public JournalsController(CommandGateway commandGateway, JournalEntryJpaRepository repository) {
        this.commandGateway = commandGateway;
        this.repository = repository;
    }

    // ========== Command Side (Write) ==========

    @PostMapping
    @Operation(summary = "Create a new journal entry")
    public CompletableFuture<ResponseEntity<JournalCreateResponse>> create(
            @Valid @RequestBody JournalCreateRequest request
    ) {
        var journalEntryId = UUID.randomUUID().toString();

        var lineInputs = request.lines().stream()
                .map(line -> new JournalLineInput(
                        line.accountCode(),
                        line.accountName(),
                        line.debitAmount(),
                        line.creditAmount()
                ))
                .toList();

        var command = new CreateJournalEntryCommand(
                journalEntryId,
                request.transactionDate(),
                request.description(),
                lineInputs,
                request.createdBy()
        );

        return commandGateway.send(command)
                .thenApply(result -> ResponseEntity
                        .status(HttpStatus.CREATED)
                        .body(new JournalCreateResponse(journalEntryId)));
    }

    @PostMapping("/{id}/submit")
    @Operation(summary = "Submit journal entry for approval")
    public CompletableFuture<ResponseEntity<Void>> submit(
            @PathVariable String id,
            @Valid @RequestBody JournalSubmitRequest request
    ) {
        var command = new SubmitJournalEntryCommand(id, request.submittedBy());

        return commandGateway.send(command)
                .thenApply(result -> ResponseEntity.ok().<Void>build());
    }

    @PostMapping("/{id}/approve")
    @Operation(summary = "Approve journal entry")
    public CompletableFuture<ResponseEntity<Void>> approve(
            @PathVariable String id,
            @Valid @RequestBody JournalApproveRequest request
    ) {
        var command = new ApproveJournalEntryCommand(id, request.approvedBy(), request.comment());

        return commandGateway.send(command)
                .thenApply(result -> ResponseEntity.ok().<Void>build());
    }

    @PostMapping("/{id}/reject")
    @Operation(summary = "Reject journal entry")
    public CompletableFuture<ResponseEntity<Void>> reject(
            @PathVariable String id,
            @Valid @RequestBody JournalRejectRequest request
    ) {
        var command = new RejectJournalEntryCommand(id, request.rejectedBy(), request.reason());

        return commandGateway.send(command)
                .thenApply(result -> ResponseEntity.ok().<Void>build());
    }

    @PostMapping("/{id}/post")
    @Operation(summary = "Post journal entry to general ledger")
    public CompletableFuture<ResponseEntity<Void>> post(
            @PathVariable String id,
            @Valid @RequestBody JournalPostRequest request
    ) {
        var command = new PostJournalEntryCommand(id, request.fiscalPeriod(), request.postedBy());

        return commandGateway.send(command)
                .thenApply(result -> ResponseEntity.ok().<Void>build());
    }

    @PostMapping("/{id}/cancel")
    @Operation(summary = "Cancel journal entry")
    public CompletableFuture<ResponseEntity<Void>> cancel(
            @PathVariable String id,
            @Valid @RequestBody JournalCancelRequest request
    ) {
        var command = new CancelJournalEntryCommand(id, request.cancelledBy(), request.reason());

        return commandGateway.send(command)
                .thenApply(result -> ResponseEntity.ok().<Void>build());
    }

    // ========== Query Side (Read) ==========

    @GetMapping("/{id}")
    @Operation(summary = "Get a journal entry by ID")
    public ResponseEntity<JournalGetResponse> get(@PathVariable String id) {
        return repository.findWithLinesById(id)
                .map(entity -> ResponseEntity.ok(JournalGetResponse.from(entity)))
                .orElse(ResponseEntity.notFound().build());
    }

    @GetMapping
    @Operation(summary = "Get all journal entries")
    public ResponseEntity<JournalListResponse> getAll() {
        var entities = repository.findAll();
        var items = entities.stream()
                .map(JournalGetResponse::from)
                .toList();
        return ResponseEntity.ok(new JournalListResponse(items));
    }

    @GetMapping("/status/{status}")
    @Operation(summary = "Get journal entries by status")
    public ResponseEntity<JournalListResponse> getByStatus(@PathVariable String status) {
        var entities = repository.findByStatus(status);
        var items = entities.stream()
                .map(JournalGetResponse::from)
                .toList();
        return ResponseEntity.ok(new JournalListResponse(items));
    }
}

22.7.2 API エンドポイント一覧

メソッド パス 説明 種別
POST /api/journals 仕訳作成 Command
POST /api/journals/{id}/submit 承認申請 Command
POST /api/journals/{id}/approve 承認 Command
POST /api/journals/{id}/reject 却下 Command
POST /api/journals/{id}/post 転記 Command
POST /api/journals/{id}/cancel キャンセル Command
GET /api/journals 仕訳一覧取得 Query
GET /api/journals/{id} 仕訳詳細取得 Query
GET /api/journals/status/{status} ステータス別取得 Query

22.7.3 Axon Test による単体テスト

コード例: JournalEntryAggregateTest.java
package com.example.accounting.journal.application.aggregate;

import com.example.accounting.journal.api.events.*;
import com.example.accounting.journal.api.events.JournalEntryCreatedEvent.JournalLineData;
import com.example.accounting.journal.domain.model.aggregate.journal.JournalEntryCommands.*;
import org.axonframework.test.aggregate.AggregateTestFixture;
import org.axonframework.test.aggregate.FixtureConfiguration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;

class JournalEntryAggregateTest {

    private FixtureConfiguration<JournalEntryAggregateAdapter> fixture;

    @BeforeEach
    void setUp() {
        fixture = new AggregateTestFixture<>(JournalEntryAggregateAdapter.class);
    }

    @Nested
    @DisplayName("仕訳作成")
    class CreateJournalEntry {

        @Test
        @DisplayName("貸借一致の仕訳を作成できる")
        void shouldCreateBalancedJournalEntry() {
            var command = new CreateJournalEntryCommand(
                    "je-1",
                    LocalDate.now(),
                    "売上計上",
                    List.of(
                            new JournalLineInput("1100", "売掛金", BigDecimal.valueOf(10000), BigDecimal.ZERO),
                            new JournalLineInput("4100", "売上高", BigDecimal.ZERO, BigDecimal.valueOf(10000))
                    ),
                    "user-1"
            );

            fixture.givenNoPriorActivity()
                    .when(command)
                    .expectSuccessfulHandlerExecution()
                    .expectEventsMatching(events ->
                            events.getPayload() instanceof JournalEntryCreatedEvent
                    );
        }

        @Test
        @DisplayName("貸借不一致の場合はエラー")
        void shouldRejectUnbalancedJournalEntry() {
            var command = new CreateJournalEntryCommand(
                    "je-1",
                    LocalDate.now(),
                    "不正な仕訳",
                    List.of(
                            new JournalLineInput("1100", "売掛金", BigDecimal.valueOf(10000), BigDecimal.ZERO),
                            new JournalLineInput("4100", "売上高", BigDecimal.ZERO, BigDecimal.valueOf(5000))
                    ),
                    "user-1"
            );

            fixture.givenNoPriorActivity()
                    .when(command)
                    .expectException(IllegalArgumentException.class);
        }
    }

    @Nested
    @DisplayName("仕訳承認")
    class ApproveJournalEntry {

        @Test
        @DisplayName("承認待ちの仕訳を承認できる")
        void shouldApprovePendingJournalEntry() {
            var createdEvent = new JournalEntryCreatedEvent(
                    "je-1",
                    "JE-001",
                    LocalDate.now(),
                    "売上計上",
                    List.of(
                            new JournalLineData("1100", "売掛金", BigDecimal.valueOf(10000), BigDecimal.ZERO),
                            new JournalLineData("4100", "売上高", BigDecimal.ZERO, BigDecimal.valueOf(10000))
                    ),
                    "user-1",
                    LocalDateTime.now()
            );
            var submittedEvent = new JournalEntrySubmittedEvent("je-1", "user-1", LocalDateTime.now());

            fixture.given(createdEvent, submittedEvent)
                    .when(new ApproveJournalEntryCommand("je-1", "manager-1", "承認します"))
                    .expectSuccessfulHandlerExecution()
                    .expectEventsMatching(events ->
                            events.getPayload() instanceof JournalEntryApprovedEvent
                    );
        }
    }

    @Nested
    @DisplayName("仕訳転記")
    class PostJournalEntry {

        @Test
        @DisplayName("承認済みの仕訳を転記できる")
        void shouldPostApprovedJournalEntry() {
            var createdEvent = new JournalEntryCreatedEvent(
                    "je-1",
                    "JE-001",
                    LocalDate.now(),
                    "売上計上",
                    List.of(
                            new JournalLineData("1100", "売掛金", BigDecimal.valueOf(10000), BigDecimal.ZERO),
                            new JournalLineData("4100", "売上高", BigDecimal.ZERO, BigDecimal.valueOf(10000))
                    ),
                    "user-1",
                    LocalDateTime.now()
            );
            var submittedEvent = new JournalEntrySubmittedEvent("je-1", "user-1", LocalDateTime.now());
            var approvedEvent = new JournalEntryApprovedEvent("je-1", "manager-1", "OK", LocalDateTime.now());

            fixture.given(createdEvent, submittedEvent, approvedEvent)
                    .when(new PostJournalEntryCommand("je-1", "2024-01", "system"))
                    .expectSuccessfulHandlerExecution()
                    .expectEventsMatching(events ->
                            events.getPayload() instanceof JournalEntryPostedEvent
                    );
        }

        @Test
        @DisplayName("未承認の仕訳は転記できない")
        void shouldNotPostUnapprovedJournalEntry() {
            var createdEvent = new JournalEntryCreatedEvent(
                    "je-1",
                    "JE-001",
                    LocalDate.now(),
                    "売上計上",
                    List.of(
                            new JournalLineData("1100", "売掛金", BigDecimal.valueOf(10000), BigDecimal.ZERO),
                            new JournalLineData("4100", "売上高", BigDecimal.ZERO, BigDecimal.valueOf(10000))
                    ),
                    "user-1",
                    LocalDateTime.now()
            );

            fixture.given(createdEvent)
                    .when(new PostJournalEntryCommand("je-1", "2024-01", "system"))
                    .expectException(IllegalStateException.class);
        }
    }
}

22.8 まとめ

22.8.1 実装した機能一覧

項目 内容 主要技術
CQRS/ES アーキテクチャ コマンドとクエリの分離 Axon Framework, Event Sourcing
ドメイン設計 イベント、コマンド、ドメインモデル sealed interface, record
Aggregate Adapter フレームワーク依存の分離 @Aggregate, @CommandHandler
Projection Read Model の更新 @EventHandler, Spring Data JPA
REST API Inbound Adapter, テスト CommandGateway, Axon Test

22.8.2 MyBatis 版と JPA 版の比較

観点 MyBatis 版 JPA 版
設定 MapperScan, XML マッピング EnableJpaRepositories
Event Store JdbcEventStorageEngine JpaEventStorageEngine
Repository Mapper インターフェース + XML JpaRepository インターフェース
Projection Mapper.insert/update Repository.save
エンティティ POJO @Entity アノテーション
リレーション 手動結合 @OneToMany/@ManyToOne
クエリ XML で SQL 定義 メソッド名規約 + @Query

22.8.3 JPA 版の利点

利点 説明
コード量削減 Spring Data JPA による自動実装
型安全性 コンパイル時にクエリメソッドをチェック
リレーション管理 @OneToMany によるカスケード操作
監査機能 @EnableJpaAuditing による自動タイムスタンプ
N+1 対策 @EntityGraph による EAGER ロード制御

22.8.4 財務会計システムにおける CQRS/ES の意義

観点 説明
監査証跡 すべての仕訳変更履歴を完全に保持
内部統制 SOX 法対応の監査ログを自動生成
訂正仕訳 修正履歴を完全に追跡可能
期末決算 任意の時点での残高を再計算可能
試算表生成 Read Model を使った高速クエリ

CQRS/ES は導入コストが高いですが、監査要件が厳しい財務会計システムでは、その恩恵は大きくなります。JPA を使用することで、Read Model の管理が簡潔になり、Spring Data JPA の豊富な機能を活用できます。