# 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: 2026-06-28
- Author: 友田 陽大
- Tags: Go, Echo, アーキテクチャ設計, 型安全, テスト, 保守性
- URL: https://tomodahinata.com/en/blog/go-echo-clean-architecture-dependency-injection-google-wire-guide
- Category: Go & Echo in production
- Pillar guide: https://tomodahinata.com/en/blog/go-echo-framework-production-guide

## Key points

- Dependencies always point inward. With Controller→UseCase→Repository (interface), isolate business logic into a pure layer that knows neither HTTP nor DB.
- Place interfaces on the consumer side (UseCase), not the implementation side. This makes the Repository swappable and lets you unit-test the UseCase without a DB.
- The Echo handler is the Controller. Keep it a thin layer that only Bind/Validate, calls the UseCase, and turns the result into JSON.
- Resolve dependency wiring at compile time with google/wire. Erase the hand-written new hell and runtime nil-injection mistakes.
- Four layers are overkill for a small app. With KISS/YAGNI, add layers only when needed. The purpose of the principles is to 'localize the impact of change.'

---

"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](/blog/go-echo-framework-production-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](/case-studies/restaurant-matching), 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](#7-dont-overdo-it-with-kissyagni-add-layers-only-when-needed)), 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

| Layer | Responsibility | May know | Example |
| --- | --- | --- | --- |
| **Domain** | entities, invariants | nothing (pure Go) | `User`, `Order`, domain errors |
| **UseCase** | the steps of a use case | Domain and the Repository's **interface** | "register user," "confirm order" |
| **Repository** | persistence implementation | DB drivers (pgx, etc.) | `pgUserRepository` |
| **Controller** | HTTP I/O | Echo, UseCase | Echo 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).**

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

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

```go
// 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](/blog/go-echo-database-postgresql-pgx-sqlc-gorm-transaction-guide).

---

## 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.

```go
// 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](/blog/go-echo-request-binding-validation-error-handling-guide) and HTTP conversion of errors to the [centralized error handler](/blog/go-echo-request-binding-validation-error-handling-guide#5-エラーハンドリング握りつぶさずreturnし一箇所で整形する), 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
//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`.

```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.

```go
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.**

```text
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)](/blog/spec-driven-development-claude-code-ai-agent-production-workflow) — **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.**

```go
// 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](/blog/go-echo-testing-strategy-httptest-echotest-testcontainers-guide).

---

## 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](/blog/go-echo-framework-production-guide).
