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.
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.
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)
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)
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)
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 rulesservice– use cases and orchestrationpostgres,http,stripe– adaptersmain– 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:
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-godirectly, 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
mainor a dedicated setup function; makes your architecture visible. - Test domain and service logic without touching HTTP or the database; use mock implementations of interfaces.






Comments
Loading comments…