Building Scalable Go APIs with Clean Architecture
After building dozens of Go services, I’ve settled on a project structure that balances simplicity with scalability. Not hexagonal architecture astronautics — just practical separation that makes code easy to test, modify, and reason about.
The Layer Cake
Three layers, strict dependency direction:
Handler → Service → Repository
(HTTP) (Logic) (Data)
Handlers know about services. Services know about repositories. Nobody looks backwards. Repositories don’t know about HTTP. Services don’t know about request/response formats.
Project Layout
order-service/
├── cmd/
│ └── server/
│ └── main.go # Wiring, config, startup
├── internal/
│ ├── handler/
│ │ └── order.go # HTTP handlers
│ ├── service/
│ │ └── order.go # Business logic
│ ├── repository/
│ │ └── order.go # Database queries
│ ├── model/
│ │ └── order.go # Domain types
│ └── middleware/
│ ├── auth.go
│ └── logging.go
├── pkg/
│ └── httputil/ # Shared HTTP helpers
├── migrations/
└── config/
Handler Layer
Handlers do three things: parse input, call service, write response.
type OrderHandler struct {
service *service.OrderService
}
func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
var input CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
httputil.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if err := input.Validate(); err != nil {
httputil.Error(w, http.StatusBadRequest, err.Error())
return
}
order, err := h.service.Create(r.Context(), input.ToModel())
if err != nil {
handleServiceError(w, err)
return
}
httputil.JSON(w, http.StatusCreated, OrderResponse{}.FromModel(order))
}
No business logic in handlers. No database calls. Just HTTP plumbing.
Service Layer
Services contain business logic and orchestrate repository calls:
type OrderService struct {
repo OrderRepository
inventory InventoryService
events EventPublisher
}
type OrderRepository interface {
Create(ctx context.Context, order model.Order) error
GetByID(ctx context.Context, id string) (*model.Order, error)
ListByCustomer(ctx context.Context, customerID string, limit int) ([]model.Order, error)
}
func (s *OrderService) Create(ctx context.Context, order model.Order) (*model.Order, error) {
if err := s.inventory.Reserve(ctx, order.Items); err != nil {
return nil, fmt.Errorf("reserve inventory: %w", err)
}
order.ID = uuid.New().String()
order.Status = model.OrderStatusPending
order.CreatedAt = time.Now()
if err := s.repo.Create(ctx, order); err != nil {
s.inventory.Release(ctx, order.Items) // Compensate
return nil, fmt.Errorf("create order: %w", err)
}
s.events.Publish(ctx, model.OrderCreatedEvent{OrderID: order.ID})
return &order, nil
}
Services depend on interfaces, not concrete implementations. This makes testing trivial.
Repository Layer
Repositories handle data access. One repository per aggregate:
type PostgresOrderRepo struct {
pool *pgxpool.Pool
}
func (r *PostgresOrderRepo) Create(ctx context.Context, order model.Order) error {
_, err := r.pool.Exec(ctx,
`INSERT INTO orders (id, customer_id, status, total_cents, created_at)
VALUES ($1, $2, $3, $4, $5)`,
order.ID, order.CustomerID, order.Status, order.TotalCents, order.CreatedAt,
)
return err
}
func (r *PostgresOrderRepo) GetByID(ctx context.Context, id string) (*model.Order, error) {
var order model.Order
err := r.pool.QueryRow(ctx,
"SELECT id, customer_id, status, total_cents, created_at FROM orders WHERE id = $1",
id,
).Scan(&order.ID, &order.CustomerID, &order.Status, &order.TotalCents, &order.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, model.ErrNotFound
}
return &order, err
}
Dependency Injection via Constructor
No frameworks. Just constructor functions:
func main() {
cfg := config.Load()
pool := mustConnectDB(cfg.DatabaseURL)
redis := mustConnectRedis(cfg.RedisURL)
// Build dependency graph bottom-up
orderRepo := repository.NewPostgresOrderRepo(pool)
inventoryService := service.NewInventoryService(redis)
eventPublisher := queue.NewKafkaPublisher(cfg.KafkaURL)
orderService := service.NewOrderService(orderRepo, inventoryService, eventPublisher)
orderHandler := handler.NewOrderHandler(orderService)
mux := http.NewServeMux()
mux.HandleFunc("POST /orders", orderHandler.Create)
mux.HandleFunc("GET /orders/{id}", orderHandler.GetByID)
server := &http.Server{
Addr: cfg.Addr,
Handler: middleware.Chain(mux, middleware.Logging, middleware.Auth),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
slog.Info("starting server", "addr", cfg.Addr)
server.ListenAndServe()
}
Everything is explicit. No magic. You can trace any dependency by reading main.go.
Testing
Services are easy to test because they depend on interfaces:
func TestOrderService_Create(t *testing.T) {
repo := &mockOrderRepo{}
inventory := &mockInventoryService{available: true}
events := &mockEventPublisher{}
svc := service.NewOrderService(repo, inventory, events)
order, err := svc.Create(context.Background(), model.Order{
CustomerID: "cust-1",
Items: []model.Item{{ProductID: "prod-1", Qty: 2}},
TotalCents: 5000,
})
require.NoError(t, err)
assert.NotEmpty(t, order.ID)
assert.Equal(t, model.OrderStatusPending, order.Status)
assert.Len(t, events.published, 1)
}
This is the payoff of clean architecture: fast, isolated unit tests that don’t need a database.
The structure isn’t revolutionary. It’s boring — and that’s the point. Boring code is easy to understand, easy to modify, and easy to hand off. Save the cleverness for the algorithms.