はじめに
今回はDDD(ドメイン駆動設計)に基づいた考え方を理解するため、例として麻雀の点数計算アプリを設計・実装した内容をまとめます。
前提と簡単なルール
-
人数:今回は4人麻雀前提
-
目的:自分の手牌13枚に、他家(ターチャ:対戦相手)が捨てた牌(捨牌:シャハイ)や自分でツモってきた牌を加えて、特定の形(アガリ形)を作ることです
以下麻雀用語がDDDの「ユビキタス言語」(プロジェクト固有の共通用語)に当たるもの
-
牌(パイ):全部で136枚の牌を使います
-
数牌(シューパイ):萬子(マンズ)、筒子(ピンズ)、索子(ソーズ)の3種類があり、それぞれ1から9までの数字が書かれた牌が4枚ずつあります
-
字牌(ジハイ):東、南、西、北の風牌(カゼハイ)と、白、發、中の三元牌(サンゲンパイ)があり、それぞれ4枚ずつあります
-
-
アガリ形:基本的なアガリ形は「面子(メンツ)」と呼ばれる3枚1組の組み合わせを4つと、同じ牌2枚の「雀頭(ジャントウ)」の合計5つの組み合わせです
-
順子(シュンツ):同じ種類の数牌で数字が連続した3枚組(例:萬子の1・2・3)
-
刻子(コーツ):同じ牌3枚組(例:筒子の5が3枚)
-
槓子(カンツ):同じ牌4枚組(暗槓と明槓があります)
-
-
ツモとロン:
-
ツモ:自分で引いてきた牌でアガる
-
ロン:他家が捨てた牌でアガる
-
-
役(ヤク):アガるためには、原則として1つ以上の「役」が必要です。役には様々な種類があり、それぞれ翻数が決まっています
点数の基本
アガった時の手牌の構成や役の種類、符数、翻数などによって点数が計算されます。下記の早見表はその一部を示したものです。
翻数\符数 | 子のロン(直撃) | 親のロン(直撃) | 子のツモ | 親のツモ |
---|---|---|---|---|
1翻30符 | 1000点 | 1500点 | 300/500点 | 500点オール |
2翻40符 | 2600点 | 3900点 | 700/1300点 | 1300点オール |
3翻40符 | 5200点 | 7700点 | 1300/2600点 | 2600点オール |
満貫(5翻) | 8000点 | 12000点 | 2000/4000点 | 4000点オール |
跳満(6-7翻) | 12000点 | 18000点 | 3000/6000点 | 6000点オール |
倍満(8-10翻) | 16000点 | 24000点 | 4000/8000点 | 8000点オール |
三倍満(11-12翻) | 24000点 | 36000点 | 6000/12000点 | 12000点オール |
役満(13翻以上) | 32000点 | 48000点 | 8000/16000点 | 16000点オール |
-
子のツモ:子(親以外のプレイヤー)がツモ(自摸:自分で引いた牌でアガリ)
-
親のツモ:親がツモでアガった場合は全員から同じ点数を取る
-
符(フ):役の構成や待ちの形などによって計算される点数の単位
-
翻(ハン):役の種類によって決まる点数を増やす単位。翻数が多くなるほど高得点になる
-
満貫(マンガン):4翻以上の場合の点数の上限
-
跳満(ハネマン):満貫より高い点数
-
倍満(バイマン):跳満よりさらに高い点数
-
三倍満(サンバイマン):倍満よりさらに高い点数
-
役満(ヤクマン):最も高い点数となる特別な役
ドメイン
-
手牌や上がり形(和了)の情報
-
役、符、点数の計算
-
親/子の区別
-
場状況(親・風・場風など)
エンティティの洗い出し
エンティティ(識別子を持ち、状態を持つ)
エンティティ名 | 説明 | 主な属性 |
---|---|---|
Player(プレイヤー) | 点数計算の対象 | id, name, seatWind, hand |
Game(対局) | 1局または1半荘のゲーム状態 | id, round, players, dora, wall |
RoundResult(局の結果) | 点数を計算する単位 | id, winningPlayerId, hand, fu, han, score, yakus |
値オブジェクトの洗い出し
値オブジェクト(識別子なし、属性の組み合わせで意味を持つ)
値オブジェクト名 | 説明 |
---|---|
Tile(牌) | 萬子・筒子・索子・字牌など |
Hand(手牌) | 手牌全体(面子・雀頭・待ちなど含む) |
Yaku(役) | 役満、一翻役など。役の種別と翻数を含む |
Score(点数) | 得点(符計算と翻数による) |
Fu(符) | 面子、待ち形、門前かどうかなどから計算 |
Han(翻) | 役の合計 |
Wind(風) | 東南西北(プレイヤーごとの風、場風) |
AgariPattern(和了形) | ロン/ツモ、待ち形など |
集約とそのルート
集約候補とルートエンティティ
集約名 | ルートエンティティ | 説明 |
---|---|---|
PlayerAggregate | Player | プレイヤーの状態(持ち点・座席など)を中心にまとめる |
GameAggregate | Game | 複数プレイヤー、局、ドラなどの管理 |
RoundAggregate | RoundResult | 1局の結果を計算する際の単位。点数計算のロジックの中心 |
リポジトリ
集約ごとのリポジトリ
リポジトリ名 | 対象 | 操作例 |
---|---|---|
GameRepository | GameAggregate | 対局の保存、取得 |
PlayerRepository | PlayerAggregate | プレイヤー情報の保存、取得 |
RoundRepository | RoundAggregate | 局結果の保存、履歴取得など |
ユースケース層(アプリケーションサービス)
ユースケース名 | 概要 | 入出力 |
---|---|---|
CalculateScoreService | 和了情報から点数を計算 | 入:手牌、場情報 出:点数・役情報 |
RegisterWinService | 和了結果を登録してゲーム状態を更新 | 入:誰がどう和了したか 出:更新後のスコア |
StartNewRoundService | 次局を開始する処理 | 入:前局の結果 出:新しい局情報 |
ViewGameStatusService | 現在の点棒状況、場情報を取得 | 入:ゲームID 出:ステータス情報 |
全体構造図
[アプリケーション層:ユースケース]
|
v
+-------------------------------+
| UseCase: 点数計算 |
| - GameRepository |
| - PlayerRepository |
| - RoundRepository |
+-------------------------------+
|
v
+------------------+ +------------------+ +---------------------+
| GameAggregate | | PlayerAggregate | | RoundAggregate |
| [Game] | | [Player] | | [RoundResult] |
| - id | | - id | | - id |
| - round | | - name | | - winningPlayerId |
| - players[] | | - seatWind | | - hand |
| - dora[] | | - hand | | - fu, han, score |
| - wall[] | | | | - yakus[] |
+------------------+ +------------------+ +---------------------+
| | |
v v v
+-------------------+ +----------------------+ +-----------------------------+
| 値オブジェクト群 | | 値オブジェクト群 | | 値オブジェクト群 |
| - Tile | | - Wind | | - Fu, Han, Score |
| - Hand | | | | - Yaku[], AgariPattern |
+-------------------+ +----------------------+ +-----------------------------+
エンティティと集約
-
GameAggregate:ゲーム全体(1局または半荘)をまとめる。複数プレイヤー、局、ドラ、山などを含む
-
PlayerAggregate:プレイヤー1人を単位に、その手牌や風などの状態を保持
-
RoundAggregate:1局分の結果計算に使う情報を中心に構成。得点計算の主ロジックはここに集中
値オブジェクト
-
Tile:個々の牌の種類を表す
-
Hand:手牌全体。雀頭や面子を含む
-
Yaku:役情報(翻数込み)
-
Score, Fu, Han:点数計算に必要な要素
-
Wind:場風・自風などの風
-
AgariPattern:和了パターン(ロン/ツモ・待ち形など)
リポジトリ
-
各Aggregateのデータを取得・保存するインターフェース
-
永続化(DBなど)や外部との接続はここが担う
ユースケース
-
点数計算、局の進行、プレイヤー管理などを実行するアプリケーションサービス層
-
外部UIやAPIから呼び出される入口
ディレクトリ構成
/mahjong-app/
├── domain/
│ ├── score.go
│ ├── fu.go
│ ├── han.go
│ ├── yaku.go
│ ├── event.go ← ドメインイベント定義
│ ├── event_dispatcher.go ← イベントディスパッチャインターフェース
│ └── service/
│ └── score_calculator.go
│
├── application/
│ └── usecase/
│ └── calculate_score.go ← UseCase 内でイベントを発行
│
├── infrastructure/
│ └── event/
│ └── simple_dispatcher.go ← イベントディスパッチャの実装
│
├── interface/
│ └── handler/
│ └── score_handler.go ← HTTP ハンドラ
│
└── main.go ← イベントハンドラ登録と起動処理
domain
score.go
// domain/score.go
package domain
type Score struct {
Points int
}
func NewScore(points int) *Score {
return &Score{Points: points}
}
fu.go
package domain
type Fu struct {
Value int
}
func NewFu(value int) *Fu {
return &Fu{Value: value}
}
han.go
package domain
type Han struct {
Value int
}
func NewHan(value int) *Han {
return &Han{Value: value}
}
yaku.go
package domain
type Yaku struct {
Name string
Value int // 翻数
}
func NewYaku(name string, value int) *Yaku {
return &Yaku{Name: name, Value: value}
}
domain event
event.go
package domain
type DomainEvent interface {
EventName() string
}
type ScoreCalculatedEvent struct {
Points int
}
func (e *ScoreCalculatedEvent) EventName() string {
return "ScoreCalculated"
}
イベントディスパッチャ定義(event_dispatcher.go)
package domain
type EventHandler func(DomainEvent)
type EventDispatcher interface {
Register(eventName string, handler EventHandler)
Dispatch(event DomainEvent)
}
ディスパッチャの実装(infrastructure/event/simple_dispatcher.go)
package event
import (
"mahjong-app/domain"
"sync"
)
type SimpleEventDispatcher struct {
handlers map[string][]domain.EventHandler
mu sync.Mutex
}
func NewSimpleEventDispatcher() *SimpleEventDispatcher {
return &SimpleEventDispatcher{
handlers: make(map[string][]domain.EventHandler),
}
}
func (d *SimpleEventDispatcher) Register(eventName string, handler domain.EventHandler) {
d.mu.Lock()
defer d.mu.Unlock()
d.handlers[eventName] = append(d.handlers[eventName], handler)
}
func (d *SimpleEventDispatcher) Dispatch(event domain.DomainEvent) {
d.mu.Lock()
handlers := d.handlers[event.EventName()]
d.mu.Unlock()
for _, h := range handlers {
go func(handler domain.EventHandler) {
defer func() {
if r := recover(); r != nil {
// ログなどのエラーハンドリング
}
}()
handler(event)
}(h) // 非同期処理
}
}
domain service
score_calculator.go
package service
import (
"mahjong-app/domain"
"math"
)
func CalculateScore(fu *domain.Fu, han *domain.Han) *domain.Score {
basePoints := int(float64(fu.Value) * math.Pow(2, float64(han.Value+2)))
return domain.NewScore(basePoints)
}
application
calculate_score.go
package usecase
import "mahjong-app/domain/service"
import "mahjong-app/domain"
type CalculateScoreInput struct {
Fu int
Han int
}
type CalculateScoreOutput struct {
Points int
}
type ScoreCalculatorUseCase struct {
Dispatcher domain.EventDispatcher
}
func (uc *ScoreCalculatorUseCase) Execute(input *CalculateScoreInput) (*CalculateScoreOutput, error) {
fu := domain.NewFu(input.Fu)
han := domain.NewHan(input.Han)
score := service.CalculateScore(fu, han)
// ドメインイベントを発行
event := &domain.ScoreCalculatedEvent{Points: score.Points}
uc.Dispatcher.Dispatch(event)
return &CalculateScoreOutput{Points: score.Points}, nil
}
interface
score_handler.go
package handler
import (
"net/http"
"mahjong-app/application/usecase"
"[github.com/labstack/echo/v4](https://github.com/labstack/echo/v4)"
)
type ScoreHandler struct {
UseCase *usecase.ScoreCalculatorUseCase
}
func NewScoreHandler(uc *usecase.ScoreCalculatorUseCase) *ScoreHandler {
return &ScoreHandler{UseCase: uc}
}
type Request struct {
Fu int `json:"fu"`
Han int `json:"han"`
}
type Response struct {
Points int `json:"points"`
}
func (h *ScoreHandler) CalculateScore(c echo.Context) error {
var req Request
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, err)
}
output, err := h.UseCase.Execute(&usecase.CalculateScoreInput{
Fu: req.Fu,
Han: req.Han,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, err)
}
return c.JSON(http.StatusOK, Response{Points: output.Points})
}
infrastructure
simple_dispatcher.go
package event
import (
"mahjong-app/domain"
"sync"
)
type SimpleEventDispatcher struct {
handlers map[string][]domain.EventHandler
mu sync.Mutex
}
func NewSimpleEventDispatcher() *SimpleEventDispatcher {
return &SimpleEventDispatcher{
handlers: make(map[string][]domain.EventHandler),
}
}
func (d *SimpleEventDispatcher) Register(eventName string, handler domain.EventHandler) {
d.mu.Lock()
defer d.mu.Unlock()
d.handlers[eventName] = append(d.handlers[eventName], handler)
}
func (d *SimpleEventDispatcher) Dispatch(event domain.DomainEvent) {
d.mu.Lock()
handlers := d.handlers[event.EventName()]
d.mu.Unlock()
for _, h := range handlers {
go func(handler domain.EventHandler) {
defer func() {
if r := recover(); r != nil {
// ログなどのエラーハンドリング
}
}()
handler(event)
}(h)
}
}
main.go
package main
import (
"fmt"
"mahjong-app/application/usecase"
"mahjong-app/domain"
"mahjong-app/infrastructure/event"
"mahjong-app/interface/handler"
"[github.com/labstack/echo/v4](https://github.com/labstack/echo/v4)"
)
func main() {
e := echo.New()
dispatcher := event.NewSimpleEventDispatcher()
dispatcher.Register("ScoreCalculated", func(ev domain.DomainEvent) {
if evt, ok := ev.(*domain.ScoreCalculatedEvent); ok {
fmt.Println("Score was calculated:", evt.Points)
// ここにログ保存や外部通知などの副作用を書く
}
})
uc := &usecase.ScoreCalculatorUseCase{Dispatcher: dispatcher}
h := handler.NewScoreHandler(uc)
e.POST("/calculate", h.CalculateScore)
e.Logger.Fatal(e.Start(":8080"))
}
-
domain/:ドメインモデルと値オブジェクト、計算ロジック(ドメインサービス)
-
application/:ユースケース(アプリケーションサービス)
-
interface/:Web API ハンドラー(JSONで fu と han を受け取る)
-
Webフロントエンド(Reactなど)とは HTTP (JSON) で接続(今回はGoのみで作成)
設計をやってみた感想
自分の興味のある分野に当てはめて考えることでDDDの良さや効果的なポイントを実際の設計を通して体感することができました。
また、業務では部署ごとに用語の使い方や呼び名が違うこともありますが、ユビキタス言語を取り入れることで認識のズレをなくし共通理解を持てるようになる点は非常に有効だと感じました。