はじめに
はじめまして。寿山と申します。
Qiita初投稿になります!!
みなさん、ドメイン駆動設計やっていますか?
ドメイン駆動設計をやると、保守性の高いプログラムが書けそうで、やってみたい!!ってなりますよね。
でも、難しいですよねドメイン駆動設計・・・
本もいくつかありますが、理解するのが難しい。もっと実践的なコードサンプルが見たいって人も多いのではないでしょうか。
ただ、ここ数年、勉強会やイベント等でドメイン駆動設計が話題になることも増え、サンプルプログラムを見る機会も増えてきているような気がします。
そこで、サンプルプログラムを自分でも書いて、実践的なノウハウを身に着けたいと思い、IPAのデータベーススペシャリストの試験問題をお題としてサンプルプログラムを作ってみました。
ソースコードは こちら
本記事投稿時のバージョンはタグ1.0になります。
全然ドメイン駆動設計になってないってツッコミがたくさん入ってしまいそうなモノになってしまいましたが、なんとか形にすることはできました。
今回は、実際にやってみて、よかったところやうまくいかなかったところなどをお話ししたいと思います。
アプリケーションの仕様等
問題
IPA 平成31年度 春期データベーススペシャリスト試験 午後1問題 の問1の問題で出題された大会運営システムをドメイン駆動設計で作成してみたサンプルプログラムになります。
制限事項
- 画面は存在せず、APIのみとなります。
- 問題内では、システムを利用する人がA社、主催者、大会に参加申し込む会員の3パターン考えられますが、今回は利用者の区別は行っておりません。なので、認証、認可の処理は作成していません。
- 抽選枠の申込終了後、抽選結果をバッチ処理等で登録することが想定されますが、今回は抽選結果を登録するAPIを作成しています。
ER図
出題された問題のままのテーブル設計となっています。
クラス図
ドメインモデルのクラスのみを抜粋し、クラス図を作成してみました。
線がごちゃごちゃするのを避けるため、関連はほとんど書いていません。
Entryクラスが抽象クラスなので、これを実装している具象クラスの汎化の関連と、MemberPointsクラスとMemberPointクラスの多重度のみを記入しています。
クラス図は後から書いたのですが、メソッドが少ない・・・
(本当にこれでドメイン駆動設計と呼んでいいのか・・・)
APIのエンドポイント
今回は以下の3つのエンドポイントを作成しました。
エントリ枠に申し込む
POST : /applications/entry
- festivalId : 大会番号
- memberId : 会員番号
- entryId : エントリ枠番号
- applicationDate : 参加申込年月日
抽選結果を登録する
POST : /lottery-entry-result
- festivalId : 大会番号
- memberId : 会員番号
- entryId : エントリ枠番号
- lotteryResult : 抽選結果
入金する
POST : /applications/payment
- festivalId : 大会番号
- memberId : 会員番号
- paymentDate : 入金年月日
- usePoints : 使用ポイント
良かったところ、うまくいかなかったところ
Application層が肥大化
ドメインモデルにメソッドを作れた場合、Application層が割と薄くなり、ドメインモデルにほとんどメソッドを作れなかった場合はApplication層が肥大化する結果となりました。(当然の結果です・・・)
3つのエンドポイントのApplication層のメソッドは以下の通り
/**
* エントリー枠に申し込む.
*/
public void applyForEntry(ApplyForEntryRequest request) {
final FestivalId festivalId = request.festivalId();
final MemberId memberId = request.memberId();
final EntryId entryId = request.entryId();
final LocalDate applicationDate = request.getApplicationDate();
final Member member = memberRepository.findMember(request.memberId());
if (member == null) {
throw new BusinessErrorException("存在しない会員です");
}
final Application alreadyApplication =
applicationRepository.findApplication(festivalId, memberId);
if (alreadyApplication != null) {
throw new BusinessErrorException("指定した大会には既に申し込み済みです");
}
final Entry entry = entryRepository.findEntry(festivalId, entryId);
if (entry == null) {
throw new BusinessErrorException("存在しないエントリ枠です");
}
final Application application = Application.createEntityForEntry(
festivalId,
memberId,
entryId,
applicationDate
);
entry.validateAndThrowBusinessErrorIfHasErrorForApplication(application);
entry.incrementApplicationNumbers();
entryRepository.saveEntry(entry);
applicationRepository.addApplication(application);
}
/**
* 抽選結果を登録する.
*/
public void registerLotteryEntryResult(RegisterLotteryEntryResultRequest request) {
final Member member = memberRepository.findMember(request.memberId());
if (member == null) {
throw new BusinessErrorException("存在しない会員です");
}
final Entry entry = entryRepository.findEntry(request.festivalId(), request.entryId());
if (entry == null) {
throw new BusinessErrorException("存在しないエントリ枠です");
}
if (!entry.isLotteryEntry()) {
throw new BusinessErrorException("抽選のエントリ枠ではありません");
}
if (entry.entryStatus() != EntryStatus.underLottery) {
throw new BusinessErrorException("まだ抽選を開始していません");
}
LotteryEntryResult already = lotteryEntryResultRepository.findLotteryEntryResult(
request.festivalId(),
request.memberId(),
request.entryId()
);
if (already != null) {
throw new BusinessErrorException("既に抽選結果を登録済みです");
}
Application application = applicationRepository.findApplication(
request.festivalId(),
request.memberId());
if (application == null) {
throw new BusinessErrorException("対象の大会には申込を行っていません");
}
// 申込を行ったエントリ枠ではない場合、申し込んだエントリ枠の多段階抽選枠か?
if (!(application.entryId().equals(request.entryId()))) {
Entry firstEntry = entryRepository.findEntry(request.festivalId(), application.entryId());
if (!(((LotteryEntry)firstEntry).followingEntryId().equals(request.entryId()))) {
throw new BusinessErrorException("抽選対象外のエントリ枠です");
}
LotteryEntryResult firstEntryResult =
lotteryEntryResultRepository.findLotteryEntryResult(
application.festivalId(),
application.memberId(),
application.entryId());
if (firstEntryResult == null) {
throw new BusinessErrorException("まだ申し込んだエントリ枠の抽選結果が登録されていません");
}
if (firstEntryResult.lotteryResult() == LotteryResult.winning) {
throw new BusinessErrorException("既に当選済みのため、後続エントリ枠の抽選結果を登録できません");
}
}
LotteryEntryResult lotteryEntryResult = new LotteryEntryResult(
request.festivalId(),
request.memberId(),
request.entryId(),
request.getLotteryResult()
);
lotteryEntryResultRepository.saveLotteryEntryResult(lotteryEntryResult);
}
/**
* 入金する.
*/
public void payToApplication(PaymentRequest request) {
final FestivalId festivalId = request.festivalId();
final MemberId memberId = request.memberId();
Application application = applicationRepository.findApplication(festivalId, memberId);
if (application == null) {
throw new BusinessErrorException("対象の大会には申込を行っていません");
}
Entry entry = entryRepository.findEntry(festivalId, application.entryId());
if (entry.isLotteryEntry()) {
// 対象のエントリが抽選なら当選しているかを確認する
LotteryEntryResult entryResult = lotteryEntryResultRepository.findLotteryEntryResult(
festivalId, memberId, entry.entryId());
if (entryResult.lotteryResult() == LotteryResult.failed) {
EntryId followingEntryId = ((LotteryEntry)entry).followingEntryId();
if (followingEntryId == null) {
throw new BusinessErrorException("対象の大会には当選していません");
} else {
LotteryEntryResult followingEntryResult =
lotteryEntryResultRepository.findLotteryEntryResult(
festivalId, memberId, followingEntryId);
if (followingEntryResult.lotteryResult() == LotteryResult.failed) {
throw new BusinessErrorException("対象の大会には当選していません");
}
}
}
}
PointAmount usePoints = new PointAmount(request.getUsePoints());
if (usePoints.isPositive()) {
MemberPoints memberPoints = memberPointRepository.findMemberPoints(memberId);
memberPoints.usePoints(request.getPaymentDate(), usePoints);
memberPointRepository.saveMemberPoints(memberPoints);
}
application.pay(request.getPaymentDate(), usePoints);
applicationRepository.saveApplication(application);
}
「エントリ枠に申し込む」と「入金する」はまだマシかなと思うのですが、「抽選結果を登録する」の方は業務エラーのチェック処理をたくさんApplication層に作る結果となってしまいました。こういう時こそドメインサービスを作るべきなのかとも思いましたが、ドメインサービスクラスを作るだけだと、ただ、コードを書く場所が変わるだけにも思えたので今回はやめときました。そもそものEntityの設計がDBに引きずれてうまくいっていないのだろうなぁと感じています。
「エントリ枠に申し込む」と「入金する」の方は、少しはオブジェクト指向で設計できた気がするので、紹介させてください。
エントリー枠に申し込む
エントリ枠には先着枠と抽選枠のパターンがあるのですが、今回、エントリ枠を抽象クラスで作成し、先着枠と抽選枠を具象クラスにしてみました。
例えば、申込時には、申込人数をインクリメントするのですが、それぞれ以下のような実装になっています。
/** 先着順エントリ枠. */
/**
* 参加申込人数をインクリメントする. 参加申込人数が定員に達した場合は、参加者確定に変更する.
*/
@Override
public void incrementApplicationNumbers() {
applicationNumbers = applicationNumbers.increment();
if (capacity.same(applicationNumbers)) {
entryStatus = EntryStatus.participantConfirmation;
}
}
/** 抽選エントリ枠. */
@Override
public void incrementApplicationNumbers() {
applicationNumbers = applicationNumbers.increment();
}
という風に、先着枠の場合、申込人数が定員に達した時点で申し込みを終了する必要があります。
クライアント側では以下のように、インターフェイスに対してメソッドを実行しているので、オブジェクト指向っぽくできたかなと。
final Entry entry = entryRepository.findEntry(festivalId, entryId);
entry.incrementApplicationNumbers();
ちなみに、DBからEntityを取得する部分は、MyBatisからはDTOでデータを取得し、先着枠か、抽選枠かで生成するクラスを分けています。
ここも、上手くいった部分かなと。
public Entry findEntry(FestivalId festivalId, EntryId entryId) {
EntryDto dto = entryMapper.selectEntry(festivalId, entryId);
if (dto == null) {
return null;
}
if (dto.firstArrivalLotteryType == FirstArrivalLotteryType.firstArrival) {
return new FirstArrivalEntry(
dto.festivalId,
dto.entryId,
dto.entryName,
dto.entryDescription,
dto.eventCode,
dto.capacity,
dto.participationFees,
dto.applicationNumbers,
dto.applicationStartDate,
dto.applicationEndDate,
dto.entryStatus);
} else {
return new LotteryEntry(
dto.festivalId,
dto.entryId,
dto.entryName,
dto.entryDescription,
dto.eventCode,
dto.capacity,
dto.participationFees,
dto.applicationNumbers,
dto.applicationStartDate,
dto.applicationEndDate,
dto.entryStatus,
dto.lotteryDate,
dto.followingEntryId);
}
}
入金する
入金時には、ポイントを使用することができるのですが、このポイントは有効期限が1年で、有効期限が近いものから使用されるという仕様で、ここが上手くいったかなと思っています。
現場で役立つシステム設計の原則 で紹介されているコレクションオブジェクトを使用しています。
public class MemberPoint implements Entity {
private MemberId memberId;
private LocalDate givenPointDate;
private PointAmount givenPoint;
private PointAmount usedPoint;
/**
* 引数で使用した値分のポイントを使用する.
*/
void use(BigDecimal value) {
BigDecimal totalUsedPointAmountValue = usedPoint.value().add(value);
if (totalUsedPointAmountValue.compareTo(givenPoint.value()) > 0) {
throw new IllegalArgumentException("付与ポイントより使用済ポイント数が多くなっています");
}
usedPoint = new PointAmount(totalUsedPointAmountValue);
}
}
public class MemberPoints {
private List<MemberPoint> list;
/**
* 引数のポイントが使えるかどうかを判定し、有効期限の近いポイントから使用する.
* なお、保持する MemberPoint オブジェクトの状態を変更する。
*/
public void usePoints(LocalDate paymentDate, PointAmount pointAmount) {
// この値がゼロになるまでこれまで付与されたポイントからポイントを使用していく
BigDecimal x = pointAmount.value();
for (MemberPoint memberPoint : list) {
// 有効期限のチェック
LocalDate expirationDate = memberPoint.givenPointDate().plusYears(1);
if (paymentDate.compareTo(expirationDate) > 0) {
continue;
}
// ポイント残高のチェック
BigDecimal availableUsePoint = memberPoint.givenPoint().value()
.subtract(memberPoint.usedPoint().value());
if (availableUsePoint.compareTo(BigDecimal.ZERO) == 0) {
continue;
}
if (availableUsePoint.compareTo(x) <= 0) {
memberPoint.use(availableUsePoint);
x = x.subtract(availableUsePoint);
} else {
memberPoint.use(x);
x = BigDecimal.ZERO;
break;
}
}
// 有効期限の近いものからポイントを使用していき、利用したいポイントに満たなかった場合エラー
if (x.compareTo(BigDecimal.ZERO) > 0) {
throw new BusinessErrorException("ポイント数が不足しています");
}
}
}
クライアントからは、以下のコードで、ポイント残高のチェックと複数のMemberPointの状態の変更ができました。
MemberPoints memberPoints = memberPointRepository.findMemberPoints(memberId);
memberPoints.usePoints(request.getPaymentDate(), usePoints);
memberPointRepository.saveMemberPoints(memberPoints);
さいごに
最終的にはそれなりのボリュームになり、バリデーションやDBへの保存まで含め、割と実践的なサンプルがつくれたのでよかったです。
ただし、実際にやってみて、もっと改善したいなと思ったのは以下の通りです。
- ValueObjectにメソッドがほとんどないので、ValueObjectを生かせていない。
- 最初に触れたようにApplication層が肥大化してしまった。複数のテーブルのデータを扱う必要があるユースケースにおいて、設計力が足りない。
これらを改善できるように、リファクタリング、あるいは、違う年度の問題で(もっと計算などが多い問題をチョイスした方がよかったかもです)実践してみようかなと思います。
またこの記事を読んで、「こうしたらもっとよくなるのでは?」などの意見がありましたら、コメントいただけると幸いです。
ここまで読んでいただき、ありがとうございました!!