本記事は ドメイン駆動設計#1 Advent Calendar 2019 18 日目の記事です。
元々私が参加登録していたわけではありませんが、空きが出ていたので、僭越ながら記事を書かせていただきます。
はじめに
最近 DDD が盛り上がっていますね。
私も数年前にエヴァンス本読んでちょくちょく勉強してたのですが、今年から色々カンファレンス行くようになって本格的に勉強始めました。
ということで、今回は個人的に DDD を練習してみた結果を書いてみたいと思います。
言語は普段使っている C# で書いています。
テーマは「備品予約」
練習しようと思ったきっかけは、「レガシーをぶっつぶせ。現場でDDD! 」に参加した際に購入した「もくもくモデリングの森を旅するチビドラゴンの軌跡」という本です。
サンプルのお題として会議室予約が挙げられており、会社の人と勉強やろうと思って話してたら、会社で使ってる備品の予約とかどう?となって、まずは自分でやってみようと思ったところが始まりです。
要件
まずは要件を以下のような感じでまとめました。
最初なので、そんなに複雑にはしませんでした。
- 利用者は備品を予約できる。
- 備品には USB, ポケットWifi, 携帯電話 がある。
- 備品、予約時間、利用目的を指定して予約する。
- 予約対象の備品がすでに同一時間帯に予約されている場合は予約できない。
- 利用者は予約をキャンセルできる。
- 利用者は予約を変更できる。
ドメインモデル
上記の要件から、ドメインオブジェクトを抽出していき、結果としてこんな感じになりました。
上記のように予約に備品を紐づけるか、備品に予約をぶら下げるか考えましたけど、予約が中心でしょ!という感じで前者にしました。笑
実装
上記のドメインモデルを実装していきます。
レイヤー分割
アプリケーション層とドメイン層がインフラ層に依存しないように、依存関係を逆転させました。
各層の中身は以下のようになっています。内容を少しづつ紹介していこうと思います。
ドメイン層
- Entity、ValueObject、DomainService などのドメインオブジェクトが格納されます。
- 集約ルートごとに Repository のインターフェースが格納されます。
- Entity では基本的にプリミティブ型は使わず ValueObject を使うようにしています。
ValueObject のコンストラクタで値のチェックをしています。
// 利用時間の ValueObject
public class ReservationDateTime : IValueObject
{
public ReservationDateTime(DateTime start, DateTime end)
{
if (start.CompareTo(end) >= 0)
throw new ArgumentException("終了日時は開始日時よりも後にしてください。");
Start = start;
End = end;
}
public DateTime Start { get; }
public DateTime End { get; }
// 以下省略
}
// 利用目的 の ValueObject
public class PurposeOfUse : IValueObject
{
public PurposeOfUse(string value)
{
Assertion.ArgumentRange(value, 64, nameof(PurposeOfUse));
Value = value;
}
public string Value { get; }
// 以下省略
}
Entity は各フィールドのプロパティを用意し、private な Setter の中で値のチェックを行います。値を変更するときは SetXXX のような名称ではなく、実際のドメインで使われている名前を付けたメソッドを用意して呼び出せるようにしています。(英語のセンスがなくて微妙かもしれないが。)
public class Reservation : IEntity
{
public Reservation(
ReservationId id,
AccountId accountId,
EquipmentId equipmentId,
ReservationDateTime reservationDateTime,
PurposeOfUse purposeOfUse,
ReservationStatus reservationStatus)
{
Id = id;
AccountId = accountId;
EquipmentId = equipmentId;
ReservationDateTime = reservationDateTime;
ReservationStatus = ReservationStatus.Reserved;
PurposeOfUse = purposeOfUse;
ReservationStatus = reservationStatus;
}
private ReservationId _id;
public ReservationId Id
{
get { return _id; }
private set
{
Assertion.ArgumentNotNull(value, nameof(Id));
_id = value;
}
}
private ReservationDateTime _reservationDateTime;
public ReservationDateTime ReservationDateTime
{
get { return _reservationDateTime; }
private set
{
Assertion.ArgumentNotNull(value, nameof(ReservationDateTime));
_reservationDateTime = value;
}
}
private PurposeOfUse _purposeOfUse;
public PurposeOfUse PurposeOfUse
{
get { return _purposeOfUse; }
private set
{
Assertion.ArgumentNotNull(value, nameof(PurposeOfUse));
_purposeOfUse = value;
}
}
・
・
・
public void ChangeReservationDateTime(ReservationDateTime reservationDateTime)
{
ReservationDateTime = reservationDateTime;
}
public void ChangePurposeOfUse(PurposeOfUse purposeOfUse)
{
PurposeOfUse = purposeOfUse;
}
public bool IsDupulicated(Reservation other)
{
Assertion.ArgumentNotNull(other, nameof(other));
if (!EquipmentId.Equals(other.EquipmentId)) return false;
if (!ReservationDateTime.IsRangeOverlapping(other.ReservationDateTime)) return false;
return true;
}
// 以下省略
}
アプリケーション層
- アプリケーションサービスを格納します。
- CQRS の考え方を利用して、Command と Query のアプリケーションサービスを分けています。ただし、DB は共通としています。(Command は ApplicationService、Query は QueryService という名前にしている。)
- アプリケーションサービスでは Unit Of Work を利用したトランザクション管理を行っています。このレイヤーには、UnitOfWork のインターフェースのみ定義し、実装はインフラ層で行っています。
- QueryService では、直接 SQL 発行しておらず、インターフェース(IQuery)を定義して、SQL の発行はインフラ層で行っています。(QueryService を挟まずとも、IQueryService の実装をインフラ層で行っても構わないと思います。)
public class ReservationAppService : IReservationAppService
{
private readonly IUnitOfWork _unitOfWork;
public ReservationAppService(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
}
public void RegisterReservation(RegisterReservationRequest request)
{
_unitOfWork.Begin();
try
{
_unitOfWork.ReservationRepository.Lock();
var reservation = new Reservation(
new ReservationId(),
new AccountId(request.AccountId),
new EquipmentId(request.EquipmentId),
new ReservationDateTime(request.StartDateTime, request.EndDateTime),
new PurposeOfUse(request.PurposeOfUse),
ReservationStatus.Reserved);
SaveReservation(reservation);
_unitOfWork.Commit();
}
catch
{
_unitOfWork.Rollback();
throw;
}
}
public void ChangeReservationInfo(ChangeReservationInfoRequest request)
{
_unitOfWork.Begin();
try
{
_unitOfWork.ReservationRepository.Lock();
var reservation = FindReservationWithValidation(request.ReservationId);
reservation.ChangeAccountOfUse(new AccountId(request.AccountId));
reservation.ChangeEquipment(new EquipmentId(request.EquipmentId));
reservation.ChangeReservationDateTime(new ReservationDateTime(request.StartDateTime, request.EndDateTime));
reservation.ChangePurposeOfUse(new PurposeOfUse(request.PurposeOfUse));
SaveReservation(reservation);
_unitOfWork.Commit();
}
catch
{
_unitOfWork.Rollback();
throw;
}
}
public void CancelReservation(CancelReservationRequest request)
{
_unitOfWork.Begin();
try
{
var reservation = FindReservationWithValidation(request.ReservationId);
reservation.Cancel();
_unitOfWork.ReservationRepository.Save(reservation);
_unitOfWork.Commit();
}
catch
{
_unitOfWork.Rollback();
throw;
}
}
private Reservation FindReservationWithValidation(string reservationId)
{
var reservation = _unitOfWork.ReservationRepository.Find(new ReservationId(reservationId));
if (reservation == null)
{
throw new ReservationNotFoundException();
}
return reservation;
}
private void SaveReservation(Reservation reservation)
{
var service = new ReservationService(_unitOfWork.ReservationRepository);
if (service.IsDupulicatedReservation(reservation))
{
throw new ReservationDupulicationException();
}
_unitOfWork.ReservationRepository.Save(reservation);
}
}
予約リスト画面や予約詳細画面には、予約のアカウント名や備品名を表示したいが、Entity を利用としようとすると、予約Entity、アカウントEntity、備品Entity のデータを取得しなければならず効率が悪いため、QueryService を利用して SQL で結合した結果を取得するようにします。(SQL の実装自体はインフラ層で行う。)
public class ReservationQueryService : IReservationQueryService
{
private readonly IQueryFactory _queryFactory;
public ReservationQueryService(IQueryFactory queryFactory)
{
_queryFactory = queryFactory ?? throw new ArgumentNullException(nameof(queryFactory));
}
public GetReservationDataResponse GetReservationData(GetReservationDataRequest request)
{
return new GetReservationDataResponse()
{
ReservationData = _queryFactory.ReservationDataQuery.FindReservationData(new ReservationId(request.ReservationId))
};
}
public GetAllReservationListDataResponse GetAllReservationListData()
{
return new GetAllReservationListDataResponse()
{
ReservationListDataList = _queryFactory.ReservationDataQuery.FindAllReservationListData()
};
}
}
インフラ層
- Repository、Query の実装、UnitOfWork の実装を格納しています。
- DB アクセスは Entity Framework、Dapper を利用しています。
public class ReservationRepository : IReservationRepository
{
private readonly MyDbContext _dbContext;
public ReservationRepository(MyDbContext dbContext)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
}
public Reservation Find(ReservationId reservationId, ReservationStatus? reservationStatus = null)
{
IQueryable<RESERVATIONS> reservations = _dbContext.Reservations.Include(_ => _.reservations_status);
if (reservationStatus != null)
{
reservations = reservations.Where(_ => _.reservations_status.status == (int)reservationStatus.Value);
}
var reservation = reservations.SingleOrDefault(_ => _.id == reservationId.Value);
return Create(reservation);
}
public IEnumerable<Reservation> FindByEquipmentId(EquipmentId equipmentId, ReservationStatus? reservationStatus = null)
{
var reservations = _dbContext.Reservations.Include(_ => _.reservations_status).
Where(_ => _.equipments_id == equipmentId.Value);
if (reservationStatus != null)
{
reservations = reservations.Where(_ => _.reservations_status.status == (int)reservationStatus.Value);
}
return reservations.Select(_ => Create(_)).ToArray();
}
public void Save(Reservation entity)
{
var reservation = _dbContext.Reservations.Find(entity.Id.Value);
if (reservation == null)
{
reservation = new RESERVATIONS();
_dbContext.Reservations.Add(reservation);
}
reservation.id = entity.Id.Value;
reservation.accounts_id = entity.AccountId.Value;
reservation.equipments_id = entity.EquipmentId.Value;
reservation.start_date_time = entity.ReservationDateTime.Start;
reservation.end_date_time = entity.ReservationDateTime.End;
reservation.purpose_of_use = entity.PurposeOfUse.Value;
var reservationStatus = _dbContext.ReservationsStatus.Find(reservation.id);
if (reservationStatus == null)
{
reservationStatus = new RESERVATIONS_STATUS();
_dbContext.ReservationsStatus.Add(reservationStatus);
}
reservationStatus.reservations_id = reservation.id;
reservationStatus.status = (int)entity.ReservationStatus;
_dbContext.SaveChanges();
}
public void Lock()
{
_dbContext.QueryObjects<RESERVATIONS>("select * from reservations for update;");
}
private Reservation Create(RESERVATIONS reservation)
{
if (reservation == null)
return null;
return new Reservation(
new ReservationId(reservation.id),
new AccountId(reservation.accounts_id),
new EquipmentId(reservation.equipments_id),
new ReservationDateTime(reservation.start_date_time, reservation.end_date_time),
new PurposeOfUse(reservation.purpose_of_use),
(ReservationStatus)reservation.reservations_status.status);
}
}
public class ReservationDataQuery : IReservationDataQuery
{
private readonly MyDbContext _dbContext;
public ReservationDataQuery(MyDbContext dbContext)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
}
public ReservationData FindReservationData(ReservationId reservationId)
{
var reservation = _dbContext.Reservations.Find(reservationId.Value);
return CreateReservationData(reservation);
}
public IEnumerable<ReservationListData> FindAllReservationListData()
{
return _dbContext.Reservations.
Include(_ => _.accounts).
Include(_ => _.equipments).
Include(_ => _.reservations_status).
Where(_ => _.reservations_status.status == (int)ReservationStatus.Reserved).
Select(_ => CreateReservationListData(_)).ToArray();
}
public ReservationListData FindReservationListData(ReservationId reservationId)
{
var reservation = _dbContext.Reservations.
Include(_ => _.accounts).
Include(_ => _.equipments).
Where(_ => _.id == reservationId.Value).
SingleOrDefault();
return CreateReservationListData(reservation);
}
private ReservationData CreateReservationData(RESERVATIONS reservation)
{
if (reservation == null)
return null;
return new ReservationData()
{
Id = reservation.id,
AccountId = reservation.accounts.id,
EquipmentId = reservation.equipments_id,
StartDateTime = reservation.start_date_time,
EndDateTime = reservation.end_date_time,
PurposeOfUse = reservation.purpose_of_use,
};
}
private ReservationListData CreateReservationListData(RESERVATIONS reservation)
{
if (reservation == null)
return null;
return new ReservationListData()
{
Id = reservation.id,
AccountId = reservation.accounts.id,
EquipmentId = reservation.equipments_id,
StartDateTime = reservation.start_date_time,
EndDateTime = reservation.end_date_time,
PurposeOfUse = reservation.purpose_of_use,
AccountName = reservation.accounts.account_name,
EquipmentType = reservation.equipments.equipment_type,
EquipmentName = reservation.equipments.equipment_name
};
}
}
public class UnitOfWork : IUnitOfWork
{
private readonly MyDbContext _dbContext;
public UnitOfWork(MyDbContext dbContext)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
}
private IReservationRepository _reservationRepository;
public IReservationRepository ReservationRepository
{
get
{
if (_reservationRepository == null) _reservationRepository = new ReservationRepository(_dbContext);
return _reservationRepository;
}
}
・
・
・
public void Begin()
{
_dbContext.Database.BeginTransaction();
}
public void Commit()
{
_dbContext.Database.CommitTransaction();
}
public void Rollback()
{
_dbContext.Database.RollbackTransaction();
}
private bool disposed = false;
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (disposed)
return;
if (disposing)
{
_dbContext.Dispose();
}
disposed = true;
}
}
ビュー層
- ASP .NET Core MVC の Web アプリケーションの入り口。
- View、Controller を格納する。
- DI(依存性の注入)を行う。
実装は、画面から入力されたデータを、ApplicationService、QueryService のメソッド引数の型に変換して、サービスのメソッドを呼び出す感じです。(投げやり)
まとめ
ということで、最後の方はだいぶ駆け足になりましたが、自分としてはなんとなく各レイヤーでどんな実装をすればよいか感触をつかめました。
モデリングがまだまだだなので、もう少し複雑なドメインを例にまたやってみようと思います。
あとは、Java、Spring を使ったサンプルも作っていきたいと思います。
ソースコードは以下に公開しています。
https://github.com/TakashiOnawa/EquipmentReservation