Skip to main content
友田 陽大
Go & Echo in production
Go
Echo
アーキテクチャ設計
型安全
テスト
保守性

Clean architecture + DI (google/wire) with Echo: keep handlers thin and build a backend resilient to change and testing

A guide to implementing clean architecture and dependency injection (google/wire) with Go Echo (v5). With real-project know-how and real code, it explains the Controller/UseCase/Repository layer split, dependency inversion (the inward dependency rule), where to place interfaces, compile-time DI with wire, directory structure, avoiding circular imports, and the KISS/YAGNI line that avoids over-engineering.

Published
Reading time
9 min read
Author
友田 陽大
Share

"It was clean at first, but in half a year it became code nobody wants to touch" — the fork point is usually the moment you start writing business logic in the handler. Mix DB calls, business rules, and HTTP formatting into an Echo handler, and tests can't be written, change becomes scary, and the impact of change becomes unreadable.

This article is the architecture chapter of the Go Echo production-operations guide. It explains the clean architecture + dependency injection (DI) design I actually adopted in the restaurant-matching site whose backend I built with Go/Echo + google/wire, from "why do it this way" to implementation. The purpose isn't to mimic a trendy structure but to build a codebase resilient to change and testing.

Rules for this article: Echo's API is based on the official documentation (v5, as of June 2026). google/wire is updated, so confirm the latest in the official docs. This article centers on design guidance and, including the line that avoids over-engineering (chapter 7), is in a form usable in real projects.


1. Just one principle: dependencies only point inward

The essence of clean architecture is not a difficult diagram but one rule.

The direction of dependency is always from outside to inside. The inside doesn't know the outside.

Lining up the layers from the inside:

[ Domain ]  ← the core of business (entities, business rules). depends on nothing
   ↑
[ UseCase ] ← the app's use cases. depends on Domain. knows neither HTTP nor DB
   ↑
[ Controller(Echo) ] / [ Repository(pgx) ] ← the outside. depends on UseCase
  • Domain / UseCase (inside): import none of HTTP, DB, Echo, or pgx. Pure Go.
  • Controller / Repository (outside): replaceable "details" that know the framework and drivers.

Keep just this direction and "changing the web framework from Echo," "changing the DB from PostgreSQL" don't ripple inward. Echo is, after all, the outermost detail, not the center of the app — this is the starting point of the design.


2. Layer responsibilities: the four layers in one minute

LayerResponsibilityMay knowExample
Domainentities, invariantsnothing (pure Go)User, Order, domain errors
UseCasethe steps of a use caseDomain and the Repository's interface"register user," "confirm order"
Repositorypersistence implementationDB drivers (pgx, etc.)pgUserRepository
ControllerHTTP I/OEcho, UseCaseEcho handler

The point is that the UseCase depends only on the Repository's "interface" and doesn't know its implementation (pgx). The next chapter's "dependency inversion" makes this hold.


3. Dependency inversion: place the interface on the "consumer side"

The biggest point beginners stumble on is here. Place the Repository interface on the consumer side (UseCase/Domain), not the implementation side (infra).

// 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}
}

Note that the arrow is reversed. Where it would normally seem to depend outward as "UseCase → pgx," placing the interface inside reverses it into "pgx → the UseCase's contract." This is the origin of the name Dependency Inversion. The implementation details are dug into in the database-layer article.


4. Controller: keep the Echo handler a "thin converter"

An Echo handler (Controller) has only 3 jobs. ① Bind/Validate the input, ② call the UseCase, ③ turn the result into JSON. It writes no business logic at all.

// 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)) // ③ 出力
}

By leaning input validation to binding & validation and HTTP conversion of errors to the centralized error handler, the Controller can be kept truly thin. This is the concrete form of SRP (single responsibility).


5. google/wire: resolve dependency wiring at compile time

Splitting layers increases the wiring (the chain of new) in main. Hand-writing it exposes argument-order mistakes and nil injection only at runtime. google/wire resolves this wiring with compile-time code generation (no reflection = zero runtime cost).

Prepare a "provider function" (NewXxx) in each layer and declare the assembly to 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はダミー)
}

Run the wire command and initialization code that assembles in the correct order by tracing types is generated in 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 just calls the generated function and registers routes with 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)
}

The advantage of wire: even as dependencies grow, just add a line to the blueprint. If types don't match, it fails at generation time, so runtime bugs like nil injection are impossible in principle. Use wire.NewSet to group providers and wire.Bind to connect an interface to an implementation, and reuse per feature works too.


6. Directory structure and avoiding circular imports

Physically split layers by package and enforce the dependency direction at package boundaries.

cmd/server/main.go          # entry point + wire
internal/
  domain/                   # entities, interfaces, domain errors (innermost)
  usecase/                  # use cases (depends on domain)
  infra/                    # pgx implementation (implements domain's interface)
  controller/               # Echo handlers (depends on usecase)
  middleware/               # cross-cutting concerns
  • Put it in internal/ and it can't be imported from external modules (explicit public boundary).
  • Avoiding circular imports: keep "the inside doesn't import the outside" and cycles don't happen. If domain starts wanting to import infra, that's a sign the design is wrong — fix that dependency to go via an interface.
  • Where to place DTOs: put HTTP I/O DTOs in controller and don't leak them to domain (don't bring web concerns into the domain).

7. Don't overdo it: with KISS/YAGNI, add layers only when needed

This is the most important and least-discussed point. Not every app needs these four layers.

  • Small CRUD, short-lived tools: the Controller may call the Repository directly. If the UseCase layer is "just a pass-through," that's a YAGNI violation (an unnecessary abstraction).
  • The criterion for adding a layer: "call the same business logic from multiple entrances (HTTP, batch, gRPC)," "the logic branched and bloated," "I want to detach the DB in tests" — add a layer after the pain appears.
  • Don't forget the purpose of the principles: DRY, SRP, dependency inversion are means, not ends. The end is ETC (Easier To Change)localizing the impact of change. If an abstraction makes change harder, it's a wrong abstraction.

The reason I adopted 4 layers + wire in the restaurant-matching backend is that, in team development, I needed to balance testability and parallel work. For a small API written solo, I'd make it thinner. Choosing layers to fit the scale and structure is what it means to use the principles "correctly."


8. The payoff: testability, the biggest reward

The price of this design is "an increased number of files," and the payoff is testability. Because the UseCase depends only on an interface, you can inject a mock and unit-test every branch without a 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)
}

The strategy of using DB-standing integration tests and such fast unit tests separately is systematized in Echo's testing strategy.


Conclusion: 7 principles of a change-resilient backend

  1. Dependencies only point inward. Domain/UseCase know neither HTTP nor DB.
  2. Place interfaces on the consumer side (UseCase) = make implementations swappable with dependency inversion.
  3. The Echo handler is a thin Controller. Only Bind/Validate → delegate → JSON.
  4. Wiring is compile-time DI with google/wire. Erase the new hell and nil injection.
  5. Split layers by package with internal/ and enforce the dependency direction at boundaries.
  6. A circular import is a sign of a design mistake. Fix it to go via an interface.
  7. With KISS/YAGNI, add layers after the pain appears. The purpose of the principles is ETC (localizing change).

Clean architecture's value is decided not by "drawing the correct diagram" but by "how narrow a fix suffices when a change comes." Echo's thinness is the flip side of being able to naturally realize this design with just Go's language features (interfaces, packages). For the big picture, head to the Go Echo production-operations guide.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

I build Go / Echo backends, from design to production

API design and migration to Echo v5, clean architecture (Controller/UseCase/Repository + DI), middleware and security, centralized error handling, graceful shutdown, and testing/CI. With experience building a clean-architecture backend in Go/Echo + google/wire, I implement APIs that don't fall over, are traceable, and are easy to change.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading