Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 066 / 100

Clean architecture in Go: a hands-on guide for engineers

Keep it simple and idiomatic.

Expona Admin10 min read

At scale, many Go projects drift into spaghetti code because layers blur together—handlers call handlers, services reach straight into the database, and domain logic scatters across packages. Clean architecture in Go: a hands-on guide for engineers helps you avoid this trap by separating concerns without the ceremony that bogs down other languages. We'll walk through a real-world example: a payment processing service where domain types hold business rules, services orchestrate use cases through interfaces, and adapters plug in HTTP handlers and databases.

Why clean architecture matters in Go

Go's simplicity can feel like a liability when your codebase grows. There's no framework forcing structure, no conventions baked into the runtime. That freedom is powerful—until it becomes chaos. Clean architecture gives us a mental model: your core business logic lives in one place, dependencies flow inward, and external concerns (HTTP, SQL, third-party APIs) sit at the edges.

The payoff is concrete. Tests run fast because they don't touch the database. You can swap out a PostgreSQL store for an in-memory mock without rewriting service logic. New team members see clear boundaries between "what this domain does" and "how we talk to the outside world." This doesn't require a framework; it's just disciplined package organization and interface design.

Domain types — Plain structs with business rules

Start with your domain. This is not the database schema, not the HTTP request body—it's the conceptual heart of your system. A payment domain might include an Order, a Transaction, and a Refund. These live in a domain package with no external dependencies.

go
package domain

import (
	"errors"
	"time"
)

type OrderStatus string

const (
	OrderPending    OrderStatus = "pending"
	OrderConfirmed  OrderStatus = "confirmed"
	OrderCancelled  OrderStatus = "cancelled"
)

type Order struct {
	ID        string
	Amount    int64 // in cents
	Status    OrderStatus
	CreatedAt time.Time
}

type Transaction struct {
	ID          string
	OrderID     string
	Provider    string // "stripe", "paypal"
	ExternalID  string
	Amount      int64
	Status      string
	CreatedAt   time.Time
}

// Business rule: an order can only be cancelled if it's still pending
func (o *Order) Cancel() error {
	if o.Status != OrderPending {
		return errors.New("only pending orders can be cancelled")
	}
	o.Status = OrderCancelled
	return nil
}

// Business rule: confirm only if we have exactly one successful transaction
func (o *Order) ConfirmWithTransaction(tx *Transaction) error {
	if tx.Status != "success" {
		return errors.New("transaction must be successful")
	}
	if tx.Amount != o.Amount {
		return errors.New("transaction amount must match order")
	}
	o.Status = OrderConfirmed
	return nil
}

Notice: no database calls, no HTTP imports, no frameworks. The Order and Transaction types are plain Go structs. Business rules—like "only pending orders can be cancelled"—live as methods. This keeps logic portable and testable. If you need to enforce a rule in a CLI, a gRPC handler, or an async worker, you reach for these same methods.

Services — Use cases that depend on interfaces

Services orchestrate domain logic and coordinate between your domain and external resources. A service depends on interfaces, not concrete implementations, so you can swap out stores or external clients during testing.

go
package service

import (
	"context"
	"errors"
	"your-module/domain"
)

// OrderStore defines what our service needs from storage
type OrderStore interface {
	GetOrder(ctx context.Context, id string) (*domain.Order, error)
	SaveOrder(ctx context.Context, order *domain.Order) error
}

// PaymentProvider defines what our service needs from a payment gateway
type PaymentProvider interface {
	Charge(ctx context.Context, amount int64, token string) (externalID string, err error)
	Refund(ctx context.Context, externalID string) error
}

type OrderService struct {
	store    OrderStore
	provider PaymentProvider
}

func NewOrderService(store OrderStore, provider PaymentProvider) *OrderService {
	return &OrderService{store: store, provider: provider}
}

// ProcessOrder is a use case: accept payment and confirm the order
func (s *OrderService) ProcessOrder(
	ctx context.Context,
	orderID string,
	paymentToken string,
) error {
	// Fetch domain entity
	order, err := s.store.GetOrder(ctx, orderID)
	if err != nil {
		return err
	}
	if order == nil {
		return errors.New("order not found")
	}

	// Call external provider
	externalID, err := s.provider.Charge(ctx, order.Amount, paymentToken)
	if err != nil {
		return err
	}

	// Create domain entity
	tx := &domain.Transaction{
		OrderID:    orderID,
		Provider:   "stripe",
		ExternalID: externalID,
		Amount:     order.Amount,
		Status:     "success",
	}

	// Apply domain logic
	if err := order.ConfirmWithTransaction(tx); err != nil {
		// Refund on failure
		_ = s.provider.Refund(ctx, externalID)
		return err
	}

	// Persist
	return s.store.SaveOrder(ctx, order)
}

The service doesn't know if the store is PostgreSQL or Redis. It doesn't care if the payment provider is Stripe or PayPal. That's the job of adapters. The service's job is to orchestrate, applying domain logic at the right moments and coordinating between interfaces.

Adapters — HTTP handlers, SQL stores and external clients

Adapters live at the boundary between your core logic and the outside world. They translate between HTTP requests and domain types, between SQL rows and domain entities, between your API and third-party services.

HTTP Adapter (Handler)

go
package http

import (
	"encoding/json"
	"net/http"
	"your-module/service"
)

type OrderHandler struct {
	orderSvc *service.OrderService
}

func NewOrderHandler(orderSvc *service.OrderService) *OrderHandler {
	return &OrderHandler{orderSvc: orderSvc}
}

// ProcessOrderRequest is the HTTP request shape
type ProcessOrderRequest struct {
	OrderID      string `json:"order_id"`
	PaymentToken string `json:"payment_token"`
}

// ProcessOrderResponse is the HTTP response shape
type ProcessOrderResponse struct {
	Success bool   `json:"success"`
	Message string `json:"message,omitempty"`
}

func (h *OrderHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}

	var req ProcessOrderRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "invalid request", http.StatusBadRequest)
		return
	}

	// Delegate to service
	err := h.orderSvc.ProcessOrder(r.Context(), req.OrderID, req.PaymentToken)

	resp := ProcessOrderResponse{
		Success: err == nil,
		Message: "",
	}
	if err != nil {
		resp.Message = err.Error()
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(resp)
}

SQL Adapter (Store)

go
package postgres

import (
	"context"
	"database/sql"
	"your-module/domain"
)

type OrderStore struct {
	db *sql.DB
}

func NewOrderStore(db *sql.DB) *OrderStore {
	return &OrderStore{db: db}
}

func (s *OrderStore) GetOrder(ctx context.Context, id string) (*domain.Order, error) {
	row := s.db.QueryRowContext(ctx,
		"SELECT id, amount, status, created_at FROM orders WHERE id = $1",
		id,
	)

	var order domain.Order
	err := row.Scan(&order.ID, &order.Amount, &order.Status, &order.CreatedAt)
	if err == sql.ErrNoRows {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	return &order, nil
}

func (s *OrderStore) SaveOrder(ctx context.Context, order *domain.Order) error {
	_, err := s.db.ExecContext(ctx,
		"UPDATE orders SET status = $1 WHERE id = $2",
		order.Status,
		order.ID,
	)
	return err
}

External Service Adapter (Payment Provider)

go
package stripe

import (
	"context"
	"errors"
)

type StripeClient struct {
	apiKey string
	// http.Client or similar
}

func NewStripeClient(apiKey string) *StripeClient {
	return &StripeClient{apiKey: apiKey}
}

func (c *StripeClient) Charge(ctx context.Context, amount int64, token string) (string, error) {
	// Call Stripe API
	// Return external ID
	if amount <= 0 {
		return "", errors.New("invalid amount")
	}
	// ... actual Stripe API call ...
	return "ch_1234567890", nil
}

func (c *StripeClient) Refund(ctx context.Context, externalID string) error {
	// Call Stripe API
	return nil
}

Each adapter knows about its specific tool—HTTP status codes, SQL queries, API client libraries—but doesn't leak that knowledge into the service or domain. When you swap Stripe for PayPal, you only touch the adapter.

Do not over-layer — Go favors fewer, clearer packages

A common trap: thinking clean architecture means five layers plus interfaces everywhere. In Go, that's often overkill. We typically need:

  • domain – business entities and rules
  • service – use cases and orchestration
  • postgres, http, stripe – adapters
  • main – wiring (dependency injection)

That's it. No repository pattern on top of repositories. No use-case interfaces when the service is already your use case. Go rewards simplicity. If you find yourself writing boilerplate, you've added a layer you don't need.

Your main or a wire package ties it together:

go
package main

import (
	"database/sql"
	"net/http"
	"your-module/http"
	"your-module/postgres"
	"your-module/service"
	"your-module/stripe"
)

func main() {
	db := sql.Open("postgres", "...")
	defer db.Close()

	// Build adapters
	orderStore := postgres.NewOrderStore(db)
	paymentProvider := stripe.NewStripeClient("sk_test_...")

	// Build service
	orderSvc := service.NewOrderService(orderStore, paymentProvider)

	// Build handler
	handler := http.NewOrderHandler(orderSvc)

	http.Handle("/process-order", handler)
	http.ListenAndServe(":8080", nil)
}

Common mistakes

  • Domain logic in adapters: Putting business rules in HTTP handlers or database functions defeats the purpose. Your rules should live on domain types and be testable without external dependencies.
  • Service depending on concrete adapters: If your service imports github.com/stripe/stripe-go directly, you've coupled business logic to an external library. Use interfaces instead.
  • Overstuffing the service layer: Services should orchestrate, not contain all logic. If you find a service method growing beyond 20 lines, consider whether some logic belongs on a domain type.
  • Too many tiny packages: Each package should represent a real boundary: domain, a use-case cluster, a specific technology. Don't create a package for every struct.
  • Circular dependencies: If domain imports service or adapter, you've got a problem. Dependencies flow inward: adapters depend on service, service depends on domain. Never the reverse.

Key takeaways

  • Place business rules on domain types as methods; keep domain free of framework and database code.
  • Build services around use cases, depending on interfaces so adapters are swappable.
  • Put HTTP, SQL, and external API logic in separate adapter packages; keep them out of your core.
  • Go's simplicity is a feature: resist the urge to add layers. Three to four packages cover most cases.
  • Wire dependencies in main or a dedicated setup function; makes your architecture visible.
  • Test domain and service logic without touching HTTP or the database; use mock implementations of interfaces.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles