はじめに
ピースミール・テクノロジー 進地 です。
最近、Go言語とドメイン駆動設計(DDD)を並行して学習しています。DDDの書籍やネット上のサンプルコードは Java や C# で書かれていることが多く、クラス・継承・アノテーションといった道具立てを前提にしています。一方の Go には、そのどれもありません。写経しようにも、そもそも写す先の構文がない。ここで最初に手が止まりました。
この記事は、その「手が止まった地点」から、パッケージ構成という Go なりの答えにたどり着くまでの学習記録です。
想定読者
- Go を学習中で、DDD にも興味がある人
- DDD の概念は知っているが、Go でどう表現するか迷っている人
この記事のスコープ
戦術的DDDのうち、レイヤをどうパッケージ構成に落とすかに絞ります。値オブジェクトの設計やリポジトリの実装詳細は、次回以降に分けて書く予定です(連載の初回として、まず全体の骨格を固めるのが狙いです)。
本記事のサンプルコード一式は、こちらのリポジトリで公開しています。
なお、私自身が学習中ですので、「もっと良いやり方がある」「その理解は違う」という点があれば、ぜひコメントで教えてください。
GoでDDDをやろうとして最初に戸惑うこと
クラスも継承もない
DDD の入門書に出てくるサンプルは、だいたいこういう形をしています(Java 風の擬似コード)。
public class Application extends Entity {
private ApplicationId id;
private Status status;
public void approve(ApproverId approverId) { ... }
}
Entity という基底クラスを継承して、エンティティであることを表現する。よく見るパターンです。しかし Go には class も extends もないので、この形をそのまま持ち込むことはできません。
最初は「Go では DDD できないのでは?」と思いかけたのですが、学習を進めるうちに、これは道具の問題ではなく表現の問題だと分かってきました。DDD が求めているのは「ドメインの知識をコードの構造に反映させること」であって、クラス継承はそのための手段の一つにすぎません。Go には Go の表現手段があります。
- エンティティや値オブジェクト →
structとメソッド - 抽象への依存 →
interface - レイヤの分離 → パッケージ構成そのもの
このうち、今回の主役は3つ目です。
「フォルダ = パッケージ = 名前空間」という割り切り
Go のパッケージシステムは良くも悪くもシンプルで、1ディレクトリ = 1パッケージです。Java のように1ファイルに複数の公開クラスを詰め込んだり、名前空間を自由に切ったりはできません。
最初はこれを窮屈に感じたのですが、DDD と組み合わせると見え方が変わりました。ディレクトリ構成がそのままアーキテクチャの図になる、ということです。ls した瞬間にレイヤ構造が見える。これは制約ではなく、むしろ DDD 向きの性質なのでは、と思うようになりました。
さらに Go には「小文字始まりの識別子はパッケージ外から見えない」という言語仕様があります。つまりパッケージの境界が、そのままカプセル化の境界になります。クラスの private の代わりに、パッケージという単位で「外に見せるもの/見せないもの」を制御する。ここが腑に落ちてから、GoとDDDが噛み合い始めました。
まず結論、パッケージ構成の全体像
先に全体像を見せます。題材は「社内の申請・承認」という小さなドメインです(稟議のもっとシンプルなもの、と思ってください)。
.
├── domain
│ └── application # 「申請」集約(エンティティ・値オブジェクト・リポジトリのinterface)
│ ├── application.go
│ ├── status.go
│ └── repository.go
├── usecase # 「申請する」「承認する」などのユースケース
│ ├── submit_application.go
│ └── approve_application.go
├── infrastructure
│ └── persistence # リポジトリの実装(今回はインメモリ)
│ └── application_memory.go
└── presentation
└── handler # HTTPハンドラ(薄く保つ)
└── application_handler.go
各レイヤの役割はこの後で順に見ていきますが、この構成で一番大事なのは依存の向きです。
矢印はすべて domain に向かって流れます。domain は誰にも依存しない、一番内側の層です。逆に言うと、domain パッケージの import 文に usecase や infrastructure が現れたら、それは設計が崩れているサインです。
「infrastructure が domain に依存する」という矢印の向きに違和感を持った方がいるかもしれません。DB アクセスの実装がドメインに依存する? 普通は逆では? ここが Go で DDD をやるときの一番面白いところで、interface の置き場所が鍵になります。これは後述の「interface は「使う側」に置く」で詳しく書きます。
各レイヤの役割とGoでの書き方
ここからは各レイヤを、内側(domain)から外側に向かって順に見ていきます。コードは説明に必要な部分だけ抜粋しています。
ビジネスルールはすべて domain に置く
まずエンティティです。「申請」を struct で表現します。
// domain/application/application.go
package application
import "errors"
// ApplicationID は申請の識別子。
// ただの string だが、型を分けることで他のIDとの取り違えをコンパイルエラーにできる
type ApplicationID string
type ApplicantID string
type ApproverID string
var (
ErrNotSubmitted = errors.New("申請中の申請のみ承認できます")
ErrSelfApproval = errors.New("自分の申請を自分で承認することはできません")
)
type Application struct {
id ApplicationID
applicantID ApplicantID
title string
status Status
}
// NewApplication は「下書き」状態の申請を生成する
func NewApplication(id ApplicationID, applicantID ApplicantID, title string) *Application {
return &Application{
id: id,
applicantID: applicantID,
title: title,
status: StatusDraft,
}
}
// Submit は申請を提出する(下書き → 申請中)
func (a *Application) Submit() error {
if a.status != StatusDraft {
return errors.New("下書きの申請のみ提出できます")
}
a.status = StatusSubmitted
return nil
}
// Approve は申請を承認する。
// 「承認者は申請者と同一人物であってはならない」というビジネスルールはここに書く
func (a *Application) Approve(approverID ApproverID) error {
if a.status != StatusSubmitted {
return ErrNotSubmitted
}
if string(approverID) == string(a.applicantID) {
return ErrSelfApproval
}
a.status = StatusApproved
return nil
}
func (a *Application) ID() ApplicationID { return a.id }
func (a *Application) Status() Status { return a.status }
ポイントを3つ。
1. フィールドはすべて小文字(非公開)にする
id や status を小文字にしているのは意図的です。前半で書いたとおり、Go ではパッケージ境界がカプセル化の境界になります。フィールドを非公開にすることで、「状態を変更する唯一の手段は Submit() や Approve() などのメソッド」という状況を言語仕様レベルで強制できます。app.status = StatusApproved のような直接代入は、パッケージの外ではコンパイルエラーになります。
DDDでいう「ドメインの不変条件を守る」を、規約ではなくコンパイラにやらせているわけです。
2. ID をただの string にしない
type ApplicationID string
type ApplicantID string
たった2行ですが、これで ApplicationID を渡すべき場所に ApplicantID を渡すとコンパイルエラーになります。引数がぜんぶ string の関数で順番を間違える、というバグを型で潰せます。Go の型定義は軽量なので、DDDの「識別子も概念として扱う」という考え方をほぼコストゼロで実践できます。
3. ビジネスルールは domain のメソッドの中に書く
「自己承認の禁止」は Approve() の中にあります。このルールを usecase や handler に書いてしまうと、承認の入口が増えたとき(HTTP経由、バッチ経由、管理画面経由……)にルールの漏れが生まれます。ドメインのメソッドを通る限り必ずルールが効く、という構造にしておくのが狙いです。
状態は値オブジェクトとして別ファイルに切り出しています。
// domain/application/status.go
package application
type Status string
const (
StatusDraft Status = "draft" // 下書き
StatusSubmitted Status = "submitted" // 申請中
StatusApproved Status = "approved" // 承認済み
StatusRejected Status = "rejected" // 差戻し
)
(状態遷移をもっと厳密に守る設計は、次回の値オブジェクト編で掘り下げる予定です)
リポジトリの interface も domain に置く
domain パッケージには、もう一つ重要なものを置きます。リポジトリの interface です。実装ではなく、interface だけです。
// domain/application/repository.go
package application
type Repository interface {
Save(app *Application) error
FindByID(id ApplicationID) (*Application, error)
}
「DBアクセスのコードがドメイン層に!?」と思うかもしれませんが、ここにあるのは「申請は保存できて、IDで取り出せる」というドメインの要求だけです。どうやって保存するか(RDB か、インメモリか、ファイルか)は一切書かれていません。この意味は次の節ではっきりします。
usecase はドメインを組み立てるだけの薄い層
// usecase/approve_application.go
package usecase
import "github.com/shinchi-pmtech/ringi/domain/application"
type ApproveApplication struct {
repo application.Repository // interface に依存。実装は知らない
}
func NewApproveApplication(repo application.Repository) *ApproveApplication {
return &ApproveApplication{repo: repo}
}
func (u *ApproveApplication) Execute(id application.ApplicationID, approverID application.ApproverID) error {
app, err := u.repo.FindByID(id)
if err != nil {
return err
}
if err := app.Approve(approverID); err != nil {
return err
}
return u.repo.Save(app)
}
やっているのは「取り出す → ドメインのメソッドを呼ぶ → 保存する」だけです。if 文でビジネスルールを判定していないことに注目してください。自己承認チェックは app.Approve() の中で行われるので、usecase は結果を受け取るだけです。
usecase が薄いかどうかは、レイヤ分離がうまくいっているかのバロメーターだと感じています。usecase に if が増えてきたら、それはドメインに置くべきルールが漏れ出しているサインかもしれません。
infrastructure は interface を実装する側
// infrastructure/persistence/application_memory.go
package persistence
import (
"errors"
"sync"
"github.com/shinchi-pmtech/ringi/domain/application"
)
type ApplicationMemoryRepository struct {
mu sync.RWMutex
store map[application.ApplicationID]*application.Application
}
func NewApplicationMemoryRepository() *ApplicationMemoryRepository {
return &ApplicationMemoryRepository{
store: map[application.ApplicationID]*application.Application{},
}
}
func (r *ApplicationMemoryRepository) Save(app *application.Application) error {
r.mu.Lock()
defer r.mu.Unlock()
r.store[app.ID()] = app
return nil
}
func (r *ApplicationMemoryRepository) FindByID(id application.ApplicationID) (*application.Application, error) {
r.mu.RLock()
defer r.mu.RUnlock()
app, ok := r.store[id]
if !ok {
return nil, errors.New("application not found")
}
return app, nil
}
今回はインメモリ実装です(DBを出すと記事がセットアップ説明で埋まるので、リポジトリ編に譲ります)。注目してほしいのは import 文で、infrastructure が domain を import しています。逆ではありません。
interface は「使う側」に置く
「まず結論、パッケージ構成の全体像」の最後に置いた「依存の矢印の違和感」を、ここで回収します。
Java で DDD をやる場合、interface と実装クラスを揃って書き、implements Repository のように実装側が interface との関係を明示します。なので interface の置き場所も「実装とセットで infrastructure 寄りに」という発想になりがちです。
Go の interface は逆で、暗黙的に満たされます。ApplicationMemoryRepository はどこにも「application.Repository を実装します」と書いていません。Save と FindByID を持っているから、結果として interface を満たしている。それだけです。
この言語仕様から、Go コミュニティには次の慣習があります。
interface は、それを使う側のパッケージで定義する
リポジトリを「使う」のはドメイン(とusecase)です。だから interface は domain に置く。実装する infrastructure 側は、domain が定めた要求仕様に合わせてコードを書く。結果、依存の矢印はこうなります。
infrastructure →(import して実装)→ domain(interface を定義)
これは DDD でいう依存性逆転そのものです。Java では DI コンテナやアーキテクチャ上の工夫で実現するものが、Go では「interface は使う側に置く」という言語の自然な慣習に従うだけで手に入ります。
GoとDDDを並行して学習していて一番「繋がった」と感じたのがここでした。interface の置き場所という Go の定石が、DDD の依存性逆転とそのまま重なっていたからです。
最後に、これらを組み立てる main を載せておきます。依存の注入も、コンストラクタ関数に渡すだけです。
// main.go
func main() {
repo := persistence.NewApplicationMemoryRepository()
approve := usecase.NewApproveApplication(repo)
handler := handler.NewApplicationHandler(approve)
// ... HTTPサーバーの起動
}
DIコンテナは使っていません。この規模なら素の Go で十分ですし、依存の組み立てが main に全部見えているのは、むしろ読みやすいと感じています。
やってみてハマったこと・まだ迷っていること
正直な記録として、つまずいた点も書いておきます。
循環 import に何度か怒られた
Go は循環 import をコンパイルエラーとして即座に弾きます。最初は鬱陶しく感じましたが、循環が起きるのはだいたい「レイヤの境界を破ろうとしたとき」でした。domain から usecase の型を参照しようとした瞬間に怒られる。今では「アーキテクチャ違反を検知するリンター」だと思って付き合っています。
パッケージ名と型名の重複問題
application パッケージに Application 型があるので、利用側では application.Application と書くことになります。正直ちょっと冗長です。app とエイリアスを付ける、パッケージ名を再考する、などの案がありますが、まだ結論が出ていません。Go の標準ライブラリにも time.Time のような例があるので、「気にしない」が正解なのかもしれません。
どこまでパッケージを分けるか問題
最初、domain/entity domain/valueobject のようにDDDの構成要素の種類でディレクトリを切ろうとして、失敗しました。関連するコードが散らばって、かえって読みにくくなります。今は domain/application のように集約単位で切っています。「技術的な分類ではなく、ドメインの関心で分ける」。これ自体がDDD的な学びでした。
まとめ
- レイヤはパッケージ構成そのもので表現できる。依存の向きは常に domain へ
- フィールドの非公開化で不変条件を、軽量な型定義で識別子の取り違え防止を、コンパイラに任せられる
- interface は使う側(domain)に置く。Go の慣習に従うだけで依存性逆転が実現する
- ビジネスルールは domain のメソッドに集約する。usecase が薄いかどうかがバロメーター
次回は、今回さらっと流した Status を題材に、値オブジェクト編を書く予定です。「差戻しされた申請は再提出できるのか?」という状態遷移のルールを、Go でどう型に落とすかを書きます。
ここまで読んでいただきありがとうございました。設計判断へのツッコミや「うちではこうしている」という事例、大歓迎です。
サンプルコードの全体は、以下のリポジトリで動く形で公開しています(go run . で自己承認エラーのデモが動きます)。
本記事のサンプルコードは説明用に簡略化しています。エラーハンドリングや並行性の考慮は最小限です。