Hexagonal Architecture in Go: Why Your "Clean" Code Is Actually a Mess
I've reviewed a lot of "clean architecture" Go projects. Most of them were over-engineered nightmares with 15 layers of abstraction for a CRUD API. This article explains how I apply hexagonal architecture in Go.
Full runnable code, tests included: gist.github.com/SergeiSkv/ca97286ae494d4c847e97b7196531bf5
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:
- Domain - Your business logic
- Ports - Interfaces to the outside world
- 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
- Anemic Domain Models - Put business logic in entities, not just getters/setters
- Leaking Infrastructure - Domain shouldn't import database/HTTP packages
- Too Many Layers - You don't need UseCases, Interactors, AND Services
- Interface Pollution - Not everything needs an interface
- 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.