← back to posts

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.