実践ドメインモデリング!Modeling Forum 2025
こんにちは!ハコベル開発チームの古賀です。
「Hacobell Developers Advent Calendar」12日目の記事を担当します。
先日、Modeling Forum 2025 で開催されたドメインモデリングワークショップに参加してきました。
ドメイン駆動設計のモデリングをいちから体感する内容だったのですが、非常に学びが多かったのでその内容を紹介します。
ワークショップの概要
ドメイン駆動設計の基本コンセプトの一つである「モデル駆動設計」の考え方とやり方を手を動かしながら体験的に学ぶ内容です。講師は増田亨氏、佐藤治夫氏でした。
調整さん風アプリケーションを題材に、チームで議論をしながらイチからドメインモデルを実装していきました。この記事では、追加要件1までを実装した流れを紹介します。

モデルとは何か?
まず、ドメイン駆動設計において「モデルとは何か?」ということからディスカッションを行ないました。講師やサポーターの方からの助言を踏まえて、次のようなことがポイントだということでまとまりました。
- モデル = 現実のモノや出来事を簡略化したもの
- モデル ≠ 現実のモノや出来事をそのまま表現したもの
- 問題解決に必要な部分を抽象化の対象として、それ以外は無視する。
- 地形図・世界地図・地下鉄の路線図など、地図ひとつとっても様々なモデルがあるが、どれも課題解決をサポートするための情報だけを含んでいる。
モデルに関して以下の書籍でも言及されています。
エリック・エヴァンスのドメイン駆動設計(P1~3)
ドメイン駆動設計をはじめよう(2.6.1 モデルとは何か)
仕様と登場人物の整理
それでは、最初のモデルを作成していきます。
初期のモデルの表現方法は任意(自然言語、図、表、テスト駆動、コード駆動など)という事で、今回は図で表現することを選択しました。
日程調整という課題を解決するのに必要な要素を洗い出し、関係性を整理しました。

-
ProposedDate: 候補日 -
MemberId: 回答者を識別するID。名前を採用。 -
Attendance: 出欠(参加または不参加) -
AttendanceCell: 出欠表の各マスを表す。ProposedDate,MemberId,Attendanceのセットを持つ。 -
AttendanceTable: 日程調整全体を表す。AttendanceCellのコレクションを持つ。
要件1: 候補日決定ルールを実装する
モデルの実装を行なっていきます。言語は Go を使用します。
まず、先ほどの登場人物たちの定義を実装します。
package main
import (
"time"
)
type AttendanceTable struct {
cells []AttendanceCell
}
type AttendanceCell struct {
date ProposedDate
memberId MemberId
attendance Attendance
}
type ProposedDate struct {
value time.Time
}
type MemberId struct {
string
}
type Attendance struct {
value bool
}
核となる候補日決定ロジックとして Decide() を実装します。
func (a AttendanceTable) Decide() ProposedDate {
dateMap := map[ProposedDate]int{}
for _, cell := range a.cells {
if cell.Attend() {
dateMap[cell.date]++
}
}
var mostParticipantsDate ProposedDate
maxCount := -1
for date, count := range dateMap {
if count > maxCount {
maxCount = count
mostParticipantsDate = date
}
}
return mostParticipantsDate
}
func (ac AttendanceCell) Attend() bool {
return ac.attendance.Value()
}
func (a Attendance) Value() bool {
return a.value
}
Decide() は出欠表の全セルをループで回して dateMap に日付と参加者数を持たせ、dateMap の中から参加者数が最大となる日付を抽出しています。
この処理は1つのメソッドに集約されており抽象化が不足しているため、最多参加者数の候補日を決定するロジックであることが一見して分かりにくい状態です。
これはモデルの抽象化がうまくできていない兆候ですが、これくらいの行数なら許容するという意見もあったため、ひとまずこのまま次の要件に進みます。(後ほど修正されます)
要件2: 必須参加者が揃わなければ仕切り直す
次に、「必須参加者が全員参加可能な日程がなければ再調整する」という要件を実装します。
必須参加者の情報は requiredMemberIds として AttendanceTable に持たせます。
type AttendanceTable struct {
attendanceCells []AttendanceCell
requiredMemberIds MemberIds // 追加
}
type MemberIds struct {
ids []MemberId
}
先ほど作成した Decide() に手を入れようとすると次のような修正が必要になり、このメソッドだけで全てを記述すると AttendanceTable の責務が大きくなり過ぎてしまいます。
func (a AttendanceTable) Decide() (ProposedDate, bool) {
// ①
// TODO: 必須参加者が全員参加可能な日程があるかをチェックするロジック
// a.attendanceCells を行単位(attendanceRow)に変換したコレクション(attendanceRows)を作成する
// attendanceRows の中から requiredMemberIds が含まれる行のみをフィルタリングした attendanceRows を作成する
// 1行でも見つかれば最多参加者を決めるロジックに移る
// 見つからなければ false を返す
// ②
// 最多参加者の日程を決めるロジック
// TODO: ①の結果である attendanceRows 内の attendanceRow に対して、参加者数をカウントして最も多い行の日程を返すように修正する
}
モデルの再考💡
ここで、上記①・②のロジックを言語化したことによってモデルに新たな気づきが得られました。
TODO で書いた内容を見てみると、①②ともに出欠表の行(attendanceRow)や行コレクション(attendanceRows)という概念が出てきており、それらに処理を委譲するのが良さそうです。
つまり、出欠表(attendanceTable)がセル(attendanceCell)を扱って直接全てを計算するのではなく、出欠表は行コレクションを保持しそれに対してフィルタリングの指示を出し、フィルタリング処理の詳細は行コレクションに任せる、という形にすると役割分担・カプセル化が進みます。
われわれ人間が出欠表を見る時も同じように、行単位で比較して参加者数が最多の行を判別したり、必須参加者が参加可能な日付かどうかを判別していることに気づきました。


上記の発見を踏まえて、行の概念をモデルに反映したものがこちらになります。

このモデルをコードに反映していきます。
変更後のモデルを反映💪
まずは登場人物を追加します。
type AttendanceTable struct {
rows AttendanceRows
requiredMemberIds MemberIds
}
type AttendanceRows struct {
rows []AttendanceRow
}
type AttendanceRow struct {
date ProposedDate
answers Answers
}
type Answers struct {
answers []Answer
}
type Answer struct {
memberId MemberId
attendance Attendance
}
次に、問題となっていた Decide() に手を入れます。
やりたいことを改めて言語化すると、
1. 出欠表の各行のうち必須参加者が全員参加できる行に絞り込む
2. 絞り込みの結果がなければ、仕切り直し
3. 絞り込みの結果があれば、最多参加者の行に更に絞り込んでその日付を返す
となります。これをコードで実装します。
func (a AttendanceTable) Decide() (ProposedDate, bool) {
// 1. 出欠表の各行のうち必須参加者が全員参加できる行に絞り込む
rows := a.rows.FilterRequiredMembersCanAttend(a.requiredMemberIds)
// 2. 絞り込みの結果がなければ、仕切り直し
if rows.IsEmpty() {
return ProposedDate{}, false
}
// 3. 絞り込みの結果があれば、最多参加者の行に更に絞り込んでその日付を返す
return rows.MostParticipantsDate(), true
}
AttendanceRows にフィルタリングを指示するようにしたことで、言語化したことをそのままコードで表現できるようになりました。
フィルタリング処理の FilterRequiredMembersCanAttend() と MostParticipantsDate() を以降でそれぞれ実装していきます。
最多参加者数の候補日を決めるロジックの実装
まず MostParticipantsDate() を実装します。
func (ars AttendanceRows) MostParticipantsDate() ProposedDate {
// 出欠表の行がなければ空の日付を返す
if ars.IsEmpty() {
return ProposedDate{}
}
// 出欠表の各行の参加人数を比較し、最大となる行の日付を返す
return slices.MaxFunc(ars.rows, func(rowA, rowB AttendanceRow) int {
return cmp.Compare(rowA.AttendCount(), rowB.AttendCount())
}).date
}
AttendCount() は1行内の回答に含まれる参加人数を返すメソッドです。
行内のデータを集計するのは行の役割のため、AttendanceRow に処理を委譲します。
func (ar AttendanceRow) AttendCount() (count int) {
for _, answer := range ar.answers.answers {
count += answer.AttendCount()
}
return
}
func (a Answer) AttendCount() int {
if a.attendance.value {
return 1
}
return 0
}
必須参加者が全員参加可能な日程を返すロジックの実装
次に FilterRequiredMembersCanAttend() を実装します。
func (ars AttendanceRows) FilterRequiredMembersCanAttend(ids MemberIds) (result AttendanceRows) {
for _, row := range ars.rows {
if row.RequiredMembersCanAttend(ids) {
result = result.Add(row)
}
}
return
}
func (ars AttendanceRows) Add(row AttendanceRow) AttendanceRows {
return AttendanceRows{rows: append(slices.Clone(ars.rows), row)}
}
RequiredMembersCanAttend() は1行内の回答を見て必須参加者が全員参加可能かどうかを判定する処理で、AttendanceRow が処理を担います。
Add() は行コレクションに行を追加する操作です。
わざわざ Add() を定義せずとも、ビルトイン関数の append を使って次のように書くこともできます。
func (ars AttendanceRows) FilterRequiredMembersCanAttend(ids MemberIds) AttendanceRows {
var result []AttendanceRow
for _, row := range ars.rows {
if row.RequiredMembersCanAttend(ids) {
result = append(result, row)
}
}
return AttendanceRows{rows: result}
}
しかし、ここでは Add() を定義して行の追加操作を AttendanceRows 型のメソッドに集約するようにしています。
これは次のようなメリットがあります。
- 行の追加処理を簡潔に書ける
- コードを見て意図がより直感的に伝わるようになる
- 変更の影響範囲を閉じ込めることができる
このテクニックは「現場で役立つシステム設計の原則」に書かれているので、興味のある方は読んでみてください。
残りの RequiredMembersCanAttend() の実装をします。
func (ar AttendanceRow) RequiredMembersCanAttend(ids MemberIds) bool {
for _, memberId := range ids.ids {
if !ar.answers.RequiredMemberCanAttend(memberId) {
return false
}
}
return true
}
func (as Answers) RequiredMemberCanAttend(id MemberId) bool {
for _, answer := range as.answers {
if answer.IsSameMember(id) {
return answer.Attendance()
}
}
return false
}
func (a Answer) IsSameMember(id MemberId) bool {
return a.memberId == id
}
func (a Answer) Attendance() bool {
return a.attendance.value
}
これで要件2の実装が完了しました。
最後に、要件1,2を実装した全てのコードを記載します。
責務の委譲などリファクタリングの余地は残っていますが、参考になれば幸いです。
全コード
package main
import (
"cmp"
"slices"
"time"
)
type AttendanceTable struct {
rows AttendanceRows
requiredMemberIds MemberIds
}
func (a AttendanceTable) Decide() (ProposedDate, bool) {
// 1. 出欠表の各行のうち必須参加者が全員参加できる行に絞り込む
rows := a.rows.FilterRequiredMembersCanAttend(a.requiredMemberIds)
// 2. 絞り込みの結果がなければ、仕切り直し
if rows.IsEmpty() {
return ProposedDate{}, false
}
// 3. 絞り込みの結果があれば、最多参加者の行に更に絞り込んでその日付を返す
return rows.MostParticipantsDate(), true
}
type AttendanceRows struct {
rows []AttendanceRow
}
func (ars AttendanceRows) FilterRequiredMembersCanAttend(ids MemberIds) (result AttendanceRows) {
for _, row := range ars.rows {
if row.RequiredMembersCanAttend(ids) {
result = result.Add(row)
}
}
return
}
func (ars AttendanceRows) Add(row AttendanceRow) AttendanceRows {
return AttendanceRows{rows: append(slices.Clone(ars.rows), row)}
}
func (ars AttendanceRows) IsEmpty() bool {
return len(ars.rows) == 0
}
func (ars AttendanceRows) MostParticipantsDate() ProposedDate {
// 出欠表の行がなければ空の日付を返す
if ars.IsEmpty() {
return ProposedDate{}
}
// 出欠表の各行の参加人数を比較し、最大となる行の日付を返す
return slices.MaxFunc(ars.rows, func(rowA, rowB AttendanceRow) int {
return cmp.Compare(rowA.AttendCount(), rowB.AttendCount())
}).date
}
type AttendanceRow struct {
date ProposedDate
answers Answers
}
func (ar AttendanceRow) RequiredMembersCanAttend(ids MemberIds) bool {
for _, memberId := range ids.ids {
if !ar.answers.RequiredMemberCanAttend(memberId) {
return false
}
}
return true
}
func (ar AttendanceRow) AttendCount() (count int) {
for _, answer := range ar.answers.answers {
count += answer.AttendCount()
}
return
}
type Answers struct {
answers []Answer
}
func (as Answers) RequiredMemberCanAttend(id MemberId) bool {
for _, answer := range as.answers {
if answer.IsSameMember(id) {
return answer.Attendance()
}
}
return false
}
type Answer struct {
memberId MemberId
attendance Attendance
}
func (a Answer) Attendance() bool {
return a.attendance.value
}
func (a Answer) AttendCount() int {
if a.attendance.value {
return 1
}
return 0
}
func (a Answer) IsSameMember(id MemberId) bool {
return a.memberId == id
}
type ProposedDate struct {
value time.Time
}
type MemberIds struct {
ids []MemberId
}
type MemberId struct {
string
}
type Attendance struct {
value bool
}
func (a Attendance) Value() bool {
return a.value
}
終わりに
ワークショップを通じてモデリングを体感し、モデルに必要な要素は何かを考え、いかにしてモデルをコードで表現するか試行錯誤する良い機会となりました。
今回紹介できなかった追加要件2・3について、自分のチームメンバーと一緒にモデリングしてみると新たな発見があるかもしれません!
「物流の次を発明する」をミッションに物流のシェアリングプラットフォームを運営する、ハコベル株式会社 開発チームのテックブログです! 【エンジニア積極採用中】t.hacobell.com//blog/engineer-entrancebook
Discussion