Building a Go API with Clean Architecture Without Overengineering
A practical take on Clean Architecture in Go: handler/service/repository separation, dependency direction, and knowing when to stop adding abstractions.
The problem
"Clean Architecture" gets blamed for two opposite failures. Skip it entirely
and a service ends up with SQL queries inside HTTP handlers, business rules
scattered across three files, and a test suite that can't run without a
live database. Apply it dogmatically and you get five interfaces per
concept, a UseCase struct that just calls one repository method, and a
junior engineer who needs four files open to understand what POST /orders actually does.
Both are real production outcomes. The goal here isn't "implement Clean Architecture" — it's using the one idea from it that actually pays for itself in a Go service, and skipping the rest.
Why it matters
The dependency direction is the part worth keeping. Everything else — use-case interactors, entity/boundary/gateway terminology, strict layer counts — is optional ceremony that Go's simplicity doesn't reward.
A service without a clear dependency direction tends to fail in the same few ways:
- Business rules leak into handlers. A discount calculation or a
authorization check gets written directly in
http.HandlerFuncbecause it's convenient, and now it can only be tested through an HTTP request. - The database becomes the API's shape. Structs generated from SQL rows
get passed straight to
json.Marshal, so a column rename becomes a breaking API change. - Nothing is testable without infrastructure. If the service layer
imports
*pgxpool.Pooldirectly instead of an interface, every unit test needs a running PostgreSQL instance.
The dependency rule
The one rule worth enforcing: dependencies point inward, toward business logic — never the other way.
Rendering diagram…
The service package defines what it needs as a small interface. The
repository package provides how it's done against PostgreSQL. The service
never imports pgx; the handler never imports SQL. This is the same
principle already used throughout Building a Production-Ready REST API
with Go and the
GoBackend Starter — this article is about
why that shape holds up, and where people take it too far.
A practical example: order creation
Three layers, one direction, no more:
// internal/model/order.go — plain data, no framework types
package model
type Order struct {
ID string
CustomerID string
Items []OrderItem
TotalCents int64
Status string
}
type OrderItem struct {
SKU string
Qty int
}// internal/service/order.go — business logic, depends on an interface
package service
import (
"context"
"fmt"
"github.com/gobackend-dev/example/internal/model"
)
// OrderRepository is defined here, at the point of use, not next to the
// PostgreSQL implementation. The service owns the contract.
type OrderRepository interface {
Create(ctx context.Context, o model.Order) (model.Order, error)
GetByID(ctx context.Context, id string) (model.Order, error)
}
type OrderService struct {
repo OrderRepository
}
func NewOrderService(repo OrderRepository) *OrderService {
return &OrderService{repo: repo}
}
func (s *OrderService) PlaceOrder(ctx context.Context, customerID string, items []model.OrderItem) (model.Order, error) {
if len(items) == 0 {
return model.Order{}, fmt.Errorf("order must have at least one item")
}
order := model.Order{
CustomerID: customerID,
Items: items,
TotalCents: calculateTotal(items),
Status: "pending",
}
created, err := s.repo.Create(ctx, order)
if err != nil {
return model.Order{}, fmt.Errorf("create order: %w", err)
}
return created, nil
}
func calculateTotal(items []model.OrderItem) int64 {
var total int64
for _, item := range items {
total += priceFor(item.SKU) * int64(item.Qty)
}
return total
}// internal/repository/order.go — the only file that knows about SQL
package repository
import (
"context"
"github.com/gobackend-dev/example/internal/model"
"github.com/jackc/pgx/v5/pgxpool"
)
type PostgresOrderRepository struct {
db *pgxpool.Pool
}
func NewPostgresOrderRepository(db *pgxpool.Pool) *PostgresOrderRepository {
return &PostgresOrderRepository{db: db}
}
func (r *PostgresOrderRepository) Create(ctx context.Context, o model.Order) (model.Order, error) {
const query = `
INSERT INTO orders (customer_id, total_cents, status)
VALUES ($1, $2, $3)
RETURNING id
`
err := r.db.QueryRow(ctx, query, o.CustomerID, o.TotalCents, o.Status).Scan(&o.ID)
return o, err
}// internal/handler/order.go — HTTP only, no business rules
package handler
func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
order, err := h.orders.PlaceOrder(r.Context(), req.CustomerID, req.Items)
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(order)
}Four files, one direction: handler → service → repository interface ← repository implementation. main.go is the only place that wires the
concrete PostgresOrderRepository into NewOrderService — everything else
only ever sees the interface.
Three versions of the same API
This is the comparison worth internalizing: the difference between "no architecture" and "practical architecture" is one interface boundary. The difference between "practical" and "over-engineered" is usually five or six extra types that don't change what the code does.
No architecture. The handler calls db.QueryRow directly. Fast to
write, fails the moment you need a second consumer (a background job, a
gRPC endpoint, a test) of the same logic.
Practical architecture (above). Handler → service → repository interface. Three files, each independently testable, each with one reason to change.
Over-engineered architecture. A CreateOrderUseCase struct that wraps
OrderService and does nothing else. A OrderPresenter interface between
the handler and the use case that only ever has one implementation. An
OrderRepositoryFactory because "we might swap databases" — a decision
that, in practice, essentially never happens after a service ships, and
when it does, it involves rewriting the queries anyway, not swapping a
factory.
The tell that architecture has gone too far: an interface with exactly one implementation, that will only ever have one implementation, existing solely to satisfy a layering rule rather than a real need (a second implementation, or a test that needs to fake it).
Where interfaces belong
Define interfaces at the point of consumption, not next to their
implementation. OrderRepository lives in internal/service, because
that's who needs it — not in internal/repository, next to
PostgresOrderRepository. This is the same guidance from Go Interfaces:
Design Small, Testable Components, and it's what
makes the dependency rule enforceable by the Go compiler: internal/service
never imports internal/repository, so it can't reach for pgx even by
accident.
Testing benefits
The payoff shows up immediately in tests — OrderService can be tested
with a hand-written fake, no database required:
type fakeOrderRepo struct {
created model.Order
}
func (f *fakeOrderRepo) Create(_ context.Context, o model.Order) (model.Order, error) {
o.ID = "order_test_1"
f.created = o
return o, nil
}
func (f *fakeOrderRepo) GetByID(_ context.Context, id string) (model.Order, error) {
return f.created, nil
}
func TestOrderService_PlaceOrder_RejectsEmptyOrder(t *testing.T) {
svc := NewOrderService(&fakeOrderRepo{})
_, err := svc.PlaceOrder(context.Background(), "cust_1", nil)
if err == nil {
t.Fatal("expected an error for an empty order")
}
}
func TestOrderService_PlaceOrder_CalculatesTotal(t *testing.T) {
repo := &fakeOrderRepo{}
svc := NewOrderService(repo)
_, err := svc.PlaceOrder(context.Background(), "cust_1", []model.OrderItem{
{SKU: "sku_1", Qty: 2},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if repo.created.TotalCents != priceFor("sku_1")*2 {
t.Fatalf("expected total %d, got %d", priceFor("sku_1")*2, repo.created.TotalCents)
}
}No test container, no migrations, no network — the whole business-logic test suite runs in milliseconds. That speed is what actually keeps a team writing tests; a suite that takes 40 seconds to spin up a database connection per package gets skipped locally and only run in CI.
Production considerations
The repository layer still needs its own tests — against a real
PostgreSQL instance, verifying the actual SQL. A fake repository proves the
service's logic is correct; it says nothing about whether the INSERT
statement compiles against the real schema. See Testing Go Backend
Applications for the integration-test side of
this.
main.go is where the graph gets wired, and it should stay boring:
pool := mustConnect(ctx, cfg.DatabaseURL)
orderRepo := repository.NewPostgresOrderRepository(pool)
orderService := service.NewOrderService(orderRepo)
orderHandler := handler.NewOrderHandler(orderService)If wiring this by hand starts feeling unwieldy, that's a signal the service
has grown past what a single main.go should own — split it into multiple
services, don't reach for a DI framework to paper over it.
Errors should be translated once, not layer by layer. Let the service
return domain errors (ErrOrderNotFound, a validation error type); map them
to HTTP status codes in one place in the handler layer, the way
Building a Production-Ready REST API with Go
does it. Don't wrap the same error at every layer just because it crossed a
boundary.
Common mistakes
- Interfaces with one real implementation and no test double. If nothing ever substitutes a fake for it, the interface is pure overhead — a concrete type would compile faster and read just as clearly.
- Anemic services. A
UserService.GetUserthat does nothing but callrepo.GetUserand return it isn't a layer, it's a detour. Business logic belongs in the service; if there isn't any yet for that operation, the handler can call the repository directly until there is. - Framework-shaped structs leaking into the domain. A
model.Orderthat embedssql.NullStringorgin.Contextties business logic to infrastructure that has nothing to do with placing an order. - Premature multi-database abstraction. Designing the repository interface to theoretically support MySQL and PostgreSQL and MongoDB before a second database is a real requirement — this is the single biggest source of accidental complexity in "clean" Go codebases.
When to stop adding abstractions
Stop at the point where every additional interface, wrapper, or layer exists to satisfy a rule rather than a concrete need you have right now: a second implementation that actually exists, a test that actually needs a fake, or a team boundary that actually needs a stable contract. A Go service with a clean dependency direction, one interface per external dependency, and business logic that doesn't import a database driver has already captured the value Clean Architecture is trying to sell — the remaining ceremony is a cost with no matching benefit.
Summary
The dependency rule — inward toward business logic, never outward toward
infrastructure — is the part of Clean Architecture worth keeping in Go.
Implement it as three layers (handler, service with interfaces it defines
itself, repository), wire the concrete types once in main.go, and resist
adding a layer or interface unless it's solving a problem you actually
have. That's enough to get fast, infrastructure-free unit tests and a
codebase where a database change doesn't ripple through the API — which is
the entire point.
funcRelated()[]Article
Building a Production-Ready REST API with Go
A clean, minimal-abstraction project structure for production Go REST APIs: routing, configuration, handlers, services, repositories, validation, error handling, logging, middleware, and health checks.
Go Microservices: A Practical Project Structure
A practical, minimal project structure for Go microservices: service boundaries, internal packages, configuration, migrations, and Docker — without over-engineering.
Go Interfaces: Design Small, Testable Components
Design small, focused Go interfaces for dependency injection, mocking, and testable backend code, with realistic service and repository examples.