"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/wireis 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
| 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).
// 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.NewSetto group providers andwire.Bindto 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
domainstarts wanting to importinfra, 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
controllerand don't leak them todomain(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
- Dependencies only point inward. Domain/UseCase know neither HTTP nor DB.
- Place interfaces on the consumer side (UseCase) = make implementations swappable with dependency inversion.
- The Echo handler is a thin Controller. Only Bind/Validate → delegate → JSON.
- Wiring is compile-time DI with google/wire. Erase the new hell and nil injection.
- Split layers by package with
internal/and enforce the dependency direction at boundaries. - A circular import is a sign of a design mistake. Fix it to go via an interface.
- 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.