2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DDDに基づいた考えでアプリ設計をする

Posted at

はじめに

今回は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

// domain/fu.go
package domain

type Fu struct {
	Value int
}

func NewFu(value int) *Fu {
	return &Fu{Value: value}
}

han.go

// domain/han.go
package domain

type Han struct {
	Value int
}

func NewHan(value int) *Han {
	return &Han{Value: value}
}

yaku.go

// domain/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

// event.go
package domain

type DomainEvent interface {
	EventName() string
}

type ScoreCalculatedEvent struct {
	Points int
}

func (e *ScoreCalculatedEvent) EventName() string {
	return "ScoreCalculated"
}

イベントディスパッチャ定義(event_dispatcher.go)

// 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)

// 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

// 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

// 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

// 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

// 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

// 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の良さや効果的なポイントを実際の設計を通して体感することができました。
また、業務では部署ごとに用語の使い方や呼び名が違うこともありますが、ユビキタス言語を取り入れることで認識のズレをなくし共通理解を持てるようになる点は非常に有効だと感じました。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?