0
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?

GoでDDDを始めたら最初にぶつかった「クラスがない」問題と、パッケージ構成という答え

0
Last updated at Posted at 2026-07-24

はじめに

ピースミール・テクノロジー 進地 です。

最近、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 文に usecaseinfrastructure が現れたら、それは設計が崩れているサインです。

「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. フィールドはすべて小文字(非公開)にする

idstatus を小文字にしているのは意図的です。前半で書いたとおり、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 を実装します」と書いていません。SaveFindByID を持っているから、結果として 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 . で自己承認エラーのデモが動きます)。


本記事のサンプルコードは説明用に簡略化しています。エラーハンドリングや並行性の考慮は最小限です。

0
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
0
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?