Goコードで理解するHexagonal・Clean Architecture
この教材で説明できるようにしたいこと
Section titled “この教材で説明できるようにしたいこと”「HTTPやDBの変更から何を守り、そのために何を余分に書くのか」をコードで説明する。HexagonalやCleanという名称だけで設計の良し悪しは決まらない。設計レビューでは、依存先、変更の波及範囲、テストで確認できる境界、運用上の失敗を具体的に話せることを目指す。
このページはLearn用の詳しい教材。Practice / Interviewでは、末尾の問いを一問ずつ出し、回答を待つ。教材追加やコード検証は本人の学習実績ではない。
読み方は「例の仕様 → 素朴な3層 → 依存性逆転 → 実行時の流れ → Cleanとの対応 → 採用判断 → 運用」。まず図を見てからコードを追ってもよい。
1. 同じ小さなAPIで比較する
Section titled “1. 同じ小さなAPIで比較する”演習上の追加条件:以下は架空の見積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層でも責務は分けられる”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の矢印はソースコードの依存。quoteはcataloghttpをimportしない。逆にcataloghttpがquoteの型・契約を参照する。
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}追う箇所は三つある。
import .../quote:外側が内側へ依存している。productDTO→quote.Product:外部field名をアプリが必要な形へ変換する。- 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_yen が price_yen へ改名され、単位・欠損・意味は変わらないケース。②は米ドル建てに変わる別ケース。実行用labのAPIを変更したものではない。
①では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があるからという理由だけで、PresenterとOutputBoundaryを追加していない。
複数の表示先に共通のUse Caseを使い、表示方針が独立して複雑になるなら、Presenterを分ける選択がある。Use CaseからPresenterを呼ぶ方式なら、その出力境界は内側で定義し、外側Presenterが実装する。今回のようにUse Caseがデータを返して外側が変換する方式も、内側へHTTP型を漏らさずに済む。
なぜ似たstructが複数あるのか
Section titled “なぜ似たstructが複数あるのか”| 型 | 所有者と意味 | 今回の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伝播の見落としは起こり得る。
実行できるlab
Section titled “実行できるlab”ソース一式。外部Go moduleは使わず、Go 1.21の言語・標準APIを対象とする。学習用であり本番雛形ではない。
cd labs/architecture-boundariesgo test ./...go list -f '{{.ImportPath}} -> {{join .Imports ", "}}' ./internal/...後者で、quoteがcataloghttpやquotehttpを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で境界に持たせる責任”Timeout・cancel・retry
Section titled “Timeout・cancel・retry”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実験は行っていない。
認可・観測・error
Section titled “認可・観測・error”認証処理を入口で行っても、「この主体がこの注文を変更してよいか」という認可が別入口から迂回されないかを考える。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では一度に一問だけ。ここには模範解答を並べず、実際の回答後に掘り下げる。
- 図Bと図Cで矢印が違う理由を、
Catalogとquote.New(client)を使って説明してください。 Catalogをcataloghttppackageへ移したら、何が変わりますか。- 外部APIにfield追加がある場合と、価格の税込/税抜の意味が変わる場合で、どこまで変更を隔離できますか。
- 薄いBFFでdomain packageやPresenterを省くなら、どの責務は残しますか。
- fakeのテストが全て成功したのに本番のheaderが壊れました。どの検証境界を補いますか。
- 別の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自体が正本。前提と判断は本文、状態・比較は図、通信順序・依存関係は既存図、実測は元の記録を参照する。教材編集を新しい学習実績にはしない。