コンテンツにスキップ

Goコードで理解するHexagonal・Clean Architecture

責務分割の入口この教材のきっかけとなったBFF経験

解説図:外部変更と変換の限界を図で見る

この教材で説明できるようにしたいこと

Section titled “この教材で説明できるようにしたいこと”

「HTTPやDBの変更から何を守り、そのために何を余分に書くのか」をコードで説明する。HexagonalやCleanという名称だけで設計の良し悪しは決まらない。設計レビューでは、依存先、変更の波及範囲、テストで確認できる境界、運用上の失敗を具体的に話せることを目指す。

このページはLearn用の詳しい教材。Practice / Interviewでは、末尾の問いを一問ずつ出し、回答を待つ。教材追加やコード検証は本人の学習実績ではない。

読み方は「例の仕様 → 素朴な3層 → 依存性逆転 → 実行時の流れ → Cleanとの対応 → 採用判断 → 運用」。まず図を見てからコードを追ってもよい。

演習上の追加条件:以下は架空の見積API。過去プロジェクトの再現ではない。

  • GET /quote?sku=book&member=trueを受け、商品Catalog APIから価格を取得する。
  • 会員は10%引き。金額は整数円で、割引額の端数を切り捨てる。1,000円なら900円、999円なら900円。
  • Catalog APIは{"product_code":"book","list_price_yen":1000}を返す。
  • 見積APIは{"sku":"book","amount_yen":900}を返す。
  • Catalogに商品がなければ404。壊れた応答や上流障害は502。deadline超過は504。

member=trueは説明を短くする演習用入力。本番の割引資格を利用者の自己申告から信用する設計ではない。実用時は認証済みの主体と信頼できる会員情報から導く。この例は読み取りだけで、決済・在庫更新・DB・公開サーバーは作らない。

2. 出発点:素朴な3層でも責務は分けられる

Section titled “2. 出発点:素朴な3層でも責務は分けられる”

図A:3層のソース依存

HTTP Handlerが入力を解釈し、Serviceが見積を作り、Catalog Clientが外部HTTPを担当する。まずは十分自然な構成だ。以下は比較用の抜粋で、実行用labには含めない。

// package quote(依存性逆転前の比較例)
import "example.com/shop/internal/cataloghttp"
type Service struct {
catalog *cataloghttp.Client // 外側の具体実装を知っている
}
func (s *Service) Execute(ctx context.Context, sku string) (int64, error) {
dto, err := s.catalog.Fetch(ctx, sku)
if err != nil { return 0, err }
return dto.ListPriceYen, nil // 外部APIの型もここへ入ってくる
}

この構成は「3層だから悪い」わけではない。単純なCRUDや薄い集約で、変更箇所が局所的なら読みやすさが勝ることもある。ただしServiceはcataloghttpをimportしている。外側のDTOや生成clientの型が変わるとServiceの変更が必要になりやすい。具体clientの置き換えにはその型が提供する仕組みを使う必要がある。

具体clientでもHTTP transportを差し替えてテストできるため、「interfaceがないと一切テストできない」は言い過ぎ。問いは、見積の業務ルールだけを試すときに、HTTP応答の準備まで必要でよいか、である。

また、3層は責務の区切り方、Hexagonal/Cleanは内外と依存の規則を強調する。3層でも依存性を逆転できるため、互いに完全な別物ではない。

3. Hexagonal:使う側が必要な契約を決める

Section titled “3. Hexagonal:使う側が必要な契約を決める”

図B:依存性逆転後のソース依存

図Bの矢印はソースコードの依存quotecataloghttpをimportしない。逆にcataloghttpquoteの型・契約を参照する。

Hexagonal / Ports and Adaptersは、アプリの内側と外部の入出力技術を分ける考え方だ。Portはアプリが提供・要求する対話の契約、Adapterはその契約と具体技術の接続役。UIの代わりにテストから駆動する、外部APIの代わりにfakeを接続する、といった組み替えを可能にする。六角形の六つの頂点や六つの層を実装する規則ではない。Cockburn原論文(2005、v0.9)

今回の見積処理は「商品情報が欲しい」のであり、「HTTPでJSONをdecodeしたい」のではない。そこで、見積を作る側のpackageに小さな契約を置く。

internal/quote/service.go完全なソース):

package quote
import (
"context"
"errors"
"fmt"
"strings"
"example.com/architecturelab/internal/domain"
)
var (
ErrInvalidSKU = errors.New("invalid sku")
ErrNotFound = errors.New("product not found")
)
// Product is the information this use case needs, not an upstream JSON DTO.
type Product struct {
SKU string
ListYen int64
}
// Catalog is an outgoing port owned by its consumer.
// Contract: missing products return an error wrapping ErrNotFound;
// implementations must propagate the caller's cancellation/deadline.
type Catalog interface {
Lookup(context.Context, string) (Product, error)
}
type Input struct {
SKU string
Member bool
}
type Result struct {
SKU string
PayYen int64
}
type Service struct{ catalog Catalog }
func New(c Catalog) *Service { return &Service{catalog: c} }
// Execute is the incoming application operation. It needs no HTTP types.
func (s *Service) Execute(ctx context.Context, in Input) (Result, error) {
if strings.TrimSpace(in.SKU) == "" {
return Result{}, ErrInvalidSKU
}
p, err := s.catalog.Lookup(ctx, in.SKU)
if err != nil {
return Result{}, fmt.Errorf("lookup product: %w", err)
}
amount, err := domain.FinalPrice(p.ListYen, in.Member)
if err != nil {
return Result{}, fmt.Errorf("calculate quote: %w", err)
}
return Result{SKU: p.SKU, PayYen: amount}, nil
}

最初にCatalog、次にExecuteを読む。

Catalog.Lookupの戻り値はアプリ側のProductだ。外部APIのJSON tag、HTTP status、SDKの型はない。Executeは入力を確かめ、商品を取り寄せ、価格ルールを呼び、Resultを返す。この処理順が今回のUse Caseに当たる。

Catalog出力Port(outgoing / driven port)。アプリが外へ要求する操作だから出力と呼ぶ。戻り値に商品データが入ってくることと矛盾しない。「出力」は値が出ていく方向だけを意味していない。

Execute入力Portとしての公開操作。HTTP HandlerやCLI、テストがアプリを駆動する入口になる。ここでは具象*quote.Serviceのメソッドを入口として使い、同じシグネチャのinterfaceを機械的に追加していない。Portの概念とGoのinterface宣言は一対一ではない。

context.Contextを受けるのは、呼び出し元のcancel/deadlineをI/Oへ伝えるため。この教材ではGo標準のcontextをアプリ境界に許容する。HTTPの*http.Requestを内側へ丸ごと渡すこととは分けて考える。Go公式のContext解説(2014-07-29)

4. Adapter:外部の都合をここで翻訳する

Section titled “4. Adapter:外部の都合をここで翻訳する”

次はcataloghttp.Client。同じLookupを実装するが、ここにはHTTPとJSONが登場する。

internal/cataloghttp/client.go完全なソース):

package cataloghttp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"example.com/architecturelab/internal/quote"
)
type Client struct {
BaseURL string
HTTP *http.Client
}
// Compile-time check. Go has no explicit "implements" declaration.
var _ quote.Catalog = (*Client)(nil)
// Only this adapter knows the upstream API's field names.
type productDTO struct {
Code string `json:"product_code"`
Price *int64 `json:"list_price_yen"`
}
func (c *Client) Lookup(ctx context.Context, sku string) (quote.Product, error) {
u := c.BaseURL + "/products?sku=" + url.QueryEscape(sku)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return quote.Product{}, err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return quote.Product{}, fmt.Errorf("catalog request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return quote.Product{}, quote.ErrNotFound
}
if resp.StatusCode != http.StatusOK {
return quote.Product{}, fmt.Errorf("catalog status %d", resp.StatusCode)
}
var dto productDTO
if err := json.NewDecoder(resp.Body).Decode(&dto); err != nil {
return quote.Product{}, fmt.Errorf("catalog decode: %w", err)
}
if dto.Code != sku || dto.Price == nil || *dto.Price < 0 {
return quote.Product{}, fmt.Errorf("invalid catalog product")
}
return quote.Product{SKU: dto.Code, ListYen: *dto.Price}, nil
}

追う箇所は三つある。

  1. import .../quote:外側が内側へ依存している。
  2. productDTOquote.Product:外部field名をアプリが必要な形へ変換する。
  3. HTTP 404 → quote.ErrNotFound:技術表現をアプリ境界の意味へ変換する。

var _ quote.Catalog = (*Client)(nil)はコンパイル時の適合確認。Goではimplements Catalogという宣言は不要で、必要なメソッド集合を満たす型がinterfaceを実装する。この一行がなくても代入できるかはコンパイラが検査する。Effective GoのInterfaces(随時更新、2026-09-07確認)

interfaceをcataloghttpへ置いてServiceからimportするだけでは、内側が外側packageへ依存する状態は残る。interfaceがあれば依存性逆転、ではない。誰が契約を所有し、そのシグネチャに誰の型が出るかを見る。

さらに、メソッドの型が同じでも意味が同じとは限らない。fakeが商品なしでnilを返し、本物がErrNotFoundを返すなら代替として不十分だ。Portのコメントには商品不在の意味とcancel/deadlineの伝播を記載した。順序・単位・副作用・一貫性が重要なら、それも契約として決める。

境界が守るのは、型名だけではない

Section titled “境界が守るのは、型名だけではない”

図Bのimport方向とAdapterのコードを踏まえ、外部変更がどこまで波及するかを比較する。演習上の追加条件:①はCatalogの list_price_yenprice_yen へ改名され、単位・欠損・意味は変わらないケース。②は米ドル建てに変わる別ケース。実行用labのAPIを変更したものではない。

円価格のfield改名ならAdapter変換を直してListYenを維持できる。米ドルへの意味変更は単なる改名では隔離できない。

図を拡大する

①ではDTOの読み取りと変換のテストを直し、内側の Product.ListYen と割引ルールを維持できる。②で10 USDをそのまま ListYen: 10 とすればコンパイルできても意味が壊れる。円へ換算する契約・時点・丸めを定めるか、内側へ通貨の概念を導入するかを判断し、影響するルールとテストも見直す。

interfaceへの型の適合は、この意味の正しさまで証明しない。Go公式のinterface解説(随時更新資料、2026-09-09再確認)。図A/Bはソース依存、図Cは実行時呼び出し、この比較図は変更の波及範囲を示す。変換を一枚挟めば外部変更を全て閉じ込められる、と一般化しない。

5. Clean:業務ルールとUse Caseを区別して読む

Section titled “5. Clean:業務ルールとUse Caseを区別して読む”

Hexagonalは内外と接続点を強調する。Clean Architectureは、より内側の業務ルール、アプリ固有のUse Case、外側の変換やframeworkという区分と、ソース依存を内側へ向ける規則を強調する。原文の四つの円は固定のディレクトリ数ではない。Martin原文(2012-08-13)

今回のコードは次のように対応づけて読める。Hexagonal版とClean版を別々に丸ごと実装する必要はない。

今回のコード Hexagonalからの見方 Cleanからの見方
domain.FinalPrice アプリ内側のルール 最も内側に置く業務ルールの小さな例
quote.Service.Execute アプリを駆動する公開操作 Use Case / Interactor
quote.Catalog アプリが要求する出力Port 外へ出る制御のためのgateway境界
quotehttp.Handler driving / inbound Adapter Controllerと応答変換の役割をまとめたもの
cataloghttp.Client driven / outbound Adapter gateway実装を含む外部API Adapter
net/http、外部Catalog 外部技術 framework / external details
cmd/demo/main.go Adapterを選び接続する場所 最も外側の組み立て場所

EntityはORMのレコードという意味ではない

Section titled “EntityはORMのレコードという意味ではない”

今回の内側のルールは以下だけ。DBにもHTTPにも依存しない。

internal/domain/price.go完全なソース):

package domain
import "errors"
var ErrInvalidPrice = errors.New("invalid price")
// FinalPrice is an exercise rule: a member receives a 10% discount.
// Prices use integer yen; discount fractions are rounded down.
func FinalPrice(listYen int64, member bool) (int64, error) {
if listYen < 0 {
return 0, ErrInvalidPrice
}
if !member {
return listYen, nil
}
return listYen - listYen/10, nil
}

CleanのEntityという語を「DBの各テーブルに対応するstruct」とだけ捉えると混乱しやすい。重要なのは業務上のルールとデータをどこで守るかであり、単純なルールならこのような関数でも説明できる。これはDDDの集約や識別子を持つEntityを一式実装した例ではない。

FinalPriceは価格の計算、Executeは「入力確認→商品取得→価格計算」というアプリの目的を達成する手順を担当する。ただし会員割引が全販売経路の共通規則なのか、今回の画面だけの施策なのかで置き場所は変わる。本教材では前者を演習上の仮定にした。名前だけでdomainへ昇格させない。

6. Controller、Presenter、DTOをコードでつなげる

Section titled “6. Controller、Presenter、DTOをコードでつなげる”

HTTP Handlerは、HTTPのqueryをquote.Inputへ、quote.Resultを応答JSONへ変換する。

internal/quotehttp/handler.go完全なソース):

package quotehttp
import (
"context"
"encoding/json"
"errors"
"net/http"
"example.com/architecturelab/internal/quote"
)
// Input adapter: binds HTTP to the application's public operation.
// A second interface is unnecessary for this small example.
type Handler struct{ Service *quote.Service }
type responseDTO struct {
SKU string `json:"sku"`
AmountYen int64 `json:"amount_yen"`
}
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// EXERCISE ONLY: real membership must come from trusted identity data.
out, err := h.Service.Execute(r.Context(), quote.Input{
SKU: r.URL.Query().Get("sku"),
Member: r.URL.Query().Get("member") == "true",
})
if err != nil {
code, message := http.StatusBadGateway, "catalog unavailable"
switch {
case errors.Is(err, quote.ErrInvalidSKU):
code, message = http.StatusBadRequest, "sku required"
case errors.Is(err, quote.ErrNotFound):
code, message = http.StatusNotFound, "product not found"
case errors.Is(err, context.DeadlineExceeded):
code, message = http.StatusGatewayTimeout, "catalog timeout"
case errors.Is(err, context.Canceled):
return // caller canceled; do not attempt another response
}
http.Error(w, message, code)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(responseDTO{SKU: out.SKU, AmountYen: out.PayYen})
}

この例は同期APIの小さな実装なので、応答変換をHandlerにまとめた。Cleanの図にPresenterがあるからという理由だけで、PresenterOutputBoundaryを追加していない。

複数の表示先に共通のUse Caseを使い、表示方針が独立して複雑になるなら、Presenterを分ける選択がある。Use CaseからPresenterを呼ぶ方式なら、その出力境界は内側で定義し、外側Presenterが実装する。今回のようにUse Caseがデータを返して外側が変換する方式も、内側へHTTP型を漏らさずに済む。

所有者と意味 今回のfield
productDTO 外部APIの応答契約を読むAdapter product_code / list_price_yen
quote.Product 見積処理が必要とする商品情報 SKU / ListYen
quote.Input 見積を実行するための入力 SKU / Member
quote.Result 見積処理の結果 SKU / PayYen
responseDTO 利用側へ返すHTTP契約 sku / amount_yen

外部APIがfield名を変えたらproductDTOとmappingを直す。見積APIのJSON名だけを変えるならresponseDTOを直す。業務ルールを変えるならFinalPriceとそのテストを直す。変更理由が異なるので境界を作った、という説明になる。

ただし意味の変更は隠せない。外部価格が税込から税抜へ変わった場合、field名の変換だけでは同じ契約を維持できない。interfaceは意味の互換性を自動保証しない。

この規模で全てを分けるのは学習用に境界を見せる目的もある。実用時にInputとHTTP requestの形が同じで変化も同時なら共用を検討できる。逆に、ORM型や巨大な生成DTOをそのまま広げると、永続化・外部契約の変更が内側へ侵入しやすい。型数の少なさと独立性のどちらを取るか、変更実態で判断する。

7. 実行時は外へ呼ぶのに、なぜ依存は内向きなのか

Section titled “7. 実行時は外へ呼ぶのに、なぜ依存は内向きなのか”

図C:実行時の呼び出しを図Bと見比べる。

図CではServiceがCatalog Clientを呼んでいる。これは実際に必要な商品情報を取りに行く処理。一方でServiceのソースはCatalogという契約しか知らず、Clientという実装名を知らない。実装を入れるのはmainだ。

cmd/demo/main.go完全なソース):

// This demo sends one request in memory; it starts no HTTP listener.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"time"
"example.com/architecturelab/internal/cataloghttp"
"example.com/architecturelab/internal/quote"
"example.com/architecturelab/internal/quotehttp"
)
func main() {
// Composition root: the outermost place knows concrete implementations.
client := &cataloghttp.Client{
BaseURL: os.Getenv("CATALOG_URL"),
HTTP: &http.Client{Timeout: 2 * time.Second},
}
svc := quote.New(client)
handler := quotehttp.Handler{Service: svc}
request := httptest.NewRequest(http.MethodGet, "/quote?sku=book&member=true", nil)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
fmt.Printf("status=%d body=%s", recorder.Code, recorder.Body.String())
}

quote.New(client)で具体的なClientをinterfaceの引数へ渡す。この引き渡しがDependency Injection(DI)。DI containerやreflectionは必要ない。

実行時のinterface値は具体的なClientを保持し、s.catalog.Lookup(...)がその実装へ到達する。別プロセスを経由したりHTTP通信が追加されたりするわけではない。外部HTTPが発生するのはClientがCatalog APIを呼ぶ箇所だけだ。

DIP(依存の方向に関する原則)とDI(依存を外から渡す方法)は区別する。New(client *cataloghttp.Client)でもDIだが、Serviceが外側の具体型へ依存している点は変わらない。

mainが内外の両方を知るのは問題ではない。組み立ての知識を外側に集めるのが意図。アプリがグローバルなcontainerから依存を取得する方式では、必要な依存が見えにくくなる。

8. テストで何が楽になり、何は残るか

Section titled “8. テストで何が楽になり、何は残るか”

次のテストはHTTPもJSONも準備せず、会員の見積結果を確認する。

internal/quote/service_test.go完全なソース):

package quote_test
import (
"context"
"errors"
"testing"
"example.com/architecturelab/internal/quote"
)
type stubCatalog struct {
product quote.Product
err error
calls int
}
func (s *stubCatalog) Lookup(context.Context, string) (quote.Product, error) {
s.calls++
return s.product, s.err
}
func TestQuoteWithoutHTTP(t *testing.T) {
catalog := &stubCatalog{product: quote.Product{SKU: "book", ListYen: 1000}}
svc := quote.New(catalog)
got, err := svc.Execute(context.Background(), quote.Input{SKU: "book", Member: true})
if err != nil || got.PayYen != 900 {
t.Fatalf("got=%+v err=%v", got, err)
}
}
func TestInvalidInputDoesNotCallCatalog(t *testing.T) {
catalog := &stubCatalog{}
_, err := quote.New(catalog).Execute(context.Background(), quote.Input{SKU: " "})
if !errors.Is(err, quote.ErrInvalidSKU) || catalog.calls != 0 {
t.Fatalf("err=%v calls=%d", err, catalog.calls)
}
}
func TestMissingProductPreservesMeaning(t *testing.T) {
catalog := &stubCatalog{err: quote.ErrNotFound}
_, err := quote.New(catalog).Execute(context.Background(), quote.Input{SKU: "missing"})
if !errors.Is(err, quote.ErrNotFound) {
t.Fatal(err)
}
}

stubCatalogはCatalogの契約を満たすだけ。Use Caseは本物かfakeかを知らない。純粋な価格計算はdomainのテスト、商品取得と計算の組み合わせはUse Caseのテストで分けて確認できる。

これで本物のAPIのfield名やheaderが正しいと証明できたわけではない。labのcataloghttp/client_test.goはfake HTTP transportを使い、上流DTO→アプリ型→公開応答の変換、404、壊れたJSON、価格欠損、SKU不一致、cancelの伝播を確認する。ネットワークは使わないため、実APIやproxyとの接続保証は別に必要になる。

検証の場所 主に確認するもの これだけでは確認できないもの
Domain unit test 端数、負数、会員/非会員の価格規則 HTTPや実データ
Use Case + stub 入力、取得失敗の意味、処理の組み合わせ 本物Adapterの契約違反
Adapter + fake HTTP JSON・status・型変換・context 実APIのschema、TLS、proxy、接続pool
実依存とのintegration/contract test 実際の入出力・互換性 本番固有条件を含む全ケース
End-to-end / 段階展開 重要経路と環境をまたぐ挙動 全入力や全障害の網羅

外側をfakeへ置き換えるテストと、外側が本物の契約を満たすテストは両方必要だ。層分けをしてもheader伝播の見落としは起こり得る。

ソース一式。外部Go moduleは使わず、Go 1.21の言語・標準APIを対象とする。学習用であり本番雛形ではない。

ターミナルウィンドウ
cd labs/architecture-boundaries
go test ./...
go list -f '{{.ImportPath}} -> {{join .Imports ", "}}' ./internal/...

後者で、quotecataloghttpquotehttpをimportしていないことを確認できる。internalは外部からのimport範囲を制限するGoの仕組みだが、同じアプリ内部の依存方向まで自動で強制しない。Go公式のmodule構成資料(2026-09-07確認)

cmd/demoは組み立てを示す補助コード。HTTP listenerを起動せず、CATALOG_URLに指定した別のCatalogへ一回アクセスして結果を表示する。テスト実行にはこの環境変数も外部サービスも不要。詳細と検証環境はlabのREADMEに記載する。

9. どこまで採用するか:Default → Why → Trade-off → Exception

Section titled “9. どこまで採用するか:Default → Why → Trade-off → Exception”

Defaultは、小さな責務の分離から始め、実際に隔離したい変更や検証境界にだけ抽象化を入れること。この教材の判断指針であり、「全てのWebアプリがこの構成であるべき」という規格ではない。

条件 初案 利益と代償
単純なCRUD、少人数、業務規則が薄い 少数のpackageと明確なhandler/service責務 追いやすい。後で必要な境界を抽出する手間は残る
外部APIの変更が多く、集約ルールを単独テストしたい 利用側所有の小さいPortとAdapter 外部DTOの波及を抑える。mapping・契約テストが増える
重要な業務規則をHTTP/CLI/workerで共用 Use Caseとdomainを入口から分離 入口を追加しやすい。認可やtransactionの責任を明確にする必要がある
複雑なdomainがある Cleanの区分を手掛かりにルールと手順を分離 変更理由が見える。過剰な層と重複モデルを避ける判断が必要
画面専用の薄いBFF backend clientとhandler中心でもよい 同じ型を何度も写す負担を避ける。共通の認可・error変換を迂回しない

「将来DBを交換できる」は利益の一つになり得るが、未定の交換だけを理由に全アクセスを巨大なRepositoryへ押し込まない。今の外部障害を再現しやすい、価格規則をHTTPなしで試せる、といった具体的な必要性のほうが説明しやすい。

interfaceを全structに一つずつ作る、Get/Save/Deleteを全Entityに揃える、一行転送のUse Caseを大量に作る、ORMを隠すためだけに独自query言語を作る、といった形は目的を再確認する。抽象化を小さく保つには、提供側の全機能を写すのでなく、利用側が必要な操作を宣言する。

Hexagonal/Cleanとモノリス/マイクロサービスは別の軸だ。このlabは一つのプロセス内のpackage境界であり、内向きの依存を作るためにサービスを分割する必要はない。サービス化にはネットワーク障害、整合性、deploy、観測の費用が追加される。

10. Productionで境界に持たせる責任

Section titled “10. Productionで境界に持たせる責任”

Handlerのr.Context()をUse Case→Port→HTTP requestへ渡している。組み立て時のclient timeoutは外部待ち時間の上限の一つで、業務全体のdeadlineや各下流への時間配分は別に決める。cancelは協調的な中断で、既に相手側で完了した更新を取り消す仕組みではない。Go公式の処理中断資料(2026-09-07確認)

低水準の接続条件はAdapterに置けるが、失敗時に部分結果を返すか、処理全体をやり直せるかはUse Caseの意味に関わる。AdapterとUse Caseの両方が無計画にretryすると試行回数が増える。予算・冪等性・責任者を決める。このlabはretryも並列化も実装していない。

Transactionはinterfaceを分けるだけでは作れない

Section titled “Transactionはinterfaceを分けるだけでは作れない”

演習上の追加条件:注文保存と在庫減算を一つのDBで原子的に行いたいとする。 二つのRepositoryを呼んでも、それぞれ独立にcommitすれば一体のtransactionではない。原子的にしたい範囲をUse Caseで決め、同じtransactionに結び付いた操作をAdapterが提供する必要がある。

小さなアプリならPlaceOrderのような意味のある一操作をPortにして内部でtransactionを完結させる案もある。複数操作を組み合わせる必要があるならtransaction内の依存を渡すUnit of Work等を検討する。どちらも境界と抽象化の費用を持つ。*sql.TxをUse Caseへ直接渡す案は単純だが、database/sqlへの依存を受け入れる判断になる。

Goでtransactionを使う場合はsql.Txを通じて操作し、途中で通常のsql.DB呼び出しを混ぜると意図したtransactionの外へ出得る。Go公式のtransaction資料(2026-09-07確認)。この教材でDB実験は行っていない。

認証処理を入口で行っても、「この主体がこの注文を変更してよいか」という認可が別入口から迂回されないかを考える。HTTP Handlerだけに重要な業務条件を置くと、CLI/worker追加で抜けることがある。

traceはHandler→Use Case→外部Adapterを同じcontextでつなぎ、遅い時間が業務処理か外部待ちか分かるようにする。内側にDatadog固有型等を必須にするか、外側で計測するかも依存の判断だ。抽象化を増やしても、APIのstatus/headerや契約上のエラーの意味を消してはいけない。

labのerror mappingは小さな例で、すべての失敗分類を表してはいない。本番では内部不具合と上流障害の区別、応答書き込みエラー、最大bodyサイズ、認証、接続設定、ログ/trace、公開serverのtimeout等も設計する。

11. Troubleshooting:層名でなく具体的な境界を見る

Section titled “11. Troubleshooting:層名でなく具体的な境界を見る”
観測 次に見る場所 設計上の問い
unit testは成功、実APIで値がゼロ upstream DTO・欠損検査・変換 fakeが実際の欠損/nullを再現しているか
HTTP schema変更でUse Caseまで大量修正 importと型の所有者 外部DTO/生成型がPortに漏れていないか
timeout後も下流呼び出しが続く context伝播、client/driver対応 AdapterでBackgroundへ置き換えていないか
controllerから直接呼ぶ経路だけ認可漏れ 共通の業務条件を置いた場所 省略した層だけが認可を持っていないか
変更に多くの一行転送ファイルを触る 層ごとの独立した責務 同じ理由で必ず変わる境界を分けすぎていないか
fakeでは成功、実DBで整合性が崩れる transaction・制約・並行実行 Portのシグネチャにない意味を思い込んでいないか

12. 自分の言葉で議論するための問い

Section titled “12. 自分の言葉で議論するための問い”

Practice / Interviewでは一度に一問だけ。ここには模範解答を並べず、実際の回答後に掘り下げる。

  1. 図Bと図Cで矢印が違う理由を、Catalogquote.New(client)を使って説明してください。
  2. Catalogcataloghttppackageへ移したら、何が変わりますか。
  3. 外部APIにfield追加がある場合と、価格の税込/税抜の意味が変わる場合で、どこまで変更を隔離できますか。
  4. 薄いBFFでdomain packageやPresenterを省くなら、どの責務は残しますか。
  5. fakeのテストが全て成功したのに本番のheaderが壊れました。どの検証境界を補いますか。
  6. 別のCLI入口を追加するとき、再利用するものと入口ごとに実装するものは何ですか。

説明の型は「今回の変更・検証上の問題 → 守りたいルール → Portの所有者と型 → 具体Adapter → 増える費用 → この構成を採用しない条件」。用語の知名度や流行の断定より、この判断をコードで示すことを重視する。

出典・対象バージョン・補強の経緯

Section titled “出典・対象バージョン・補強の経緯”

2026-09-07の追加依頼「文字だけではイメージしにくい。コードと図で一般論を詳しく議論できる教材」を受けて作成。きっかけは2026-09-07-experience-bff-migration-01のarchitecture補強依頼。私的な経験を一般例に転用せず、架空の見積APIを使った。

設計原典はCockburn 2005 v0.9、Martin 2012記事。言語例はGo 1.21、標準ライブラリのみ。Go公式の随時更新資料は2026-09-07に確認し、この例はGo 1.21.6で検証した。新しい言語機能や当時の実プロジェクトのバージョンは推測しない。コード・配置・採用判断は原典の転載でなく、この教材の具体例と解釈である。

図のJSON正本:変更前依存性逆転後実行時。固定revisionのArchify公開CLI deliverでHTMLを生成する。

図表改善(2026-09-09):説明用SVGはSVG自体が正本。前提と判断は本文、状態・比較は図、通信順序・依存関係は既存図、実測は元の記録を参照する。教材編集を新しい学習実績にはしない。