はじめに
ピースミール・テクノロジー 進地 です。
GoとDDDの学習記録、連載の第3回です。前回は値オブジェクトと状態遷移を書きました。
今回はリポジトリ編です。第1回からずっとインメモリ(メモリ上のmapに保存するだけ)だったリポジトリを、SQLiteによる本物の永続化に差し替えます。第1回で「interfaceは使う側に置く」と設計した依存性逆転が、実装の交換という本番でどこまで効くのかを確かめる回です。
先に結果を言うと、usecaseは1行も変わりませんでした。ただし、domainは無傷ではありませんでした。この「効いた部分」と「甘くなかった部分」の両方を書きます。
想定読者
- 連載を読んでくれている人(第1回・第2回の設計を前提にしますが、要点はその都度振り返ります)
- リポジトリパターンを「聞いたことはあるが、何が嬉しいのか腑に落ちていない」人
この記事のスコープ
リポジトリの概念と、SQLite実装への差し替えに絞ります。トランザクションや複雑な検索クエリは扱いません。
本記事時点のコードは article-03 タグ、前回からの差分は article-02...article-03 の比較 で確認できます。
今回も学習中の身として書いています。実務でリポジトリパターンを運用している方の視点、歓迎です。
そもそもリポジトリとは何か
差し替えの前に、リポジトリという概念を整理しておきます。連載でここまで説明せずに使ってきたので、一度立ち止まります。
リポジトリは、ドメインオブジェクト専用の保管係です。利用する側から見ると、まるでメモリ上のコレクションのように振る舞います。申請を渡せば保管してくれて、IDを渡せば取り出してくれる。その裏側がSQLなのか、ファイルなのか、ただのmapなのかは、利用する側からは見えません。
第1回で定義したinterfaceを見直すと、この性格がよく出ています。
// domain/application/repository.go(第1回から変更なし)
package application
type Repository interface {
Save(app *Application) error
FindByID(id ApplicationID) (*Application, error)
}
ここには「申請は保存できて、IDで取り出せる」というドメインの要求しか書かれていません。SQLの気配もテーブルの気配もない。これがリポジトリの本質で、永続化という技術的な関心事を、ドメインの語彙で覆い隠す壁の役割を果たします。
もう1つ、第1回の設計判断をおさらいします。このinterfaceは実装側(infrastructure)ではなく、使う側(domain)に置いてあります。Goの「interfaceは使う側で定義する」という慣習に従った結果、依存の向きはこうなっています。
usecaseが知っているのはinterfaceだけで、実装が何者かを知りません。だとすれば、実装を丸ごと入れ替えてもusecaseには影響がないはずです。今回はこの「はず」を検証します。
一番の利点。DBを決めなくても開発は止まらない
差し替え作業に入る前に、リポジトリパターンの一番のメリットを先に言っておきたいです。それは、DBが決まっていなくても開発の手が止まらないことです。
実はこの連載自体がその証拠になっています。第1回と第2回で、パッケージ構成を作り、エンティティを実装し、値オブジェクトと状態遷移のルールを組み、テストを書き、デモを動かしてきました。この間、DBは影も形もありません。それでもドメインの設計と実装は2記事分、前に進みました。
実務のプロジェクトを思い浮かべると、この価値が具体的になります。開発初期は「DBはPostgreSQLかMySQLか」「クラウドのマネージドサービスをどこまで使うか」といったインフラの意思決定が、要件やチーム事情の確定待ちで宙に浮きがちです。そこで手を止めてインフラの結論を待つのか、インメモリ実装で先にドメインを組み立てて検証を進めるのか。リポジトリパターンは後者を選べるようにしてくれます。
これは「決定を遅らせる」こと自体に価値があるという話でもあります。DB選定は、非機能要件やデータ量の見込みといった情報が揃うほど精度が上がる意思決定です。リポジトリという壁があると、その決定を情報が揃う時点まで先送りしながら、ドメインの検証という別の学習を先に回せます。
もっとも、これは「壁の内側にSQLが漏れていなければ」の話です。本当に漏れていなかったのかは、差し替えてみれば分かります。
甘くなかった話その1。非公開フィールドは永続化できない
SQLite実装を書き始めて、最初の数分でつまずきました。Saveを書こうとすると、保存すべき値が取り出せません。
// こう書きたいが、コンパイルエラーになる
_, err := r.db.Exec(`INSERT INTO applications ...`,
app.id, // 非公開フィールドは参照できない
app.applicantID, // 同上
app.title, // 同上
...
)
第1回で「フィールドはすべて非公開にして、状態変更の手段をメソッドに限定する」と設計しました。その方針が、infrastructure層からの読み出しまで塞いでいたわけです。カプセル化を徹底したことのコストが、ここで表面化した形です。
対処はゲッターの追加です。
// domain/application/application.go(抜粋)
func (a *Application) ID() ApplicationID { return a.id }
func (a *Application) ApplicantID() ApplicantID { return a.applicantID }
func (a *Application) Title() string { return a.title }
func (a *Application) Status() Status { return a.status }
ここで大事なのは、公開したのは「読み」だけという点です。書き込みは今までどおり Submit() や Approve() などのメソッド経由に限定されていて、遷移ルールを通らない状態変更はできないままです。カプセル化のうち、守りたかった部分(不変条件)は壊れていません。それでも「ドメインの内部表現を外に見せる窓が増えた」のは事実で、この釈然としなさは後半の「迷ったこと」で改めて書きます。
甘くなかった話その2。生成と復元は別物だった
2つ目のつまずきは、もう少し根が深い話でした。
第1回で作ったコンストラクタ関数は、こうです。
// NewApplication は「下書き」状態の申請を生成する
func NewApplication(id ApplicationID, applicantID ApplicantID, title string) *Application {
return &Application{
id: id,
applicantID: applicantID,
title: title,
status: StatusDraft, // 必ず下書きから始まる
}
}
「申請は必ず下書きから始まる」というドメインルールを、コンストラクタで強制しています。これ自体は正しい設計です。ところがDBからの読み戻しでは、このルールが邪魔になります。DBには「承認済み」の申請が保存されている。それを読み戻すのに NewApplication を使うと、下書きに巻き戻ってしまいます。
つまり、業務イベントとしての「申請を作る」と、技術的な操作としての「保存済みの申請をメモリ上に組み立て直す」は、似ているようで別物でした。前者にはドメインルール(下書きから始まる)が働き、後者には働いてはいけない。そこで復元専用の関数を追加しました。
// Reconstruct は永続化された値から Application を復元する。
// 新規作成(NewApplication)と違い、状態を「下書き」に固定しない。
// リポジトリ実装(infrastructure層)からの利用を想定している
func Reconstruct(id ApplicationID, applicantID ApplicantID, title string, status Status) *Application {
return &Application{
id: id,
applicantID: applicantID,
title: title,
status: status,
}
}
ここで、前回の話がつながります。Reconstruct の第4引数は Status 型です。string ではありません。つまり呼び出し側は、DBから読んだ生の文字列を Status に変換してからでないと、この関数を呼べません。その変換こそ、前回作った NewStatus() です。
前回「DBやJSONから状態を読み戻す場面では、外の世界から生の文字列が入ってくる」と書いたときは、まだその場面が存在しませんでした。今回、その場面が本当にやってきます。
SQLite実装を書く
準備が整ったので、本体です。DBにはSQLiteを選びました。サーバーのセットアップなしに go run . だけで動くからです。ドライバは modernc.org/sqlite(純Go実装)を使います。定番の mattn/go-sqlite3 はcgo(GoからCのコードを呼び出す仕組み)を使うため、環境によってはCコンパイラの用意が必要になります。特にWindowsではひと手間なので、依存を足すだけで動く純Go実装を選びました。
// infrastructure/persistence/application_sqlite.go
package persistence
import (
"database/sql"
"errors"
"fmt"
"github.com/shinchi-pmtech/ringi/domain/application"
)
type ApplicationSQLiteRepository struct {
db *sql.DB
}
// NewApplicationSQLiteRepository はリポジトリを生成し、テーブルがなければ作る。
// (本格的なマイグレーション管理は本連載では扱わない)
func NewApplicationSQLiteRepository(db *sql.DB) (*ApplicationSQLiteRepository, error) {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS applications (
id TEXT PRIMARY KEY,
applicant_id TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL
)`)
if err != nil {
return nil, fmt.Errorf("テーブル作成に失敗しました: %w", err)
}
return &ApplicationSQLiteRepository{db: db}, nil
}
func (r *ApplicationSQLiteRepository) Save(app *application.Application) error {
_, err := r.db.Exec(`
INSERT INTO applications (id, applicant_id, title, status)
VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
applicant_id = excluded.applicant_id,
title = excluded.title,
status = excluded.status`,
string(app.ID()), string(app.ApplicantID()), app.Title(), string(app.Status()),
)
return err
}
func (r *ApplicationSQLiteRepository) FindByID(id application.ApplicationID) (*application.Application, error) {
row := r.db.QueryRow(
`SELECT applicant_id, title, status FROM applications WHERE id = ?`,
string(id),
)
var applicantID, title, statusRaw string
if err := row.Scan(&applicantID, &title, &statusRaw); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errors.New("application not found")
}
return nil, err
}
// DBから来た文字列は「外の世界」の値。NewStatus の自己検証を必ず通す
status, err := application.NewStatus(statusRaw)
if err != nil {
return nil, fmt.Errorf("復元に失敗しました: %w", err)
}
return application.Reconstruct(
id,
application.ApplicantID(applicantID),
title,
status,
), nil
}
ポイントを3つ。
1. Save は INSERT と UPDATE を区別しない
ON CONFLICT(id) DO UPDATE はupsert(あれば更新、なければ挿入)の構文です。リポジトリのinterfaceが Save という1つの動詞しか持っていないのは、コレクションの抽象に合わせているからで、「新規か更新か」というSQL側の区別はこの壁の内側に閉じ込めます。利用側は何も考えずに Save を呼べばよい形になっています。
2. 復元の入口で NewStatus が門番をする
FindByID の中で、DBから読んだ statusRaw を必ず NewStatus() に通しています。誰かがSQLでDBを直接書き換えて不正な状態を仕込んでも、ドメインの世界に入る前にここで弾かれます。ドメインの整合性を守る最後の防衛線が、infrastructureとdomainの境界線上に立っている構図です。
3. infrastructureは薄いまま
このファイルにビジネスルールは1つもありません。SQLの発行と、ドメインオブジェクトとの詰め替えだけです。「申請中でなければ承認できない」も「自己承認の禁止」も、すべてdomainにあります。第1回から言い続けている「ルールはdomainに、infrastructureは変換だけ」が、本物のDBが来ても維持できました。
差し替えは、mainの数行だけだった
いよいよ差し替えです。変更箇所はmainの構築部だけでした。
// main.go(抜粋)
db, err := sql.Open("sqlite", "ringi.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// 差し替えたのはこの1箇所。usecase 以下は一切変更なし
repo, err := persistence.NewApplicationSQLiteRepository(db)
if err != nil {
log.Fatal(err)
}
approve := usecase.NewApproveApplication(repo)
NewApplicationMemoryRepository() を NewApplicationSQLiteRepository(db) に替えただけで、usecaseのコードには一切触れていません。usecaseはinterfaceにしか依存していないので、interfaceを満たすものなら何が来ても構わないからです。先ほど「はず」と書いた依存性逆転が、実装の交換という一番分かりやすい形で確認できました。
実行すると、前回までと同じ業務フローに、SQLiteならではのデモが2つ加わった出力になります。
提出: submitted
自己承認: 自分の申請を自分で承認・差戻しすることはできません
差戻し: rejected
再提出: submitted
承認: approved
復元: approved
不正データ: 復元に失敗しました: 不正な状態です: "banana"
「復元」の行は、承認まで済んだ申請をDBから読み戻したものです。プロセスを再起動しても ringi.db ファイルに状態が残ります。
最後の「不正データ」の行に注目してください。デモの中で、リポジトリを迂回してSQLを直接発行し、statusカラムに 'banana' を書き込んでいます。それを FindByID で読み戻そうとすると、NewStatus が復元を拒否する。前回作った自己検証が、想定どおり動きました。
インメモリ実装はテストダブルとして再利用する
差し替えが終わっても、インメモリ実装は削除しません。テストで再利用できるからです。
usecaseのテストを書くとき、本物のDBは要りません。interfaceを満たすものなら何でもいいので、インメモリ実装をテストダブル(本物の部品の代わりにテストで使う代役)として使えます。テストダブルにもいくつか種類があり、本例のインメモリ実装のように「簡易だが実際に保存・取得が機能する」ものは、フェイクと呼ばれます。
// usecase/approve_application_test.go(抜粋)
func TestApproveApplication_自己承認は拒否される(t *testing.T) {
repo := persistence.NewApplicationMemoryRepository() // DBなしで動く
app := application.NewApplication("APP-001", "tanaka", "開発端末の購入")
if err := app.Submit(); err != nil {
t.Fatal(err)
}
if err := repo.Save(app); err != nil {
t.Fatal(err)
}
u := usecase.NewApproveApplication(repo)
err := u.Execute("APP-001", "tanaka")
if !errors.Is(err, application.ErrSelfApproval) {
t.Errorf("自己承認が拒否されませんでした: %v", err)
}
}
DBの起動もテスト用スキーマの準備も不要で、テストはミリ秒で終わります。「DBを決めなくても開発が止まらない」の章で書いたメリットは、開発初期だけの話ではなく、テストという形でその後の開発でも効き続けるわけです。
なお、SQLite実装そのもののテストは本物のSQLite(:memory: のインメモリDB)で書いています。SQLの正しさはSQLでしか検証できないので、ここは代役なしです。リポジトリの比較リンクから application_sqlite_test.go を見てもらうと、'banana' を仕込んで復元が失敗することのテストも入っています。
やってみてハマったこと・迷っていること
インメモリ実装は「Save忘れ」を隠していた
差し替えで一番焦ったのがこれです。旧mainは、申請オブジェクト(Application)を操作した後に Save を呼ばなくても正しく動いていました。インメモリ実装はオブジェクトへのポインタ(参照)をそのままmapに持つので、手元での変更が保存済みデータに即座に反映されていたからです。SQLiteに替えた瞬間、この暗黙の共有は消え、Saveを通していない変更はDBに残らなくなりました。つまりインメモリのテストダブルは、「Saveの呼び忘れ」というバグを検出できません。本物より寛容な代役は、この種のバグを見逃します。
Reconstruct は誰でも呼べてしまう
Reconstruct はinfrastructure層のための関数ですが、Goの公開関数なので、どこからでも呼べます。極端な話、usecaseが遷移ルールを無視して「承認済みの申請」をゼロから組み立てることもできてしまう。Javaならパッケージプライベートで縛れる場面ですが、Goのdomainとinfrastructureは別パッケージなので、公開する以外に渡す手段がありません。「infrastructureにだけ見せる」ができない以上、ここはコメントと規律で守るしかないのか、まだ私には答えが出ていません。
ゲッターが増えていく先への不安
今回4つのゲッターを足しました。今は「読み」だけの公開なので許容していますが、この調子でフィールドが増えるたびにゲッターが並ぶと、エンティティがただのデータ入れ物に見えてくる予感があります。永続化のためだけの公開なら、エンティティ本体ではなく変換専用の中間の形を挟む手もあると聞きますが、この規模で導入するには大げさな気もして、見送りました。
まとめ
- リポジトリは、永続化という技術的関心事をドメインの語彙で覆い隠す壁。一番の利点は「DBを決めなくても開発が止まらない」ことで、この連載自体が2記事分その恩恵を受けてきた
- 差し替えでusecaseは1行も変わらなかった。interfaceを使う側(domain)に置いた第1回の設計が、実装の交換の場面で効いた
- ただしdomainは無傷ではなかった。永続化には「読み」の公開(ゲッター)と、生成とは別の「復元」(Reconstruct)が必要になる
- 復元は外の世界との境界。前回作った
NewStatusの自己検証が、DBからの不正データを弾く防衛線として働いた
次回は、モデルの区切り方(DDDでいう集約)の話をやる予定です。今の申請は承認者が1人ですが、現実の稟議は「課長の次は部長」のように承認が多段になります。承認ルートという新しい概念を、申請モデルの内側に持たせるべきか、独立した別のモデルにするべきか。その境界線を引く話です。
サンプルコードの全体はリポジトリで公開しています。本記事時点のコードは article-03 タグを参照してください。
本記事のサンプルコードは説明用に簡略化しています。エラーハンドリングや並行性の考慮は最小限です。