8290 words
41 minutes
GoのOnion Architectureとエラーハンドリングを触ってみた

前回の続きです#

お久しぶりです、YAMAです。
前回、DMMさんのインターン「DMM Sprint Go」に行ってきた話を書きました。
→ DMMのインターンに参加した話。

ありがたいことにこの記事にフィードバックをいただきまして、ざっくり言うと

  • インターンのことなどについては書いてあるけど、技術面の話が少ないね…というものでした。
    確かによく見るとGoのインターンで学んだ技術面についてはほとんど書いていないなぁとその後に気づきました…

まったくもってその通りで、前回の記事は「体験談」としては書けていても「技術のドキュメント」としては何も書けていませんでした。

なので今回は、コードを出しながらこの2つを書き直す回です。
なお、この記事に出てくるコードは、インターンで書いた実物ではなく、構造はそのままに名前を変えて簡略化した疑似コードです。
題材は「アカウントがメモ(Note)を投稿するだけのAPI」ということにしておきます。

それと、インターンから時間が結構立っていて、記憶が曖昧なところもあるので、間違っていたらすいません。
それと私はGoの経験がとても浅く、有識者から見た場合、誤りがあるかもしれません。
以上の点を踏まえた上で、読んでいただけると幸いです。


Onion Architecture#

まず、どんな層に分かれているのか#

ディレクトリはこんな感じになっています。

app
├── ui          # プレゼンテーション層(HTTPハンドラ、リクエスト/レスポンス)
├── usecase     # ユースケース層(システムの振る舞い)
├── domain
│   ├── object      # ドメインオブジェクト(ビジネスルールの本体)
│   ├── repository  # 永続化の「設計図」=interfaceだけ
│   └── service     # ドメインサービス
├── infra       # インフラ層(MySQLとの実際のやり取り)
└── server      # DI(依存性注入)とルーティング

そして依存の向きがこうなります。

   ui  ──────▶  usecase  ──────▶  domain
                                    ▲
   infra ───────────────────────────┘

ポイントは、矢印が全部 domain に向かって内側を向いていることです。
infra が domain を向いているのが、後で書く「依存性逆転」のところになります。

MVC(というかController-Service-Repository)だとどう書いていたか#

自分はもともとSpring Bootを触っていたので、Webアプリを書くときの頭の中はずっとこれでした。

Controller  →  Service  →  Repository  →  DB

これをGoの疑似コードで書くと、だいたいこうなります。

// ===== handler(Controller) =====
func (h *Handler) CreateNote(w http.ResponseWriter, r *http.Request) {
    var req CreateNoteRequest
    json.NewDecoder(r.Body).Decode(&req)
    note, err := h.service.CreateNote(r.Context(), accountID, req.Body)
    // ...
}

// ===== service =====
type NoteService struct {
    repo *NoteRepository // ← 実装そのものを持っている
}

func (s *NoteService) CreateNote(ctx context.Context, accountID uint64, body string) (*Note, error) {
    if len(body) == 0 || len(body) > 140 {   // バリデーションもここ
        return nil, errors.New("invalid body")
    }
    return s.repo.Insert(ctx, accountID, body)
}

// ===== repository =====
type NoteRepository struct {
    db *sqlx.DB
}

func (r *NoteRepository) Insert(ctx context.Context, accountID uint64, body string) (*Note, error) {
    // ここで直接SQLを書く
}

素直だし、正直これで動きます。上から下に一直線なので読みやすいです。
ただ、NoteService が *NoteRepository という具体的な実装を直接持っているのがポイントで、ここが後で効いてきます。

(厳密にはこれはMVCというよりレイヤードアーキテクチャなのですが、自分の頭の中の「いつもの書き方」がこれだったので、以降はこれを比較対象にします。)

Onion Architectureで同じ機能を書くとこうなる#

同じ「メモを投稿する」を、Onion Architectureで書くとこうなります。
ファイルが4つに割れます。

① domain/object:ビジネスルールを持つ#

まず、「メモとは何か」をドメインオブジェクトとして定義します。
ここがルールの本体で、不正な状態のオブジェクトはそもそも作れないようにします。

// app/domain/object/note/pending_note.go
package note

// PendingNote = まだDBに保存されていない(IDが決まっていない)メモ
type PendingNote struct {
    accountID uint64
    body      string
}

func NewPendingNote(accountID uint64, body string) (*PendingNote, error) {
    p := &PendingNote{}
    if accountID == 0 {
        return nil, errors.ErrBadRequest.WithDevMessage("account id must be more than 0")
    }
    p.accountID = accountID
    if len(body) == 0 || len(body) > 140 {
        return nil, errors.ErrBadRequest.WithDevMessage("body must be between 1 and 140 characters")
    }
    p.body = body
    return p, nil
}

func (p *PendingNote) AccountID() uint64 { return p.accountID }
func (p *PendingNote) Body() string      { return p.body }

フィールドが全部小文字(=非公開)で、生成は NewPendingNote を通すしかないので、140文字を超えたメモはそもそも存在できません。
「保存前(PendingNote)」と「保存後(Note)」で型を分けているのも最初は「なんで?」となったのですが、保存前はIDが決まっていないので、ID が0の中途半端なオブジェクトを持ち回さなくて済むという意味でした。

② domain/repository:保存の「設計図」だけ書く#

次が、自分が一番「は?」となったところです。

// app/domain/repository/note.go
package repository

type Note interface {
    Insert(ctx context.Context, pendingNote *note.PendingNote) (*note.Note, error)
    FindByID(ctx context.Context, id uint64) (*note.Note, error)
}

interfaceしかありません。SQLは1行もありません。
「このドメインを永続化するときはこういう操作ができて、引数はこれで、返り値はこれ」という設計図だけをdomain層に置きます。

MySQL という単語も sqlx という単語も、ここには出てきません。これが大事です。

③ usecase:設計図を呼ぶ#

ユースケース層は、②の設計図(interface)を受け取って使います。

// app/usecase/note/create_note.go
type CreateNoteUseCaseImpl struct {
    noteRepo   repository.Note      // ← interface を持つ(実装ではない)
    transactor transactor.Transactor
}

func NewCreateNoteUseCase(noteRepo repository.Note, transactor transactor.Transactor) *CreateNoteUseCaseImpl {
    return &CreateNoteUseCaseImpl{noteRepo: noteRepo, transactor: transactor}
}

func (uc *CreateNoteUseCaseImpl) CreateNote(ctx context.Context, accountID uint64, body string) (*note.Note, error) {
    result, err := uc.transactor.TransactionWithValue(ctx, func(ctx context.Context) (any, error) {
        pendingNote, err := note.NewPendingNote(accountID, body) // ← ルールはドメインに聞く
        if err != nil {
            return nil, err
        }
        return uc.noteRepo.Insert(ctx, pendingNote) // ← 保存は設計図に投げる
    })
    // ...
}

usecaseがやっているのは、「ドメインオブジェクトを作って、リポジトリに渡す」という段取りだけです。
バリデーションのルールも知らないし、SQLも知りません。

さっきのMVC版の NoteService が *NoteRepository を持っていたのに対して、こっちは repository.Note(interface)を持っています。この違いが全部です。

④ infra:設計図を見ながら実装する#

そして実際にMySQLを叩くのがinfra層です。

// app/infra/note.go
package infra

// このコンパイル時アサーションが地味に効く
var _ repository.Note = (*NoteRepoImpl)(nil)

type NoteRepoImpl struct{}

type noteDTO struct {
    ID        uint64    `db:"id"`
    AccountID uint64    `db:"account_id"`
    Body      string    `db:"body"`
    CreatedAt time.Time `db:"created_at"`
}

func (r *NoteRepoImpl) Insert(ctx context.Context, pendingNote *note.PendingNote) (*note.Note, error) {
    tx, err := transaction.FetchTransaction(ctx)
    if err != nil {
        return nil, err
    }

    result, err := tx.ExecContext(ctx,
        `INSERT INTO note (account_id, body) VALUES (?, ?)`,
        pendingNote.AccountID(), pendingNote.Body(),
    )
    if err != nil {
        return nil, err
    }
    id, err := result.LastInsertId()
    if err != nil {
        return nil, err
    }

    var dto noteDTO
    if err := tx.GetContext(ctx, &dto,
        `SELECT id, account_id, body, created_at FROM note WHERE id = ?`, id); err != nil {
        return nil, err
    }
    // DTO(テーブルの形)→ ドメインオブジェクト(アプリの形)に詰め替える
    return note.ReconstructNote(dto.ID, dto.AccountID, dto.Body, dto.CreatedAt)
}

var _ repository.Note = (*NoteRepoImpl)(nil) はGoのイディオムで、「NoteRepoImpl は repository.Note を満たしてますよね?」というのをコンパイル時にチェックさせる書き方です。
設計図にメソッドを1つ足したのに実装を忘れると、その場でビルドが落ちてくれるので、これはかなり便利でした。

⑤ server.go:ここで初めて全部つながる#

で、interfaceと実装をくっつけているのが server.go です。ここがDI(依存性注入)です。

// app/server/server.go
// Repository(infra の実装をここで作る)
noteRepo := infra.NewNoteRepository()

// UseCase(interface のところに実装を「注入」する)
createNoteUseCase := usecase_note.NewCreateNoteUseCase(noteRepo, transactor)

// Handler
noteHandler := api_note.NewNoteHandler(createNoteUseCase)

usecase は自分で infra を import しません。server.go が外から差し込みます。
usecaseは、自分が渡されたものがMySQL実装なのかどうかを最後まで知りません。

「コードの流れ」と「依存の向き」が逆になる、という話#

ここが一番の山でした。

コードを書く順番・処理が流れる順番は

usecase  →  repository(設計図)  →  infra(実装)

なのに、import の向き=依存の向きは

infra  →  repository(設計図)

と、逆向きになります。
infra が domain/repository を import していて、domain は infra のことを一切知りません。

これが依存性逆転と呼ばれるやつです。
自分はMVCの「上から下に一直線」に慣れていたので、「呼ぶ側が呼ばれる側に依存する」のが当たり前だと思っていて、ここで完全に迷子になりました。

Day2で丸一日溶かしたのは、正直ここが飲み込めていなかったからです。
「repositoryにinterfaceだけ書いても動かなくない?」「じゃあ本体はどこ?」となって、ファイルの間を延々と行ったり来たりしていました。

で、それで何が嬉しいの?#

これも講師の方に聞いて、ようやく腹落ちしました。

1. DBを乗り換えるときに、触る場所が2箇所で済む#

たとえば「MySQLをやめてPostgresにしたい」となったとします。

MVC版だと、NoteRepository の中身が書き換わって、それを直接持っている NoteService にも影響が出る可能性があります。

Onion版だと、domain/repository/note.go(設計図)は1文字も変わりません。
やることは、

  1. infra に Postgres版の実装を新しく作る
  2. server.go で渡すものを差し替える

これだけです。

// Before
noteRepo := infra.NewNoteRepository()          // MySQL実装

// After
noteRepo := infra.NewNotePostgresRepository()  // Postgres実装

usecase も domain も ui も、1行も変わりません。これが「変更容易性が高い」ということでした。

2. 段階移行できるし、切り戻しもできる#

しかもMySQL実装を消す必要がありません。両方infraに残しておいて、

var noteRepo repository.Note
if config.UsePostgres() {
    noteRepo = infra.NewNotePostgresRepository()
} else {
    noteRepo = infra.NewNoteRepository()
}

こうしておけば、移行先で問題が起きたときにフラグを戻すだけで元に戻せます。
「乗り換えやすい」だけじゃなくて「失敗しても戻れる」というのが、実務的にはかなりデカいんだろうなと思いました。

3. テストのときにDBが要らない#

usecase が持っているのはinterfaceなので、テストでは偽物を渡せます。

type fakeNoteRepo struct{}

func (f *fakeNoteRepo) Insert(ctx context.Context, p *note.PendingNote) (*note.Note, error) {
    return note.ReconstructNote(1, p.AccountID(), p.Body(), time.Now())
}
func (f *fakeNoteRepo) FindByID(ctx context.Context, id uint64) (*note.Note, error) {
    return nil, nil
}

// テスト側
uc := usecase_note.NewCreateNoteUseCase(&fakeNoteRepo{}, &fakeTransactor{})

MySQLを立ち上げずにユースケースのテストが書けます。
これも「interfaceに依存しているから」できることでした。

「どの層に何を書くか明確になる」って、具体的にはこういうこと#

前回の記事で「明確になったのが嬉しい」と書いたのですが、自分でも何が嬉しいのかふわっとしていたので、具体例で書きます。

MVCで書いていたときに一番迷っていたのは、「このバリデーション、どこに書けばいいんだ?」でした。

  • Controllerで弾く?(早く弾けるけど、他の入口からも呼ばれたら?)
  • Serviceで弾く?(じゃあControllerのバリデーションは何だったの?)
  • Entityで弾く?(そんな仕組みなくない?)

結果、Controllerにも書いてServiceにも書いて、同じチェックが両方に散らばる、みたいなことをよくやっていました。

Onion Architectureだと、この迷いが「層の責務」で機械的に決まります。

やりたいことどの層理由
リクエストJSONのdecodeに失敗したuiHTTPの都合。ドメインには関係ない
認証ヘッダからaccountIDを取り出すui(middleware)同上
140文字を超えていないかdomain/object「メモとは何か」というルールそのもの
アカウント名が重複していないかdomain/service1つのオブジェクトだけでは判断できないルール
投稿処理をトランザクションで囲むusecase「どこからどこまでが1つの処理か」は振る舞いの話
SQLを書くinfra技術の詳細
sql.ErrNoRows の判定infradatabase/sql を知っていいのはここだけ
エラーをHTTPステータスに変換uiステータスコードはHTTPの知識

「これはビジネスのルールか?アプリの段取りか?技術の詳細か?」で振り分けるだけになるので、迷う時間がなくなりました。
Day2は迷いっぱなしで進まなかったのに、Day4以降で急に手が速くなったのは、たぶんこの表が頭の中にできたからだと思います。

ちなみに、さっきの NewPendingNote の文字数チェック、いま見返すと len(body) を使っているので日本語だと1文字が3バイト換算になっていて、完全にバグっています(utf8.RuneCountInString を使うべきでした)。
ただ、これも直す場所が pending_note.go の1箇所だけなんですよね。MVC時代の自分みたいにControllerとServiceの両方に書いていたら、片方直して片方忘れる、を確実にやっていたと思います。

難しさを感じたところ#

嬉しい話ばかり書いてもフェアじゃないので、しんどかったところも書きます。

① 1機能作るのに触るファイルが多い#

「メモを投稿する」を作るだけで、触ったファイルはこれだけあります。

app/domain/object/note/pending_note.go   新規
app/domain/object/note/note.go           新規
app/domain/repository/note.go             新規
app/usecase/note/create_note.go            新規
app/infra/note.go                         新規
app/ui/api/note/request.go                新規
app/ui/api/note/response.go               新規
app/ui/api/note/note.go                  新規
app/server/server.go                       修正

9ファイル。MVCなら3ファイルで終わる内容です。
慣れていない状態でこれをやると、「今どこを書いているんだっけ」が本当にわからなくなります。
Day2で詰まった原因の半分はこれで、アーキテクチャの問題というより、自分の頭のワーキングメモリの問題でした。

② 「知っていてはいけないこと」がある#

これが一番カルチャーショックでした。

たとえば、usecase の中で「404を返したい」と思っても、net/http を import してはいけません。
HTTPステータスコードはUI層の知識なので、usecaseがそれを知った瞬間に usecase → ui という依存が生まれて、矢印が逆流するからです。

③ DTOとドメインオブジェクトの詰め替えが面倒に見えた#

infra層で、noteDTO にSELECTした結果を入れて、それを ReconstructNote でドメインオブジェクトに詰め替えています。

最初は「同じフィールドを移し替えてるだけじゃん、Note に直接 db:"id" タグを付ければよくない?」と思っていました。

でもこれをやると、ドメインオブジェクトがテーブル定義に引きずられます。
カラム名を変えたらドメインオブジェクトが変わるし、テーブルを分割したらドメインオブジェクトが壊れる。せっかく infra を切り離した意味がなくなります。

④ トランザクションをどこで貼るか#

これもOnionならではで悩みました。
トランザクションは「1つの処理の単位」なのでusecaseで貼りたい。でも *sqlx.Tx はinfraの知識なので、usecaseが持つわけにいきません。

答えは、これもinterfaceでした。

// app/usecase/transactor/transactor.go ← usecase側にinterface
type Transactor interface {
    Transaction(ctx context.Context, txFunc func(context.Context) error) error
    TransactionWithValue(ctx context.Context, txFunc func(context.Context) (any, error)) (any, error)
}
// app/infra/transaction/transaction.go ← infra側に実装
func (t *TransactorImpl) TransactionWithValue(
    ctx context.Context,
    txFunc func(ctx context.Context) (any, error),
) (any, error) {
    tx, err := t.db.Beginx()
    if err != nil {
        return nil, err
    }
    ctxWithTx := context.WithValue(ctx, transactionKey, tx)  // contextにtxを詰める
    result, err := txFunc(ctxWithTx)
    if err != nil {
        tx.Rollback()
        return nil, err
    }
    return result, tx.Commit()
}

// infra側はここでcontextからtxを取り出す
func FetchTransaction(ctx context.Context) (*sqlx.Tx, error) {
    tx, ok := ctx.Value(transactionKey).(*sqlx.Tx)
    if !ok {
        return nil, errors.ErrInternal.WithDevMessage("transaction not found in context")
    }
    return tx, nil
}

context に tx を詰めて渡して、infra側の FetchTransaction(ctx) で取り出す。
こうするとusecaseは「トランザクションで囲む」という意図だけを書けて、*sqlx.Tx の存在を知らずに済みます。

repositoryとまったく同じパターン(usecase側にinterface、infra側に実装)だと気づいたとき、「あ、これ全部同じ考え方でできてるんだ」となって、ここでやっと全体がつながった感じがありました。


まず前提:Goのエラーは「値」#

Javaのtry-catchのように例外を投げるのではなく、Goではエラーは戻り値として返ってきます。

result, err := doSomething()
if err != nil {
    return nil, err
}

これがひたすら続きます。最初は「if err != nil 書きすぎでは?」と思うのですが、どこで失敗しうるかがコードを見ただけで全部わかるという良さがあって、途中から気にならなくなりました。

学んだ手法の整理#

インターンで学んだ(または調べた)手法を並べるとこんな感じです。

① そのまま返す#

if err != nil {
    return nil, err
}

一番シンプル。ただし、これだけだとどこで失敗したのかがわからなくなります。

② fmt.Errorf と %w でラップする#

if err != nil {
    return nil, fmt.Errorf("failed to insert note: %w", err)
}

%w を使うと、元のエラーを包んだまま情報を足せます。
%v だと単なる文字列になって元のエラーが取り出せなくなるので、ここは %w です。

③ sentinel error と errors.Is#

あらかじめエラーを変数として定義しておいて、比較で判定するやり方です。

var ErrNotFound = errors.New("not found")

// 呼び出し側
if errors.Is(err, ErrNotFound) {
    // 404を返す、など
}

err == ErrNotFound ではなく errors.Is を使うのは、②でラップされていても中身まで辿って比較してくれるからです。

④ 独自エラー型と errors.As#

エラーに追加情報を持たせたいときは、自分で型を作ります。

type ValidationError struct {
    Field string
}
func (e *ValidationError) Error() string { return e.Field + " is invalid" }

// 呼び出し側
var vErr *ValidationError
if errors.As(err, &vErr) {
    fmt.Println(vErr.Field)  // 追加情報が取り出せる
}

errors.Is は「そのエラーかどうか」、errors.As は「その型として取り出せるか」。ここは最初ごちゃごちゃになりました。

⑤ panicは使わない#

Goには panic もありますが、「想定できるエラーは全部 error で返す」というのが基本でした。
panic を使うのは、本当に復帰できない状況だけ。
ただし最後の砦として、ミドルウェアで recover してサーバーが落ちないようにはしてあります。

r.Use(middleware.Recoverer)

実際に実装したもの:エラーに「意味」を持たせる#

で、ここからが実装の話です。

さっきOnionの章で書いたとおり、usecaseやdomainからHTTPステータスコードを呼べません。
でも「これは400なのか404なのか500なのか」という情報は、エラーが起きた場所(domainやinfra)が一番よく知っています。

この矛盾をどう解くか、というのが今回のエラーハンドリング設計のキモでした。

1. 自前のステータスコードを定義する#

まず、HTTPから切り離した自分たちのコードを定義します。

// pkg/errors/code/code.go
package code

type StatusCode string

var (
    BadRequest   StatusCode = "BAD_REQUEST"  // 400
    Unauthorized StatusCode = "UNAUTHORIZED" // 401
    Forbidden    StatusCode = "FORBIDDEN"    // 403
    NotFound     StatusCode = "NOT_FOUND"    // 404
    Conflict     StatusCode = "CONFLICT"     // 409
    Internal     StatusCode = "INTERNAL"     // 500
)

コメントにHTTPの番号は書いてありますが、型としてはHTTPと無関係な文字列です。
これならdomainから使っても依存の矢印は逆流しません。

2. エラー型を作る#

そのコードを持つエラー型を定義します。

// pkg/errors/status.go
type Status struct {
    code       code.StatusCode
    uiMessage  string  // クライアントに返すメッセージ
    devMessage string  // 開発者向けの詳細
}

func (e *Status) Error() string {
    return fmt.Sprintf("code: %s, uiMessage: %s, devMessage: %v", e.code, e.uiMessage, e.devMessage)
}

// devMessageだけ差し替えた「新しい」Statusを返す
func (e *Status) WithDevMessage(devMessage string) *Status {
    return &Status{code: e.code, uiMessage: e.uiMessage, devMessage: devMessage}
}

この uiMessage と devMessage を分ける という発想が、個人的に一番「なるほど」となったところでした。

今までの自分は errors.New("account id must be more than 0") みたいに書いて、それをそのままレスポンスに出したりしていました。
でもこれ、内部の実装の言葉がそのまま外に漏れるんですよね。
外向けは「bad request」だけ、詳細はログにだけ出す。この分離が最初から型に組み込まれているのが良かったです。

3. 汎用エラーを用意しておく#

// pkg/errors/error.go
var (
    ErrBadRequest   = New(code.BadRequest, "bad request", "")
    ErrUnauthorized = New(code.Unauthorized, "unauthorized", "")
    ErrForbidden    = New(code.Forbidden, "forbidden", "")
    ErrNotFound     = New(code.NotFound, "not found", "")
    ErrConflict     = New(code.Conflict, "conflict", "")
    ErrInternal     = New(code.Internal, "internal server error", "internal server error occurred")
)

これで各層から、こう書けます。

// domain層:140文字を超えていた
return nil, errors.ErrBadRequest.WithDevMessage("body must be between 1 and 140 characters")

// domain層:アカウント名が重複していた
return nil, errors.ErrConflict

// infra層:トランザクションが取れなかった
return nil, errors.ErrInternal.WithDevMessage("transaction not found in context")

WithDevMessage が新しい Status を返す(元を書き換えない)ようになっているのがポイントで、グローバル変数の ErrBadRequest を汚さずに詳細だけ足せます。

4. UI層で、初めてHTTPに変換する#

そして最後、UI層でだけHTTPステータスコードに変換します。

// app/ui/api/pkg/errors/errors.go
var codes = map[code.StatusCode]int{
    code.BadRequest:   http.StatusBadRequest,
    code.Unauthorized: http.StatusUnauthorized,
    code.Forbidden:    http.StatusForbidden,
    code.NotFound:     http.StatusNotFound,
    code.Conflict:     http.StatusConflict,
    code.Internal:     http.StatusInternalServerError,
}

func Handle(w http.ResponseWriter, err error) {
    if err == nil {
        return
    }
    if v, ok := errors.AsType[*apperrors.Status](err); ok {
        slog.Info(v.UIMessage(), "code", v.Code())
        http.Error(w, v.UIMessage(), codes[v.Code()])   // uiMessageだけ外に出す
    } else {
        // Status型じゃないエラー = 想定外なので、中身を隠して500
        slog.Info(err.Error())
        http.Error(w, "internal server error", http.StatusInternalServerError)
    }
}

ハンドラ側は、エラーが来たらこれを呼ぶだけになります。

created, err := h.createNoteUseCase.CreateNote(ctx, accountID, req.Body)
if err != nil {
    ui_errors.Handle(w, err)   // 400か409か500かはHandleが判断する
    return
}

ハンドラに if errors.Is(err, ...) { 400 } else if ... { 409 } みたいな分岐が一切いらなくなりました。
そして**「HTTP」という単語が出てくるファイルが ui の下だけ**になります。ここでOnionの話とエラーハンドリングの話がきれいに合流したのが、個人的に一番気持ちよかったところです。

もう1つ良かったのが、Status 型じゃないエラー(=どこかで変換し忘れた想定外のエラー)が来たら、問答無用で500にして中身を隠すようになっていることです。
うっかりSQLのエラー文がそのままクライアントに返る、みたいな事故が構造的に起きません。

まだ答えが出ていないこと#

ラップするか、しないか#

Status を返す設計にしたので、「どの種類のエラーか」は完璧にわかります。
でも fmt.Errorf("...: %w", err) でラップしていく方式と違って、どこを通ってきたエラーなのかがわかりません。

理想は「Status でコードを持ちつつ、%w で経路も残す」だと思うのですが、両立させる書き方をきれいに整理しきれませんでした。ここは持ち帰りの宿題です。

どこでログを出すか#

これも最初やらかしました。
「エラーが起きたところでログを出そう」と思って各層に slog を書いたら、1つのエラーでログが3行も4行も出ました。infraで1回、usecaseで1回、handlerで1回、みたいな感じで。

結局、ログを出すのは Handle の中だけ(=一番外側で1回だけ)に統一しました。
途中の層は情報を足して返すだけ、出力するのは端っこ。これは他の言語でも同じだと思うのですが、Goだとエラーが手で流れていくぶん、やらかしやすいなと感じました。


まとめ#

長くなったので、学んだことをまとめます。

Onion Architecture

  • 「コードの流れ」と「依存の向き」は逆になる。usecase → repository(interface) ← infra。ここを飲み込むまでが山。
  • 嬉しいのは「DBを乗り換えるとき、infraとDI(server.go)だけ直せばいい」「両方残せるので切り戻せる」「テストでDBが要らない」の3つ。
  • 一番効いたのは、「この処理どこに書く?」で迷わなくなったこと。ファイルは増えるけど、増えたぶん迷いが減る。

エラーハンドリング

  • HTTPステータスコードはUI層の知識。domainやusecaseから触ると依存が逆流するので、自前のコードを定義してUI層で変換する。
  • uiMessage(外向け)と devMessage(開発者向け)を型レベルで分けておくと、内部情報が漏れない。
  • sql.ErrNoRows は、同じエラーでも文脈で「正常」にも「異常」にもなる。だから決まった答えがない。

前回の記事で「Onion Architectureを理解したら実装が楽になった」と一行で書いてしまったのですが、こうして書き出してみると、楽になった理由って**「迷わなくなったから」**の一点だったんだなと改めて思いました。
設計って「きれいに書くためのもの」だと思っていたのですが、実際は「考えなくていいことを増やすためのもの」なんですね。

フィードバックをくださった方、ありがとうございました。

ここまで読んでいただきありがとうございました!
以上、YAMAでした。

おまけ : DDD(ドメイン駆動設計)の話#

まあ、今回のインターンでは最終日にビアバッシュでいろいろな人と話していて、よく**DDD(ドメイン駆動設計)**の話を聞きました。
ちょっとした雑談ですが、DDDの話も書いておきます。

DDDの話#

まず、DDDとは何ぞやという話ですが、ざっくり言うと「ドメイン(業務のルール)を中心に据えた設計手法」です。(簡単に言うとですが…)

DBのテーブル設計から始めるのではなく、「この業務では何が主役で、どんなルールがあるのか」から考えて、それをそのままコードの形にしていく、みたいな考え方らしいです。

Onion ArchitectureとDDDって何が違うの?#

自分が最初に混乱したのがここでした。
話を聞いていると「DDD」と「Onion Architecture」がほぼセットで出てくるので、同じものだと思っていたんですよね。

聞いた感じだと、

  • DDD = 「ドメインを中心に考えよう」という考え方・方針
  • Onion Architecture = それをコードの構造として成立させるための置き場所のルール

という関係でした。

記事の前半で書いた「domain を一番内側に置いて、そこには何も依存させない」というのは、要するに**「ドメインが主役」をディレクトリ構成で強制している**わけです。
そう考えると、なんで domain が infra を知らないのかが腑に落ちました。DBは主役じゃないんですね。

気づいたら書いていた「DDDの部品」#

で、これが一番おもしろかったのですが、話を聞きながら自分の書いたコードを見返したら、DDDの用語がそのまま出てきていました。

DDDの用語今回のコードで言うと何をしていたか
エンティティNoteIDで同一性が決まるもの。本文が変わっても同じNote
ファクトリNewPendingNote不正な状態のオブジェクトを作らせない、唯一の生成口
リポジトリrepository.Note(interface)永続化の設計図。保存先の技術は知らない
ドメインサービスNameUniqueChecker1つのオブジェクトの中には置けないルール
値オブジェクト使っていない← ここが後述の反省点

NameUniqueChecker が特にわかりやすくて、「アカウント名が重複していないか」って、Account を1個だけ見ても絶対に判断できないんですよね。他の全アカウントを知らないと決められない。
かといって、これを usecase に書いてしまうと「業務のルール」がドメインの外に漏れます。
だから domain/service という置き場所がある、と。

研修中は「なんでこのチェックだけ別ファイルなんだろう」と思いながら写経していたので、ここで初めて意味がわかりました。

値オブジェクトを使わなかったツケ#

唯一まったく使っていなかったのが値オブジェクトです。
値オブジェクトというのは、string や int をそのまま持つのではなく、「本文」「アカウント名」といった意味を持った型を作る、という考え方らしいです。

今回のコードは、わかりやすさ優先で「フィールド+バリデーション付きのSetter」という形にしていました。
で、その結果どうなったかというと、こうなりました。

// app/domain/object/note/pending_note.go(保存前)
func NewPendingNote(accountID uint64, body string) (*PendingNote, error) {
    // ...
    if len(body) == 0 || len(body) > 140 {
        return nil, errors.ErrBadRequest.WithDevMessage("body must be between 1 and 140 characters")
    }
    // ...
}
// app/domain/object/note/note.go(保存後。ReconstructNoteから呼ばれる)
func (n *Note) SetBody(body string) error {
    if body == "" {
        return errors.ErrInternal.WithDevMessage("body must not be empty")
    }
    if !(len(body) <= 140) {
        return errors.ErrInternal.WithDevMessage("body must be less than 140 characters")
    }
    n.body = body
    return nil
}

同じ「140文字以下」というルールが2箇所に書いてあります。
記事の前半で「MVC時代はControllerとServiceの両方に同じバリデーションを書いてしまっていた」と偉そうに反省したのに、結局ドメイン層の中で同じことをやっていました。ちょっと恥ずかしいです。

これを値オブジェクトにすると、こうなります。

// 「本文」という型そのものを作る
type Body struct {
    value string
}

func NewBody(value string) (*Body, error) {
    length := utf8.RuneCountInString(value)
    if length == 0 || length > 140 {
        return nil, errors.ErrBadRequest.WithDevMessage("body must be between 1 and 140 characters")
    }
    return &Body{value: value}, nil
}

func (b *Body) String() string { return b.value }
// PendingNoteもNoteも、stringじゃなくてBodyを持つ
type PendingNote struct {
    accountID uint64
    body      *Body // ← ここが string じゃない
}

type Note struct {
    id        uint64
    accountID uint64
    body      *Body // ← 同じ型を使い回す
    createdAt time.Time
}

ルールが NewBody の中の1箇所だけになりました。
しかも型が string じゃないので、バリデーションを通っていない文字列をうっかり突っ込むことがコンパイル時に不可能になります。

記事の前半で「len() を使っていて日本語だとバグる」と書きましたが、値オブジェクトにしていれば直すのは NewBody の1行だけです。
今の書き方だと2箇所直さないといけないし、片方忘れても誰も教えてくれません。

DDDは「やる/やらない」の二択じゃないらしい#

あと、これも聞いた話なのですが、DDDって「導入する/しない」で決めるものではないそうです。

ドメインが単純なアプリ(管理画面のCRUDだけ、みたいなもの)にフルのDDDを持ち込むと、記事の前半で書いた「1機能9ファイル問題」がひたすら重くのしかかるだけで、得るものが少ない。
逆に、業務のルールが複雑なところほど効いてくる、と。

これから#

正直、いまの自分の理解は「その場で聞いた話 + 5日間書いたコード」の範囲でしかないので、めちゃくちゃ浅いです。
ただ、自分がすでに書いていたコードに名前がついていたという体験はかなり効きました。

用語から入っていたら「集約って何……」で確実に止まっていたと思うのですが、先に手を動かしていたおかげで「あ、あれのことか」から入れたのが良かったです。
順番って大事ですね。

DDDには定番の本がいくつかあるらしいので、まずはそのあたりを読みつつ、次に個人開発をするときは値オブジェクトから入れてみようと思います。


というわけで、おまけのほうが長くなってしまいましたが、以上です。
次はDDDをちゃんと勉強して、また記事にできたらと思います。
改めて、ここまでお付き合いいただきありがとうございました!