「最初は綺麗だったのに、半年で誰も触りたくないコードになった」——その分岐点は、たいていハンドラにビジネスロジックを書き始めた瞬間です。Echo ハンドラに DB 呼び出しと業務ルールと HTTP の体裁が混ざると、テストは書けず、変更は怖くなり、変更の影響が読めなくなります。
この記事は、Go Echo 本番運用ガイドのアーキテクチャ編です。私が実際に Go/Echo + google/wire でバックエンドを構築した飲食店マッチングサイトで採った、**クリーンアーキテクチャ + 依存性注入(DI)**の設計を、「なぜそうするか」から実装まで解説します。目的は流行りの構造を真似ることではなく、変更とテストに強いコードベースを作ることです。
この記事のルール:Echo の API は 公式ドキュメント(v5・2026年6月時点) に基づきます。
google/wireは更新されるため公式で最新を確認してください。本記事は設計指針が中心で、過剰設計(over-engineering)を避ける線引き(第7章)まで含めて、実プロジェクトで使える形にしています。
1. たった一つの原則:依存は内向きにしか向かない
クリーンアーキテクチャの本質は、難しい図ではなく1つのルールです。
依存の方向は、常に外側から内側へ。内側は外側を知らない。
層を内側から並べると:
[ Domain ] ← 業務の中核(エンティティ・ビジネスルール)。何にも依存しない
↑
[ UseCase ] ← アプリのユースケース。Domain に依存。HTTP も DB も知らない
↑
[ Controller(Echo) ] / [ Repository(pgx) ] ← 外側。UseCase に依存する
- Domain / UseCase(内側):HTTP・DB・Echo・pgx を一切 import しない。純粋な Go。
- Controller / Repository(外側):フレームワークやドライバを知る、付け替え可能な「詳細」。
この向きさえ守れば、「Web フレームワークを Echo から変える」「DB を PostgreSQL から変える」が内側に波及しません。Echo はあくまで最も外側の詳細であって、アプリの中心ではない——これが設計の出発点です。
2. 層の責務:4つのレイヤーを1分で
| 層 | 責務 | 知ってよいもの | 例 |
|---|---|---|---|
| Domain | エンティティ・不変条件 | 何も(純粋 Go) | User, Order, ドメインエラー |
| UseCase | ユースケースの手順 | Domain と Repository のinterface | 「ユーザー登録」「注文確定」 |
| Repository | 永続化の実装 | DB ドライバ(pgx 等) | pgUserRepository |
| Controller | HTTP 入出力 | Echo・UseCase | Echo ハンドラ |
ポイントは、UseCase が Repository の「インターフェース」にだけ依存し、その実装(pgx)を知らないことです。次章の「依存性逆転」がこれを成立させます。
3. 依存性逆転:インターフェースは「利用側」に置く
初学者がつまずく最大のポイントがここです。Repository のインターフェースは、実装側(インフラ)ではなく、利用側(UseCase/Domain)に置きます。
// domain/user.go ── 内側がインターフェースを「要求」する
package domain
type User struct {
ID string
Name string
Email string
}
// UseCase が必要とする操作を、内側で定義する(実装は知らない)
type UserRepository interface {
FindByID(ctx context.Context, id string) (*User, error)
Create(ctx context.Context, u *User) error
}
// usecase/user_usecase.go ── インターフェースにのみ依存
package usecase
type UserUseCase struct {
repo domain.UserRepository // ← 具象 pgx ではなく interface
}
func NewUserUseCase(repo domain.UserRepository) *UserUseCase {
return &UserUseCase{repo: repo}
}
func (uc *UserUseCase) Register(ctx context.Context, name, email string) (*domain.User, error) {
// ここは純粋な業務ロジックだけ。HTTP も SQL も無い
if email == "" {
return nil, domain.ErrInvalidEmail
}
u := &domain.User{ID: newID(), Name: name, Email: email}
if err := uc.repo.Create(ctx, u); err != nil {
return nil, err
}
return u, nil
}
// infra/pg_user_repository.go ── 外側が内側の interface を「実装」する
package infra
type pgUserRepository struct {
pool *pgxpool.Pool
}
// 戻り値の型は domain.UserRepository(依存性逆転の成立点)
func NewUserRepository(pool *pgxpool.Pool) domain.UserRepository {
return &pgUserRepository{pool: pool}
}
矢印が反転していることに注目してください。普通なら「UseCase → pgx」と外向きに依存しそうなところを、interface を内側に置くことで「pgx → UseCase の契約」へと逆転させています。これが Dependency Inversion の名の由来です。実装の詳細はデータベース層の記事で深掘りしています。
4. Controller:Echo ハンドラは「薄い変換器」に保つ
Echo ハンドラ(Controller)の仕事は3つだけ。①入力を Bind/Validate、②UseCase を呼ぶ、③結果を JSON にする。業務ロジックは1行も書きません。
// controller/user_handler.go
package controller
type UserHandler struct {
uc *usecase.UserUseCase // UseCase にのみ依存
}
func NewUserHandler(uc *usecase.UserUseCase) *UserHandler {
return &UserHandler{uc: uc}
}
func (h *UserHandler) Create(c *echo.Context) error {
var dto CreateUserDTO
if err := c.Bind(&dto); err != nil { // ① 入力(境界)
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
if err := c.Validate(&dto); err != nil {
return err
}
user, err := h.uc.Register(c.Request().Context(), dto.Name, dto.Email) // ② 委譲
if err != nil {
return err // 集中エラーハンドラが HTTP に変換
}
return c.JSON(http.StatusCreated, toResponse(user)) // ③ 出力
}
入力検証はバインディング&バリデーション、エラーの HTTP 変換は集中エラーハンドラに寄せることで、Controller は本当に薄く保てます。これが SRP(単一責任)の具体形です。
5. google/wire:依存の配線をコンパイル時に解決する
層を分けると、main での配線(new の連鎖)が増えます。手書きすると、引数の順番ミスや nil 注入が実行時に初めて露見します。google/wire は、この配線をコンパイル時にコード生成で解決します(リフレクションなし=実行時コストゼロ)。
各層に「プロバイダ関数」(NewXxx)を用意し、wire に組み立てを宣言します。
//go:build wireinject
// +build wireinject
// wire.go ── これは「設計図」。wire がここから実体コードを生成する
package main
import "github.com/google/wire"
func InitializeUserHandler(pool *pgxpool.Pool) *controller.UserHandler {
wire.Build(
infra.NewUserRepository, // *pgxpool.Pool → domain.UserRepository
usecase.NewUserUseCase, // domain.UserRepository → *usecase.UserUseCase
controller.NewUserHandler, // *usecase.UserUseCase → *controller.UserHandler
)
return nil // 本体は wire が生成(このreturnはダミー)
}
wire コマンドを実行すると、wire_gen.go に型を辿って正しい順序で組み立てた初期化コードが生成されます。
// wire_gen.go(自動生成)── 手書きの配線がこれに置き換わる
func InitializeUserHandler(pool *pgxpool.Pool) *controller.UserHandler {
userRepository := infra.NewUserRepository(pool)
userUseCase := usecase.NewUserUseCase(userRepository)
userHandler := controller.NewUserHandler(userUseCase)
return userHandler
}
main は生成された関数を呼び、Echo にルートを登録するだけです。
func main() {
pool, _ := infra.NewPool(context.Background(), os.Getenv("DATABASE_URL"))
defer pool.Close()
userHandler := InitializeUserHandler(pool) // wire 生成の配線
e := echo.New()
e.POST("/api/v1/users", userHandler.Create)
// ...グレースフルに起動(StartConfig)
}
wire の利点:依存が増えても設計図に1行足すだけ。型が合わなければ生成時に落ちるので、nil 注入のような実行時バグが原理的に起きません。プロバイダをまとめる
wire.NewSet、インターフェースと実装を結ぶwire.Bindを使えば、機能単位での再利用も効きます。
6. ディレクトリ構成と循環インポート回避
層を物理的にパッケージで分け、依存の向きをパッケージ境界で強制します。
cmd/server/main.go # エントリポイント + wire
internal/
domain/ # エンティティ・interface・ドメインエラー(最内)
usecase/ # ユースケース(domain に依存)
infra/ # pgx 実装(domain の interface を実装)
controller/ # Echo ハンドラ(usecase に依存)
middleware/ # 横断的関心事
internal/に置くと、外部モジュールから import されない(公開境界の明示)。- 循環インポートの回避:「内側は外側を import しない」を守れば循環は起きません。もし
domainがinfraを import したくなったら、設計が間違っているサイン——その依存は interface 経由に直します。 - DTO の置き場所:HTTP の入出力 DTO は
controllerに置き、domainには漏らしません(Web の都合をドメインに持ち込まない)。
7. 過剰にしない:KISS/YAGNI で層は必要になってから
ここが最も重要かつ、語られないポイントです。全アプリにこの4層は要りません。
- 小さな CRUD・短命なツール:Controller から直接 Repository を呼んでよい。UseCase 層が「ただの素通し」なら、それは YAGNI 違反(不要な抽象)です。
- 層を足す判断基準:「同じ業務ロジックを複数の入口(HTTP・バッチ・gRPC)から呼ぶ」「ロジックが分岐して肥大化してきた」「テストで DB を切り離したい」——痛みが出てから層を足します。
- 原則の目的を忘れない:DRY・SRP・依存性逆転は手段であって目的ではありません。目的はETC(Easier To Change)——変更の影響を局所化すること。抽象が変更を難しくしているなら、それは間違った抽象です。
私が飲食店マッチングのバックエンドで4層 + wire を採ったのは、チーム開発でテスタビリティと並行作業を両立する必要があったからです。一人で書く小さな API なら、もっと薄くします。規模と体制に合わせて層を選ぶのが、原則を「正しく」使うということです。
8. 見返り:テスト容易性という最大の報酬
この設計の対価は「ファイル数の増加」、見返りはテスト容易性です。UseCase は interface にしか依存しないので、モックを注入すれば DB なしで全分岐を単体テストできます。
// Repository をモックに差し替え、UseCase をDBなしでテスト
type mockUserRepo struct {
createFn func(ctx context.Context, u *domain.User) error
}
func (m *mockUserRepo) Create(ctx context.Context, u *domain.User) error { return m.createFn(ctx, u) }
func (m *mockUserRepo) FindByID(ctx context.Context, id string) (*domain.User, error) { return nil, nil }
func TestRegister_DuplicateEmail(t *testing.T) {
uc := usecase.NewUserUseCase(&mockUserRepo{
createFn: func(ctx context.Context, u *domain.User) error { return domain.ErrEmailTaken },
})
_, err := uc.Register(context.Background(), "Hinata", "dup@example.com")
assert.ErrorIs(t, err, domain.ErrEmailTaken)
}
DB を立てる統合テストと、こうした高速な単体テストを使い分ける戦略は、Echo のテスト戦略で体系化しています。
まとめ:変更に強いバックエンドの7原則
- 依存は内向きにしか向かわない。Domain/UseCase は HTTP も DB も知らない。
- インターフェースは利用側(UseCase)に置く=依存性逆転で実装を差し替え可能に。
- Echo ハンドラは薄い Controller。Bind/Validate→委譲→JSON だけ。
- 配線は google/wire でコンパイル時 DI。new 地獄と nil 注入を消す。
internal/で層をパッケージ分割し、依存の向きを境界で強制。- 循環インポートは設計ミスのサイン。interface 経由に直す。
- KISS/YAGNI で層は痛みが出てから。原則の目的は ETC(変更の局所化)。
クリーンアーキテクチャは「正しい図を描くこと」ではなく、「変更が来たときに、どれだけ狭い範囲の修正で済むか」で価値が決まります。Echo の薄さは、この設計を Go の言語機能(interface・パッケージ)だけで自然に実現できることの裏返しです。全体像はGo Echo 本番運用ガイドへ。