「開発はどこから始めればよいか」に対するアプローチ
noteではかなりざっくりした話を書く。
今回は「開発はどこから始めればよいか」に対するアプローチについて。
開発プロセスの開始点についての質問は一般的であり、特に要件が不明確な場合には、次のような手法を採用することが多い。
ツールの選択は多岐にわたるが、必要に応じて適切なものを選ぶことが重要である。
イベントストーミング
ユーザーストーリーマッピング
リレーションシップ駆動要件分析(RDRA)
ドメインモデルの設計と実装に入る前に、システムの全体像を把握した上で、少なくともユーザーストーリと概念モデルを定義しておくことが重要である。
テスト駆動開発(TDD)のアプローチでは、ユーザーストーリーを基に最初にテストを記述する。例えば、「オークションに最高額で入札する」というユーザーストーリーがある場合、以下のようにメソッド呼び出し部分を想起しながらテストを作る。
auction, err = auction.Bid(highBidPrice, buyerId)
このテストでは、対象のオークションオブジェクトに入札命令を送ることを想定している。初期段階ではコンパイルエラーを気にせず、チーム内での認識の統一に焦点を当てる。
テストから始める利点は多岐にわたる。特に「脳みそのスタックが深い」ような熟練開発者でも、ゴールを明確に設定したほうが効果的だ。チームでの認識合わせにも役立つ。また「作る前に使う」ことで、オブジェクトのインターフェースを適切に設計できる。
一方で、「オブジェクトはDBから読み込まなければならない」という誤解があることもある。しかし、ビジネスロジックの単体テストにDBが必要かどうかは、テスト対象によって異なる。以下のテスト例では、Givenセクションでテストに必要なオブジェクトを準備している。DBからデータを読み込む必要はない。
// オークションに最高額で入札する
func Test_BidHighestAmountInAuction(t *testing.T) {
// Given
clock := createMockClock()
product := createProduct(t, domain.ProductTypeGeneric)
sellerId := domain.GenerateUserAccountId()
startDateTime, endDateTime := startAndEndDateTime(clock)
startPrice, err := domain.NewPrice(1000)
require.NoError(t, err)
auctionId := domain.GenerateAuctionId()
auction, err := domain.NewAuction(clock, auctionId, product, &startDateTime, &endDateTime, startPrice, sellerId)
require.NoError(t, err)
buyerId := domain.GenerateUserAccountId()
highBidPrice, err := domain.NewPrice(2000)
require.NoError(t, err)
callback := false
setClock(startDateTime, auction)
auction, err = auction.Start(func(auction *domain.Auction) {
callback = true
})
require.NoError(t, err)
require.NotNil(t, auction)
require.True(t, callback)
// When
auction, err = auction.Bid(highBidPrice, buyerId)
// Then
require.NoError(t, err)
require.Equal(t, auction.GetHighBidderId(), buyerId)
require.Equal(t, auction.GetHighBidPrice(), highBidPrice)
}
このテストはやや長いため、以下のようにヘルパーメソッドを使用して宣言的に記述することもできる。
// オークションに最高額で入札する
func TestAuctionBidWithHighestAmount(t *testing.T) {
// Given
auction := setupAuctionWithStartPrice(t, 1000)
buyerId := domain.GenerateUserAccountId()
highBidPrice := createPrice(t, 2000)
// When
auction, err = auction.Bid(highBidPrice, buyerId)
// Then
require.NoError(t, err)
assertHighBid(t, auction, buyerId, highBidPrice)
}
func setupAuctionWithStartPrice(t *testing.T, startPrice int) *domain.Auction {
// ...
}
func createPrice(t *testing.T, amount int) domain.Price {
price, err := domain.NewPrice(amount)
require.NoError(t, err)
return price
}
func assertHighBid(t *testing.T, auction *domain.Auction, bidderId domain.UserAccountId, expectedPrice domain.Price) {
require.Equal(t, auction.GetHighBidderId(), bidderId)
require.Equal(t, auction.GetHighBidPrice(), expectedPrice)
}
テスト対象が何であり、目的が何であるかを明確にすることが重要だ。これが不明瞭だと、開発は迷走しやすくなる。
テスト対象の考え方には様々なアプローチがあるが、私はできるだけ疎結合を目指している。ドメインモデルのテストでは、ドメインモデル以外の要素はモックにすることが多い。例えば、認証部分の単体テストでは、認証サービスが必要なモデルを取得するためのリポジトリの抽象に依存する。以下の例では、インメモリリポジトリを使用している。
// 登録済みの未ログインユーザがログインできる
func Test_RegisteredNonLoggedInUserCanLogin(t *testing.T) {
// Given
userAccount, authService := setupUserAndAuthService(t, "Junichi", "Kato")
// When
login, err := authenticationService.Login(userAccount1.GetId(), userAccount1.GetPassword())
// Then
require.NoError(t, err)
assertLoginSuccessful(t, login, sessionRepository)
}
func setupUserAndAuthService(t *testing.T, name, password string) (*domain.UserAccount, *infrastructure.AuthenticationService) {
userAccountRepository := memory.NewUserAccountRepositoryInMemory() // 単体テストはインメモリ版のリポジトリを使う
sessionRepository := memory.NewSessionRepositoryInMemory() // 単体テストはインメモリ版のリポジトリを使う
userAccount, err := domain.NewUserAccount(domain.GenerateUserAccountId(), name, password)
require.NoError(t, err)
err = userAccountRepository.Store(userAccount)
require.NoError(t, err)
authenticationService := infrastructure.NewAuthenticationService(userAccountRepository, sessionRepository)
return userAccount, authenticationService
}
func assertLoginSuccessful(t *testing.T, login *domain.Login, sessionRepository domain.SessionRepository) {
require.NoError(t, login.Err)
require.NotNil(t, login)
// セッションの検索と検証
session, err := sessionRepository.FindById(login.Session.GetId())
require.NoError(t, err)
require.NotNil(t, session)
}
このように、ユーザーの要求に対応したユーザーストーリに基づいてドメインモデルのテストを設計し、そのテストを通じていち早く機能要求を満たすことを確認する。目標が明確になれば、ドメインより外部レイヤーの要求に対する設計や実装を進めることができる。
ドメインモデルの設計では、データの読み込みや表示の要求を同時に満たすことは挑戦的である。そのような状況ではCQRSの考え方が役立つ。CQRSとイベントソーシングを組み合わせると、システムの複雑度が増すが、適切に対処することで効率的なソフトウェア設計が可能となる。
お知らせ
おかげさまで、2024年3月24日のオブジェクト指向カンファレンスで「CQRS/Event Sourcingシステム実装入門」というハンズオンを実施することになりました。スターを付けてくれた方々、ありがとうございます。
CQRS/Event Sourcingの実装レベルでの詳しい話はこのセッションで聴けます。よかったら参加してみてください。