Hexagonal Architecture #
Imagine an application that has been running for two years. Its database is PostgreSQL, its REST API uses Gin, and emails are sent via SMTP. Then the business decides: migrate to MongoDB, add gRPC alongside REST, and switch the email vendor to SendGrid. Without proper architecture, these changes touch almost the entire codebase — business logic gets dragged along even though not a single business rule changed. Hexagonal Architecture, introduced by Alistair Cockburn, solves this problem with one powerful metaphor: imagine your application as a hexagon in the middle, and all external technologies — databases, HTTP, message brokers, UIs — as things attached to its outer sides through replaceable connectors. Those connectors are called adapters, and the sockets they plug into are ports. Technology changes, connectors get swapped, but the application core never needs to be touched.
The Port and Adapter Concept #
Hexagonal Architecture revolves around two complementary concepts:
A Port is an interface defined by the application core — a “socket” shaped like a contract that describes what the core needs without caring how it is implemented. There are two kinds of ports:
An Adapter is the concrete implementation of a port for a specific technology. Adapters are “plugged into” the matching ports. Swapping the database means swapping the adapter, not the port.
flowchart LR
subgraph EXT_L["Outside World (Driving)"]
HTTP["HTTP Client"]
CLI["CLI"]
TEST["Test Suite"]
end
subgraph CORE["Application Core"]
IP["Inbound Port\\n(interface)"]
APP["Application\\nService"]
OP["Outbound Port\\n(interface)"]
end
subgraph EXT_R["Outside World (Driven)"]
PG["PostgreSQL"]
KAFKA["Kafka"]
SMTP["SMTP / SendGrid"]
end
HTTP -->|"via Inbound Adapter"| IP
CLI -->|"via Inbound Adapter"| IP
TEST -->|"direct / mock"| IP
IP --> APP
APP --> OP
OP -->|"via Outbound Adapter"| PG
OP -->|"via Outbound Adapter"| KAFKA
OP -->|"via Outbound Adapter"| SMTPThere are two types of ports with different roles:
| Port Type | Other Name | Who Initiates | Example |
|---|---|---|---|
| Inbound Port | Driving Port | An external actor calls the core | CreateOrderUseCase, GetUserService |
| Outbound Port | Driven Port | The core calls the outside world | OrderRepository, EmailSender, EventPublisher |
Inbound Ports and Adapters #
An inbound port is the interface defining what operations external actors can perform on the application. HTTP handlers, CLI commands, and gRPC servers are all inbound adapters that call inbound ports.
// port/inbound/create_order.go
// Inbound Port: defined by the application core
// External actors (HTTP, CLI, gRPC) call through this interface
package inbound
import "context"
// CreateOrderRequest is a framework-agnostic input DTO
type CreateOrderRequest struct {
CustomerID string
Items []CreateOrderItem
}
type CreateOrderItem struct {
ProductID string
Quantity int
PriceCents int64
}
type CreateOrderResponse struct {
OrderID string
TotalCents int64
Status string
}
// CreateOrderPort is an inbound port — the contract the core offers to the outside world
type CreateOrderPort interface {
CreateOrder(ctx context.Context, req CreateOrderRequest) (*CreateOrderResponse, error)
}
// application/create_order_service.go
// The Application Service implements the inbound port
package application
import (
"context"
"errors"
"myapp/domain"
"myapp/port/inbound"
"myapp/port/outbound"
)
// CreateOrderService implements inbound.CreateOrderPort
// ✓ It does not know whether it is called from HTTP, CLI, or gRPC
type CreateOrderService struct {
orderRepo outbound.OrderRepository // outbound port
inventory outbound.InventoryChecker // outbound port
eventBus outbound.EventPublisher // outbound port
}
func NewCreateOrderService(
orderRepo outbound.OrderRepository,
inventory outbound.InventoryChecker,
eventBus outbound.EventPublisher,
) *CreateOrderService {
return &CreateOrderService{
orderRepo: orderRepo,
inventory: inventory,
eventBus: eventBus,
}
}
// CreateOrder implements inbound.CreateOrderPort
func (s *CreateOrderService) CreateOrder(
ctx context.Context,
req inbound.CreateOrderRequest,
) (*inbound.CreateOrderResponse, error) {
if req.CustomerID == "" {
return nil, errors.New("customer ID is required")
}
// Build domain objects from the request
items := make([]domain.OrderItem, len(req.Items))
for i, item := range req.Items {
items[i] = domain.OrderItem{
ProductID: domain.ProductID(item.ProductID),
Quantity: item.Quantity,
Price: domain.Money{Cents: item.PriceCents},
}
}
// Check stock via an outbound port
if err := s.inventory.CheckAvailability(ctx, items); err != nil {
return nil, err
}
// Create the domain entity
order, err := domain.NewOrder(domain.CustomerID(req.CustomerID), items)
if err != nil {
return nil, err
}
// Save via an outbound port
if err := s.orderRepo.Save(ctx, order); err != nil {
return nil, err
}
// Publish events via an outbound port
for _, event := range order.PullEvents() {
_ = s.eventBus.Publish(ctx, event)
}
return &inbound.CreateOrderResponse{
OrderID: string(order.ID()),
TotalCents: order.Total().Cents,
Status: string(order.Status()),
}, nil
}
// adapter/inbound/http/order_handler.go
// HTTP Inbound Adapter: translates HTTP requests into inbound port calls
package http
import (
"encoding/json"
"net/http"
"myapp/port/inbound"
)
// OrderHandler is the inbound adapter for HTTP
// ✓ It only translates HTTP → inbound port, no business logic
type OrderHandler struct {
createOrder inbound.CreateOrderPort
}
func NewOrderHandler(createOrder inbound.CreateOrderPort) *OrderHandler {
return &OrderHandler{createOrder: createOrder}
}
type createOrderHTTPRequest struct {
CustomerID string `json:"customer_id"`
Items []orderItemHTTP `json:"items"`
}
type orderItemHTTP struct {
ProductID string `json:"product_id"`
Quantity int `json:"quantity"`
Price int64 `json:"price"`
}
func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
var httpReq createOrderHTTPRequest
if err := json.NewDecoder(r.Body).Decode(&httpReq); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
// Map HTTP request → inbound port request
portReq := inbound.CreateOrderRequest{
CustomerID: httpReq.CustomerID,
Items: make([]inbound.CreateOrderItem, len(httpReq.Items)),
}
for i, item := range httpReq.Items {
portReq.Items[i] = inbound.CreateOrderItem{
ProductID: item.ProductID,
Quantity: item.Quantity,
PriceCents: item.Price,
}
}
// Call the inbound port — does not know its implementation
resp, err := h.createOrder.CreateOrder(r.Context(), portReq)
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(resp)
}
Outbound Ports and Adapters #
An outbound port is the interface defined by the application core for operations that need to reach the outside world — saving to a database, sending emails, publishing events. The core defines its needs, and adapters provide the implementations.
// port/outbound/repository.go
// Outbound Ports: defined by the core, implemented by adapters
package outbound
import (
"context"
"myapp/domain"
)
// OrderRepository is the outbound port for persistence
// ✓ Defined on the core side, not the database side
type OrderRepository interface {
Save(ctx context.Context, order *domain.Order) error
FindByID(ctx context.Context, id domain.OrderID) (*domain.Order, error)
FindByCustomer(ctx context.Context, customerID domain.CustomerID) ([]*domain.Order, error)
}
// InventoryChecker is the outbound port for checking stock availability
type InventoryChecker interface {
CheckAvailability(ctx context.Context, items []domain.OrderItem) error
}
// EventPublisher is the outbound port for publishing domain events
type EventPublisher interface {
Publish(ctx context.Context, event domain.DomainEvent) error
}
// EmailSender is the outbound port for sending emails
type EmailSender interface {
Send(ctx context.Context, to, subject, body string) error
}
// adapter/outbound/postgres/order_repository.go
// PostgreSQL Outbound Adapter: implements outbound.OrderRepository
package postgres
import (
"context"
"database/sql"
"fmt"
"myapp/domain"
"myapp/port/outbound"
)
// PostgresOrderRepository is the PostgreSQL adapter for outbound.OrderRepository
// ✓ Only this knows about SQL — the core does not need to
type PostgresOrderRepository struct {
db *sql.DB
}
// Make sure PostgresOrderRepository implements the interface correctly
var _ outbound.OrderRepository = (*PostgresOrderRepository)(nil)
func NewPostgresOrderRepository(db *sql.DB) *PostgresOrderRepository {
return &PostgresOrderRepository{db: db}
}
func (r *PostgresOrderRepository) Save(ctx context.Context, order *domain.Order) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx,
`INSERT INTO orders (id, customer_id, status, total_cents, created_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (id) DO UPDATE
SET status = $3, total_cents = $4`,
string(order.ID()), string(order.CustomerID()),
string(order.Status()), order.Total().Cents,
)
if err != nil {
return fmt.Errorf("failed to save order: %w", err)
}
return tx.Commit()
}
func (r *PostgresOrderRepository) FindByID(
ctx context.Context,
id domain.OrderID,
) (*domain.Order, error) {
// query implementation...
return nil, nil
}
func (r *PostgresOrderRepository) FindByCustomer(
ctx context.Context,
customerID domain.CustomerID,
) ([]*domain.Order, error) {
// query implementation...
return nil, nil
}
// adapter/outbound/sendgrid/email_sender.go
// SendGrid Outbound Adapter: implements outbound.EmailSender
package sendgrid
import (
"context"
"fmt"
)
// SendGridEmailSender is the SendGrid adapter
// ✓ Swapping the email vendor only means swapping this adapter
type SendGridEmailSender struct {
apiKey string
fromEmail string
}
var _ outbound.EmailSender = (*SendGridEmailSender)(nil)
func NewSendGridEmailSender(apiKey, fromEmail string) *SendGridEmailSender {
return &SendGridEmailSender{apiKey: apiKey, fromEmail: fromEmail}
}
func (s *SendGridEmailSender) Send(ctx context.Context, to, subject, body string) error {
// Call the SendGrid API...
fmt.Printf("[SendGrid] Sending to %s: %s\n", to, subject)
return nil
}
Directory Structure #
A folder structure that mirrors the Hexagonal Architecture metaphor:
myapp/
├── domain/ ← Domain core — no external dependencies
│ ├── order.go ← Entity, Aggregate
│ ├── money.go ← Value Object
│ └── events.go ← Domain Events
│
├── port/ ← Contracts/interfaces (socket definitions)
│ ├── inbound/ ← What actors can do to the core
│ │ ├── create_order.go ← CreateOrderPort
│ │ └── get_order.go ← GetOrderPort
│ └── outbound/ ← What the core needs from the outside world
│ ├── repository.go ← OrderRepository, etc.
│ ├── event_publisher.go ← EventPublisher
│ └── email_sender.go ← EmailSender
│
├── application/ ← Inbound port implementations (Application Services)
│ ├── create_order_service.go
│ └── get_order_service.go
│
├── adapter/ ← Port implementations (connectors to the outside world)
│ ├── inbound/
│ │ ├── http/
│ │ │ └── order_handler.go ← HTTP Inbound Adapter
│ │ └── grpc/
│ │ └── order_server.go ← gRPC Inbound Adapter
│ └── outbound/
│ ├── postgres/
│ │ └── order_repository.go ← PostgreSQL Outbound Adapter
│ ├── kafka/
│ │ └── event_publisher.go ← Kafka Outbound Adapter
│ └── sendgrid/
│ └── email_sender.go ← SendGrid Outbound Adapter
│
└── cmd/
└── server/
└── main.go ← Wiring all adapters to ports
Testing with Mock Adapters #
The greatest strength of Hexagonal Architecture is ease of testing. Because the core only knows about ports (interfaces), you can plug in mock adapters to test application services without real infrastructure:
// application/create_order_service_test.go
package application_test
import (
"context"
"testing"
"myapp/application"
"myapp/domain"
"myapp/port/inbound"
)
// Mock adapter for OrderRepository
type mockOrderRepository struct {
savedOrders []*domain.Order
shouldFail bool
}
func (m *mockOrderRepository) Save(ctx context.Context, order *domain.Order) error {
if m.shouldFail {
return errors.New("database error")
}
m.savedOrders = append(m.savedOrders, order)
return nil
}
func (m *mockOrderRepository) FindByID(ctx context.Context, id domain.OrderID) (*domain.Order, error) {
return nil, nil
}
func (m *mockOrderRepository) FindByCustomer(ctx context.Context, customerID domain.CustomerID) ([]*domain.Order, error) {
return nil, nil
}
// Mock adapter for InventoryChecker
type mockInventory struct {
available bool
}
func (m *mockInventory) CheckAvailability(ctx context.Context, items []domain.OrderItem) error {
if !m.available {
return errors.New("insufficient stock")
}
return nil
}
// Mock adapter for EventPublisher
type mockEventPublisher struct {
publishedEvents []domain.DomainEvent
}
func (m *mockEventPublisher) Publish(ctx context.Context, event domain.DomainEvent) error {
m.publishedEvents = append(m.publishedEvents, event)
return nil
}
// Test: testing CreateOrderService without a real database, HTTP, or Kafka
func TestCreateOrderService_Success(t *testing.T) {
repo := &mockOrderRepository{}
inventory := &mockInventory{available: true}
events := &mockEventPublisher{}
// ✓ Using mock adapters — no PostgreSQL or Kafka needed
svc := application.NewCreateOrderService(repo, inventory, events)
resp, err := svc.CreateOrder(context.Background(), inbound.CreateOrderRequest{
CustomerID: "cust-123",
Items: []inbound.CreateOrderItem{
{ProductID: "prod-1", Quantity: 2, PriceCents: 50000},
},
})
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if resp.OrderID == "" {
t.Error("order ID must not be empty")
}
if resp.TotalCents != 100000 {
t.Errorf("total should be 100000, got %d", resp.TotalCents)
}
if len(repo.savedOrders) != 1 {
t.Errorf("expected 1 saved order, got %d", len(repo.savedOrders))
}
if len(events.publishedEvents) == 0 {
t.Error("expected events to be published")
}
}
func TestCreateOrderService_OutOfStock(t *testing.T) {
repo := &mockOrderRepository{}
inventory := &mockInventory{available: false} // ✓ simulate out of stock
events := &mockEventPublisher{}
svc := application.NewCreateOrderService(repo, inventory, events)
_, err := svc.CreateOrder(context.Background(), inbound.CreateOrderRequest{
CustomerID: "cust-123",
Items: []inbound.CreateOrderItem{
{ProductID: "prod-1", Quantity: 999, PriceCents: 50000},
},
})
if err == nil {
t.Error("expected an error when out of stock")
}
if len(repo.savedOrders) != 0 {
t.Error("order must not be saved when out of stock")
}
}
// Test using the CLI inbound adapter directly — no HTTP server
func TestCreateOrder_ViaDirectPort(t *testing.T) {
// ✓ The test suite can call the inbound port directly — no HTTP server needed
var svc inbound.CreateOrderPort = application.NewCreateOrderService(
&mockOrderRepository{},
&mockInventory{available: true},
&mockEventPublisher{},
)
_, err := svc.CreateOrder(context.Background(), inbound.CreateOrderRequest{
CustomerID: "cust-456",
Items: []inbound.CreateOrderItem{
{ProductID: "prod-2", Quantity: 1, PriceCents: 75000},
},
})
if err != nil {
t.Fatalf("expected no error: %v", err)
}
}
Swapping Adapters Without Touching the Core #
This is the most powerful demonstration of Hexagonal Architecture’s value — swapping the entire database implementation only requires replacing the adapter, without touching a single line in the domain or application layer:
// Before: using PostgreSQL
pgRepo := postgres.NewPostgresOrderRepository(postgresDB)
// After: migrating to MongoDB — only swap the adapter
// ✓ The application service does not change at all
mongoRepo := mongodb.NewMongoOrderRepository(mongoClient)
// Wiring in main.go — the only file that changes
svc := application.NewCreateOrderService(
mongoRepo, // ← swap this
inventoryChecker,
eventPublisher,
)
sequenceDiagram
participant HTTP as HTTP Adapter
participant PORT as Inbound Port
participant APP as Application Service
participant OUTPORT as Outbound Port
participant PG as PostgreSQL Adapter
HTTP->>PORT: CreateOrder(req)
PORT->>APP: CreateOrder(req)
APP->>OUTPORT: Save(order)
OUTPORT->>PG: INSERT INTO orders...
PG-->>OUTPORT: ok
OUTPORT-->>APP: nil
APP-->>PORT: CreateOrderResponse
PORT-->>HTTP: response
Note over OUTPORT,PG: Swap PostgreSQL → MongoDB:<br/>only the Adapter changesComparison with Clean Architecture and Onion #
Hexagonal, Clean, and Onion Architectures are three names for a very similar philosophy — all belong to the domain-centric architecture family. The differences are more about emphasis and terminology:
| Aspect | Hexagonal | Clean Architecture | Onion Architecture |
|---|---|---|---|
| Visual metaphor | A hexagon with ports on its sides | Concentric circles | Onion layers |
| Key terminology | Ports and Adapters | Layers and the Dependency Rule | Rings and dependencies |
| Emphasis | Swappability of technology (pluggability) | Dependency direction rules | Domain purity at the center |
| Inbound/Outbound | Explicitly distinguished | Not formally distinguished | Not distinguished |
| Introduced by | Alistair Cockburn (2005) | Robert C. Martin (2012) | Jeffrey Palermo (2008) |
In practice, all three produce very similar code when implemented. Choose the terminology easiest to communicate to your team.
Anti-Patterns to Avoid #
// ✗ Domain importing adapters — the most fundamental violation
package domain
import "myapp/adapter/outbound/postgres" // ✗ domain must not know about postgres
type OrderService struct {
repo *postgres.PostgresOrderRepository // ✗ dependency on a concrete adapter
}
// ✓ Domain only knows about ports (interfaces)
package domain
import "myapp/port/outbound" // ✓ only interfaces
type OrderService struct {
repo outbound.OrderRepository // ✓ dependency on an interface
}
// ✗ Business logic in an adapter — adapters should only translate
func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
// ✗ business validation in an HTTP handler
if req.Quantity > 100 {
http.Error(w, "quantity too large", http.StatusBadRequest)
return
}
// ✗ price calculation in the HTTP adapter
total := req.Quantity * req.Price * 0.9 // 10% discount
// ...
}
// ✓ Thin adapters — only parse, delegate, format
func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
var req createOrderHTTPRequest
json.NewDecoder(r.Body).Decode(&req)
resp, err := h.createOrder.CreateOrder(r.Context(), mapToPortRequest(req)) // ✓
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
json.NewEncoder(w).Encode(resp)
}
// ✗ Outbound ports defined on the adapter side — ownership violation
// adapter/outbound/postgres/repository.go
package postgres
type OrderRepository interface { // ✗ interface on the implementation side
Save(order *Order) error
}
// ✓ Outbound ports are defined on the core side — the core determines the needs
// port/outbound/repository.go
package outbound
type OrderRepository interface { // ✓ interface on the core side
Save(ctx context.Context, order *domain.Order) error
}
When Not to Use Hexagonal Architecture #
Use Hexagonal Architecture if:
✓ Many external integrations may be swapped in the future
✓ Testability is a high priority — unit tests without infrastructure needed
✓ The team needs to develop core and adapters in parallel
✓ Medium to large systems with long lifetimes
✓ There is a chance of adding new channels (REST + gRPC + CLI) without changing the core
Consider a simpler approach if:
✗ The application is very small with one database and one channel
✗ The team is new to interfaces and dependency inversion
✗ The timeline is very tight — Hexagonal setup takes more time initially
✗ The domain is very simple and will not change
✗ There is only one implementation for every port — interfaces without alternatives
Hexagonal Architecture Review Checklist #
PORT DESIGN:
□ Inbound ports are defined in the application/port package, not in adapters
□ Outbound ports are defined in the port/outbound package, not in infrastructure
□ Ports use domain objects or DTOs — not structs from external libraries
□ Inbound and outbound ports are explicitly separated
ADAPTERS:
□ Inbound adapters only translate external formats to ports — no business logic
□ Outbound adapters only implement ports — no business logic
□ Adapter names reflect the technology: PostgresOrderRepository, KafkaEventPublisher
□ Every adapter validates the interface with a compile-time check: var _ Port = (*Impl)(nil)
APPLICATION CORE:
□ No imports from adapter or infrastructure packages
□ All dependencies to the outside world go through outbound ports (interfaces)
□ Application services implement inbound ports
□ The domain layer has no dependency on application or adapters
TESTING:
□ Application services are tested with mock adapters — no real infrastructure needed
□ Domain logic is tested purely without any dependencies
□ Adapters are tested with integration tests against real infrastructure
□ go test -race is run to ensure no race conditions
DEPENDENCY INJECTION:
□ Adapter-to-port wiring happens only in main.go
□ No global state — all dependencies are injected
□ Swapping adapters is easy without changing the application core
Summary #
- Ports are contracts, adapters are implementations — ports are defined by the core (what it needs), adapters provide concrete implementations (how to fulfill them); the two must be firmly separated.
- Inbound ports for initiating actors, outbound ports for core-initiated calls — HTTP handlers, gRPC servers, and CLIs call inbound ports; databases, email, and message brokers are accessed through outbound ports.
- The core must not know about adapters — if there is an
import "adapter/postgres"in the domain or application layer, that is a violation to fix immediately.- Adapters must be thin — adapters only translate formats, they contain no business logic; business validation lives in the domain, orchestration in application services.
- Swapping technology means swapping adapters — database migration, changing email vendors, adding new channels; all only touch adapters, never the core.
- Testability is a natural result — mock adapters replace concrete implementations during testing; tests need no real database, HTTP server, or message broker.
- Outbound ports are defined by the core, not infrastructure — this is often reversed in wrong implementations; the core determines its needs, infrastructure fulfills them.
- Compile-time interface checks — use
var _ Port = (*Impl)(nil)to ensure an adapter truly implements the expected port; errors are caught at compile time, not runtime.- Hexagonal, Clean, and Onion are the same family — the differences are mostly terminology; choose whichever is easiest to communicate to the team.
- Best for systems with many integrations — the more external technologies interacting with the system, the greater the benefit of firm port/adapter boundaries.