The Problem With Your "Clean" Architecture

This is what I see in most Go projects claiming to use "clean architecture":

project/
├── cmd/
├── internal/
│   ├── controllers/
│   ├── services/
│   ├── repositories/
│   ├── models/
│   ├── dtos/
│   ├── mappers/
│   ├── validators/
│   ├── decorators/
│   ├── factories/
│   └── managers/
└── pkg/

That's not clean architecture. That's Java with worse syntax.

What Hexagonal Architecture Actually Is

Three things. That's it:

  1. Domain - Your business logic
  2. Ports - Interfaces to the outside world
  3. Adapters - Implementations of those interfaces

The domain doesn't know about databases, HTTP, or frameworks. The adapters don't know about business rules. Clean separation.

The Right Way: A Payment Processing System

payment-service/
├── domain/           # Business logic lives here
│   ├── payment.go    # Core entities
│   ├── service.go    # Business rules
│   └── ports.go      # Interfaces
├── adapters/         # External world lives here
│   ├── http/         # REST API
│   ├── grpc/         # gRPC API
│   ├── postgres/     # Database
│   └── stripe/       # Payment gateway
└── cmd/
    └── server/       # Wiring

Step 1: Define Your Domain

The domain layer contains pure business logic without any infrastructure concerns. Entities represent core business concepts with their invariants and rules. This isolation makes business logic testable without databases, APIs, or frameworks.

Money handling uses integers (cents) instead of floats to avoid precision errors that plague financial systems. This decision, made at the domain level, prevents costly rounding bugs throughout the application.

Business rules live as methods on domain entities. The CanRefund method encapsulates the refund policy - a business decision that should be consistent regardless of whether the request comes from REST API, gRPC, or CLI.

Status transitions enforce state machine rules at the domain level. A payment can't jump from pending to succeeded without going through processing. These invariants are maintained regardless of the adapter calling the domain.

// domain/payment.go - Pure business logic
type Payment struct {
    ID         string
    Amount     Money
    Status     PaymentStatus
    CustomerID string

    // PaymentMethodID is the gateway's payment method reference (e.g. a
    // Stripe PaymentMethod id), attached to the customer before checkout.
    PaymentMethodID string

    // GatewayReference is set once a charge attempt returns one (e.g. a
    // Stripe PaymentIntent id). Refunds go through the gateway by this
    // reference, not by our own Payment.ID - the gateway has never heard
    // of our ID.
    GatewayReference string

    Description   string
    CreatedAt     time.Time
    ProcessedAt   *time.Time
    FailureReason string
    Refunded      bool
}

type Money struct {
    Amount   int64     // Cents, not dollars
    Currency Currency
}

// CanRefund is a pure predicate - it never mutates the payment. Whoever
// calls it decides what to do with the answer.
func (p *Payment) CanRefund() bool {
    if p.Status != StatusSucceeded || p.Refunded {
        return false
    }
    if p.ProcessedAt == nil {
        return false
    }
    // 90-day refund policy. It is better to transfer from the config
    return time.Since(*p.ProcessedAt) < 90*24*time.Hour
}

func (p *Payment) Process() error {
    if p.Status != StatusPending {
        return fmt.Errorf("payment must be pending to process, got %s", p.Status)
    }
    p.Status = StatusProcessing
    return nil
}

func (p *Payment) Succeed(gatewayReference string) error {
    if p.Status != StatusProcessing {
        return errors.New("payment must be processing to succeed")
    }

    now := time.Now()
    p.Status = StatusSucceeded
    p.ProcessedAt = &now
    p.GatewayReference = gatewayReference
    return nil
}

func (p *Payment) Fail(reason string) error {
    if p.Status != StatusProcessing {
        return errors.New("payment must be processing to fail")
    }

    p.Status = StatusFailed
    p.FailureReason = reason
    return nil
}

Currency lives on Money, not directly on Payment - keeping amount and currency in one value object avoids a class of bug where the two drift out of sync across a codebase that reads and writes them separately.

Step 2: Define Your Ports

Ports are interfaces that define how the domain interacts with the external world. They're contracts, not implementations. This inversion of control is what makes hexagonal architecture powerful - the domain defines what it needs, adapters provide it.

Outbound ports (driven) represent services the domain needs: databases, payment gateways, notification systems. The domain calls these interfaces without knowing their implementation. A PaymentRepository could be PostgreSQL in production, in-memory for tests, or DynamoDB in AWS.

Inbound ports (driving) represent ways to invoke the domain: REST APIs, gRPC services, CLI commands, message queues. These adapters translate external requests into domain operations. The same business logic serves all entry points.

Port design requires careful thought. Too granular and you'll have hundreds of interfaces. Too broad and you'll violate interface segregation. We follow the rule: one port per external system or capability, methods grouped by cohesion.

// Ports define contracts, not implementations
package domain

import (
    "context"
)

// Port for storing payments (outbound)
type PaymentRepository interface {
    Save(ctx context.Context, payment *Payment) error
    FindByID(ctx context.Context, id string) (*Payment, error)
    FindByCustomer(ctx context.Context, customerID string) ([]*Payment, error)
    Update(ctx context.Context, payment *Payment) error
}

// Port for the payment gateway (outbound)
type PaymentGateway interface {
    Charge(ctx context.Context, payment *Payment) (*ChargeResult, error)
    // Refund and GetStatus take the gateway's own reference
    // (Payment.GatewayReference), not our internal Payment.ID - the
    // gateway has never seen our ID.
    Refund(ctx context.Context, gatewayReference string, amount Money) error
    GetStatus(ctx context.Context, gatewayReference string) (PaymentStatus, error)
}

type ChargeResult struct {
    GatewayReference string
    Status           PaymentStatus
    FailureReason    string
}

// Port for notifications (outbound)
type NotificationService interface {
    SendPaymentConfirmation(ctx context.Context, payment *Payment) error
    SendPaymentFailure(ctx context.Context, payment *Payment) error
}

// Port for events (outbound)
type EventPublisher interface {
    PublishPaymentCreated(ctx context.Context, payment *Payment) error
    PublishPaymentSucceeded(ctx context.Context, payment *Payment) error
    PublishPaymentFailed(ctx context.Context, payment *Payment) error
}

// Port for logging (outbound) - this is the whole point of a port: the
// domain needs to report "this failed, but I'm not going to fail because
// of it" without importing a concrete logging package to do it.
type Logger interface {
    Error(ctx context.Context, msg string, err error)
}

Adding Logger here isn't decoration - without it, a service that needs to note "this failed, but I'm not failing because of it" has nowhere to put that except calling log.Printf directly, which is exactly the "domain importing infrastructure" mistake this article warns about two sections down. A port fixes it the same way it fixes everything else here: the domain says what it needs, an adapter decides where it goes.

Step 3: Implement Your Service (Business Logic)

The service orchestrates domain operations using ports. It contains use cases - the application's features. This layer knows business rules but not technical details. It's where the magic happens, isolated from infrastructure concerns.

Dependency injection provides port implementations at runtime. The service receives interfaces, not concrete types. This design enables testing with mocks and swapping implementations without changing business logic.

Error handling strategy matters here. We wrap infrastructure errors with domain context but never expose implementation details. A database connection error becomes "payment storage failed" - the domain language, not PostgreSQL jargon.

Transaction boundaries live in services, not repositories. A use case might update multiple aggregates - the service coordinates this within a transaction. Repositories handle single aggregate persistence, services handle consistency.

// Service orchestrates domain operations
package domain

import (
    "context"
    "errors"
    "fmt"
    "time"

    "github.com/google/uuid"
    // On Go 1.27+, the standard library's top-level "uuid" package covers
    // this too (New/NewV4/NewV7, same [16]byte type) - a drop-in swap
    // unless you need something it doesn't have yet, like namespace UUIDs.
)

type PaymentService struct {
    repo      PaymentRepository
    gateway   PaymentGateway
    notifier  NotificationService
    publisher EventPublisher
    logger    Logger
}

func NewPaymentService(
    repo PaymentRepository,
    gateway PaymentGateway,
    notifier NotificationService,
    publisher EventPublisher,
    logger Logger,
) *PaymentService {
    return &PaymentService{
        repo:      repo,
        gateway:   gateway,
        notifier:  notifier,
        publisher: publisher,
        logger:    logger,
    }
}

func (s *PaymentService) CreatePayment(ctx context.Context, req CreatePaymentRequest) (*Payment, error) {
    if err := req.Validate(); err != nil {
        return nil, fmt.Errorf("invalid request: %w", err)
    }

    payment := &Payment{
        ID:              uuid.New().String(),
        Amount:          req.Amount,
        Status:          StatusPending,
        CustomerID:      req.CustomerID,
        PaymentMethodID: req.PaymentMethodID,
        Description:     req.Description,
        CreatedAt:       time.Now(),
    }

    if err := s.repo.Save(ctx, payment); err != nil {
        return nil, fmt.Errorf("failed to save payment: %w", err)
    }

    if err := s.publisher.PublishPaymentCreated(ctx, payment); err != nil {
        // Best-effort: events are async, so a publish failure here
        // shouldn't fail payment creation - but "log and forget" via a
        // Logger port, not log.Printf reached for directly from the
        // domain.
        s.logger.Error(ctx, "failed to publish payment created event", err)
    }

    return payment, nil
}

func (s *PaymentService) ProcessPayment(ctx context.Context, paymentID string) error {
    payment, err := s.repo.FindByID(ctx, paymentID)
    if err != nil {
        return fmt.Errorf("payment not found: %w", err)
    }

    if err := payment.Process(); err != nil {
        return err
    }
    if err := s.repo.Update(ctx, payment); err != nil {
        return fmt.Errorf("failed to update payment: %w", err)
    }

    result, err := s.gateway.Charge(ctx, payment)
    if err != nil {
        payment.Fail(err.Error())
        if uerr := s.repo.Update(ctx, payment); uerr != nil {
            return fmt.Errorf("charge failed (%v) and failed to persist that: %w", err, uerr)
        }
        s.notifier.SendPaymentFailure(ctx, payment)
        s.publisher.PublishPaymentFailed(ctx, payment)
        return fmt.Errorf("charge failed: %w", err)
    }

    if result.Status == StatusSucceeded {
        payment.Succeed(result.GatewayReference)
    } else {
        payment.Fail(result.FailureReason)
    }

    if err := s.repo.Update(ctx, payment); err != nil {
        // The gateway has already moved money (or definitively declined
        // it) by this point - this only failed to record the outcome.
        // That gap is exactly what an outbox pattern closes: write the
        // status update and the notify/publish intent in the same local
        // transaction, instead of as separate steps that can fail
        // independently like this one just did. Out of scope here - see
        // the outbox-pattern article.
        return fmt.Errorf("charge %s but failed to persist result: %w", result.Status, err)
    }

    if result.Status == StatusSucceeded {
        s.notifier.SendPaymentConfirmation(ctx, payment)
        s.publisher.PublishPaymentSucceeded(ctx, payment)
    } else {
        s.notifier.SendPaymentFailure(ctx, payment)
        s.publisher.PublishPaymentFailed(ctx, payment)
    }

    return nil
}

func (s *PaymentService) RefundPayment(ctx context.Context, paymentID string, amount Money) error {
    payment, err := s.repo.FindByID(ctx, paymentID)
    if err != nil {
        return fmt.Errorf("payment not found: %w", err)
    }

    if !payment.CanRefund() {
        return errors.New("payment cannot be refunded")
    }

    if err := s.gateway.Refund(ctx, payment.GatewayReference, amount); err != nil {
        return fmt.Errorf("refund failed: %w", err)
    }

    payment.Refunded = true
    if err := s.repo.Update(ctx, payment); err != nil {
        // Same shape of gap as ProcessPayment above: the refund already
        // happened at the gateway, only recording that it happened
        // failed. payment.Refunded staying false in storage while it's
        // true at the gateway means a retry here is blocked, not a
        // double-refund - but it's stuck until someone reconciles it by
        // hand.
        return fmt.Errorf("refund succeeded at the gateway but failed to persist: %w", err)
    }

    return nil
}

Note what RefundPayment persists and why: without writing payment.Refunded back through repo.Update, CanRefund() would keep returning true forever, and a second call would refund the same payment again. And the gap where Charge succeeds but Update fails is surfaced as an error rather than swallowed - the money has already moved at the gateway, and only recording that fact failed.

Step 4: Implement Adapters

Adapters translate between the domain and external systems. They handle technical concerns: HTTP parsing, SQL queries, API calls. The adapter's job is translation, not business logic. If you find business rules in adapters, you've violated the architecture.

Inbound adapters (controllers) receive external requests and invoke domain services. They handle authentication, request validation, response formatting. The HTTP adapter knows about status codes and JSON. The gRPC adapter knows about protocol buffers. The domain knows neither.

Outbound adapters implement port interfaces defined by the domain. The PostgreSQL adapter translates domain entities to SQL. The Stripe adapter translates payment requests to API calls. Each adapter encapsulates integration complexity.

DTO (Data Transfer Object) conversion happens in adapters, not the domain. The domain works with rich entities. Adapters convert between these entities and external representations. This separation keeps the domain pure and enables API evolution without changing business logic.

HTTP Adapter (Inbound)

// HTTP adapter translates web requests to domain calls
package http

import (
    "encoding/json"
    "net/http"
    "time"

    "hexagonal/domain"
)

type Handler struct {
    service *domain.PaymentService
}

func NewHandler(service *domain.PaymentService) *Handler {
    return &Handler{service: service}
}

func (h *Handler) CreatePayment(w http.ResponseWriter, r *http.Request) {
    var req CreatePaymentDTO
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    domainReq := domain.CreatePaymentRequest{
        Amount: domain.Money{
            Amount:   req.Amount,
            Currency: domain.Currency(req.Currency),
        },
        CustomerID:      req.CustomerID,
        PaymentMethodID: req.PaymentMethodID,
        Description:     req.Description,
    }

    payment, err := h.service.CreatePayment(r.Context(), domainReq)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    response := PaymentDTO{
        ID:         payment.ID,
        Amount:     payment.Amount.Amount,
        Currency:   string(payment.Amount.Currency), // Currency lives on Money, not Payment directly
        Status:     string(payment.Status),
        CustomerID: payment.CustomerID,
        CreatedAt:  payment.CreatedAt,
    }

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

// DTOs - separate from domain
type CreatePaymentDTO struct {
    Amount          int64  `json:"amount"`
    Currency        string `json:"currency"`
    CustomerID      string `json:"customer_id"`
    PaymentMethodID string `json:"payment_method_id"`
    Description     string `json:"description"`
}

type PaymentDTO struct {
    ID         string    `json:"id"`
    Amount     int64     `json:"amount"`
    Currency   string    `json:"currency"`
    Status     string    `json:"status"`
    CustomerID string    `json:"customer_id"`
    CreatedAt  time.Time `json:"created_at"`
}

PostgreSQL Adapter (Outbound)

// adapters/postgres/repository.go
package postgres

import (
    "context"
    "database/sql"

    "hexagonal/domain"
)

type Repository struct {
    db *sql.DB
}

func NewRepository(db *sql.DB) *Repository {
    return &Repository{db: db}
}

func (r *Repository) Save(ctx context.Context, payment *domain.Payment) error {
    query := `
        INSERT INTO payments (id, amount, currency, status, customer_id, payment_method_id, description, created_at)
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
    `

    _, err := r.db.ExecContext(ctx, query,
        payment.ID,
        payment.Amount.Amount,
        payment.Amount.Currency,
        payment.Status,
        payment.CustomerID,
        payment.PaymentMethodID,
        payment.Description,
        payment.CreatedAt,
    )

    return err
}

func (r *Repository) FindByID(ctx context.Context, id string) (*domain.Payment, error) {
    query := `
        SELECT id, amount, currency, status, customer_id, payment_method_id, gateway_reference,
               description, created_at, processed_at, failure_reason, refunded
        FROM payments
        WHERE id = $1
    `

    var payment domain.Payment
    var processedAt sql.NullTime
    var failureReason sql.NullString
    var gatewayReference sql.NullString

    err := r.db.QueryRowContext(ctx, query, id).Scan(
        &payment.ID,
        &payment.Amount.Amount,
        &payment.Amount.Currency,
        &payment.Status,
        &payment.CustomerID,
        &payment.PaymentMethodID,
        &gatewayReference,
        &payment.Description,
        &payment.CreatedAt,
        &processedAt,
        &failureReason,
        &payment.Refunded,
    )

    if err != nil {
        return nil, err
    }

    if processedAt.Valid {
        payment.ProcessedAt = &processedAt.Time
    }
    if failureReason.Valid {
        payment.FailureReason = failureReason.String
    }
    if gatewayReference.Valid {
        payment.GatewayReference = gatewayReference.String
    }

    return &payment, nil
}

// FindByCustomer follows the same Scan pattern as FindByID, over
// rows.Next() instead of a single QueryRowContext - full version in the
// gist, omitted here since it's the same shape repeated.

func (r *Repository) Update(ctx context.Context, payment *domain.Payment) error {
    query := `
        UPDATE payments
        SET status = $2, processed_at = $3, failure_reason = $4, gateway_reference = $5, refunded = $6
        WHERE id = $1
    `

    _, err := r.db.ExecContext(ctx, query,
        payment.ID,
        payment.Status,
        payment.ProcessedAt,
        payment.FailureReason,
        payment.GatewayReference,
        payment.Refunded,
    )

    return err
}

FindByCustomer is the fourth method PaymentRepository requires - any implementation missing it doesn't actually satisfy the port, however plausible the rest of the code looks.

Stripe Adapter (Outbound)

This targets stripe-go/v86 and the Payment Intents API - the Charges API (charge.New) is closed to new integrations and can't handle SCA/3DS, which by now is effectively every card payment. Verified against the current SDK source: even the package-level PaymentIntent helpers are themselves deprecated in favor of a stripe.Client, so this uses that, not just a newer import path on the same old pattern.

// adapters/stripe/gateway.go
package stripe

import (
    "context"
    "errors"

    stripesdk "github.com/stripe/stripe-go/v86"

    "hexagonal/domain"
)

type Gateway struct {
    client *stripesdk.Client
}

func NewGateway(apiKey string) *Gateway {
    return &Gateway{client: stripesdk.NewClient(apiKey)}
}

// Charge creates and immediately confirms a PaymentIntent against a
// payment method already attached to the customer.
func (g *Gateway) Charge(ctx context.Context, payment *domain.Payment) (*domain.ChargeResult, error) {
    params := &stripesdk.PaymentIntentCreateParams{
        Amount:        stripesdk.Int64(payment.Amount.Amount),
        Currency:      stripesdk.String(string(payment.Amount.Currency)),
        Description:   stripesdk.String(payment.Description),
        PaymentMethod: stripesdk.String(payment.PaymentMethodID),
        Confirm:       stripesdk.Bool(true),
        OffSession:    stripesdk.Bool(true),
    }

    pi, err := g.client.V1PaymentIntents.Create(ctx, params)
    if err != nil {
        return nil, err
    }

    status := domain.StatusFailed
    failureReason := ""
    if pi.Status == stripesdk.PaymentIntentStatusSucceeded {
        status = domain.StatusSucceeded
    } else if pi.LastPaymentError != nil {
        failureReason = pi.LastPaymentError.Msg
    }

    return &domain.ChargeResult{
        GatewayReference: pi.ID,
        Status:           status,
        FailureReason:    failureReason,
    }, nil
}

func (g *Gateway) Refund(ctx context.Context, gatewayReference string, amount domain.Money) error {
    if gatewayReference == "" {
        return errors.New("refund requires a gateway reference, payment was never successfully charged")
    }

    params := &stripesdk.RefundCreateParams{
        PaymentIntent: stripesdk.String(gatewayReference),
        Amount:        stripesdk.Int64(amount.Amount),
    }

    _, err := g.client.V1Refunds.Create(ctx, params)
    return err
}

func (g *Gateway) GetStatus(ctx context.Context, gatewayReference string) (domain.PaymentStatus, error) {
    pi, err := g.client.V1PaymentIntents.Retrieve(ctx, gatewayReference, nil)
    if err != nil {
        return "", err
    }
    if pi.Status == stripesdk.PaymentIntentStatusSucceeded {
        return domain.StatusSucceeded, nil
    }
    return domain.StatusFailed, nil
}

Step 5: Wire Everything Together

// cmd/server/main.go
package main

import (
    "database/sql"
    "log"
    "net/http"
    "os"

    httpAdapter "hexagonal/adapters/http"
    "hexagonal/adapters/logging"
    "hexagonal/adapters/noop"
    "hexagonal/adapters/postgres"
    "hexagonal/adapters/stripe"
    "hexagonal/domain"

    _ "github.com/lib/pq"
)

func main() {
    db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Fatal(err)
    }

    // notifier/publisher are no-op stand-ins here so this compiles and
    // runs without a real SMTP server or Kafka cluster - swap for real
    // email/kafka adapters the exact same way postgres and stripe are
    // wired below.
    repository := postgres.NewRepository(db)
    gateway := stripe.NewGateway(os.Getenv("STRIPE_KEY"))
    notifier := noop.NewNotifier()
    publisher := noop.NewPublisher()
    logger := logging.NewStdLogger()

    service := domain.NewPaymentService(
        repository,
        gateway,
        notifier,
        publisher,
        logger,
    )

    handler := httpAdapter.NewHandler(service)

    mux := http.NewServeMux()
    mux.HandleFunc("/payments", handler.CreatePayment)
    mux.HandleFunc("/payments/process", handler.ProcessPayment)

    log.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}

Testing: The Real Benefit

Every mock below satisfies its full port - that's the point of testing against an interface, and it only works if the mock actually implements all of it:

// domain/service_test.go
package domain_test

import (
    "context"
    "errors"
    "testing"
    "time"

    "hexagonal/domain"
)

// --- Mocks: one per port, each satisfying the full interface ---

type mockRepository struct {
    payments   map[string]*domain.Payment
    failSave   bool
    failUpdate bool
}

func newMockRepository() *mockRepository {
    return &mockRepository{payments: make(map[string]*domain.Payment)}
}

func (m *mockRepository) Save(ctx context.Context, payment *domain.Payment) error {
    if m.failSave {
        return errors.New("save failed")
    }
    m.payments[payment.ID] = payment
    return nil
}

func (m *mockRepository) FindByID(ctx context.Context, id string) (*domain.Payment, error) {
    p, ok := m.payments[id]
    if !ok {
        return nil, errors.New("not found")
    }
    return p, nil
}

func (m *mockRepository) FindByCustomer(ctx context.Context, customerID string) ([]*domain.Payment, error) {
    var out []*domain.Payment
    for _, p := range m.payments {
        if p.CustomerID == customerID {
            out = append(out, p)
        }
    }
    return out, nil
}

func (m *mockRepository) Update(ctx context.Context, payment *domain.Payment) error {
    if m.failUpdate {
        return errors.New("update failed")
    }
    m.payments[payment.ID] = payment
    return nil
}

type mockGateway struct {
    shouldFail bool
    refundErr  error
}

func (m *mockGateway) Charge(ctx context.Context, payment *domain.Payment) (*domain.ChargeResult, error) {
    if m.shouldFail {
        return &domain.ChargeResult{Status: domain.StatusFailed, FailureReason: "card declined"}, nil
    }
    return &domain.ChargeResult{GatewayReference: "pi_test_123", Status: domain.StatusSucceeded}, nil
}

func (m *mockGateway) Refund(ctx context.Context, gatewayReference string, amount domain.Money) error {
    return m.refundErr
}

func (m *mockGateway) GetStatus(ctx context.Context, gatewayReference string) (domain.PaymentStatus, error) {
    return domain.StatusSucceeded, nil
}

// mockNotifier and mockPublisher follow the same pattern - full versions
// in the gist, elided here since they're one field (a call log) and one
// method per port action.

With the mocks actually satisfying their interfaces, the test that mattered most - the one that would have caught the refund bug - looks like this:

func TestPaymentService_RefundPayment_RejectsSecondRefund(t *testing.T) {
    processedAt := time.Now().Add(-24 * time.Hour)
    repo := newMockRepository()
    repo.payments["pay_123"] = &domain.Payment{
        ID:               "pay_123",
        Status:           domain.StatusSucceeded,
        ProcessedAt:      &processedAt,
        GatewayReference: "pi_test_123",
        Refunded:         true, // already refunded once
    }

    service, _, _, _ := newService(repo, &mockGateway{})

    err := service.RefundPayment(context.Background(), "pay_123", domain.Money{Amount: 1000, Currency: domain.USD})
    if err == nil {
        t.Fatal("expected an error refunding an already-refunded payment, got nil")
    }
}

This is exactly the test a RefundPayment that doesn't persist Refunded would fail: without that write, CanRefund() stays true forever and a second refund silently succeeds. The full suite (7 tests, including a table test over every CanRefund edge case) is in the gist, and runs clean under go test -race ./....

Common Mistakes to Avoid

  1. Anemic Domain Models - Put business logic in entities, not just getters/setters
  2. Leaking Infrastructure - Domain shouldn't import database/HTTP packages
  3. Too Many Layers - You don't need UseCases, Interactors, AND Services
  4. Interface Pollution - Not everything needs an interface
  5. DTO Everywhere - Sometimes passing domain objects is fine

When NOT to Use Hexagonal Architecture

  • Simple CRUD APIs - Just use standard library
  • Scripts and tools - Overkill
  • Prototypes - Ship first, refactor later
  • Team doesn't understand it - Architecture should help, not confuse

The Bottom Line

Hexagonal architecture isn't about having 20 directories and 50 interfaces. It's about keeping business logic pure and testable.

The version above has 3 directories, 5 interfaces, and a domain package you can test without a database, an HTTP server, or a Stripe API key - that's the actual benefit, not a coverage percentage. The gist has a runnable test suite you can run and see pass yourself.