Skip to content
Backend

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.

GoBackend.dev13 min read
GoArchitectureBackendREST APIClean Architecture

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.HandlerFunc because 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.Pool directly 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.GetUser that does nothing but call repo.GetUser and 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.Order that embeds sql.NullString or gin.Context ties 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.