When a service goes to the database for data or calls an external API, the simplest thing is to write that call straight into the business logic. It's convenient at first, but later, during testing, you have to spin up a database or mock the HTTP client, and swapping one payment system for another forces you to rewrite logic that should actually be indifferent to it.
Hexagonal Architecture solves this through ports — interfaces that describe what the core needs from the outside world without saying how exactly to get it. The real implementations — adapters — plug in from the outside. The core knows nothing about them.
Where a port lives and how it's named
A port is an interface in core/<bc>/port/out/. In core/, not in the adapter. The core declares the contract it needs itself, and the adapter shows up and says: "I know how to do this."
The structure looks like this:
internal/core/order/
aggregate/ order.go
value_object/ money.go
event/ order_confirmed.go
port/out/
order_repo.go # OrderRepository — working with the aggregate in the DB
payment_port.go # PaymentPort — external payment system
errors.go # port errors
usecase/
confirm_order.go # ConfirmOrderCommand + ConfirmOrderHandler
Naming conventions:
| Type | Name | Example |
|---|---|---|
| Working with the aggregate in the DB | <X>Repository | OrderRepository |
| External HTTP system | <Y>Port | PaymentPort, SmsPort |
| Direct event publishing | <Z>EventPublisher | OrderEventPublisher |
Repository is a historical name from DDD; it explains itself. The Port suffix for external systems makes it explicit: this is a boundary to the infrastructure.
How to declare an outbound port
A port's methods accept and return domain types from core/. There must be no structs belonging to specific external systems in the signature — those are adapter details.
// internal/core/order/port/out/payment_port.go
package out
import (
"context"
"time"
)
type PaymentPort interface {
Register(ctx context.Context, cmd RegisterPaymentCommand) (RegisterPaymentResult, error)
Cancel(ctx context.Context, paymentID PaymentID) error
}
type RegisterPaymentCommand struct {
OrderID OrderID
CustomerID CustomerID
Amount Money
}
type RegisterPaymentResult struct {
PaymentID PaymentID
ConfirmedAt time.Time
}
Money and OrderID are domain value objects. The SberRegisterRequest struct (the specific request format for Sber) is an adapter detail; it never appears in the port.
For working with the database it's the same:
// internal/core/order/port/out/order_repo.go
package out
import "context"
type OrderRepository interface {
FindByID(ctx context.Context, id OrderID) (*aggregate.Order, error)
Save(ctx context.Context, order *aggregate.Order) error
}
Port errors — typed structs
Errors are declared in core/, next to the port itself. Adapters may add their own error types, but the core doesn't know about them.
// internal/core/order/port/out/errors.go
package out
import "your-module/internal/core/apperr"
type PaymentPortError struct {
Op string
Err error
}
func (e *PaymentPortError) Error() string { return "payment port: " + e.Op + ": " + e.Err.Error() }
func (e *PaymentPortError) Unwrap() error { return e.Err }
func (e *PaymentPortError) Kind() apperr.Kind { return apperr.Integration }
type OrderNotFoundError struct {
OrderID OrderID
}
func (e *OrderNotFoundError) Error() string { return "order not found: " + string(e.OrderID) }
func (e *OrderNotFoundError) Kind() apperr.Kind { return apperr.Domain }
An adapter can add its own error type with details specific to a particular system:
// internal/adapter/out/sber/errors.go
package sber
type SberError struct {
Op string
Err error
}
func (e *SberError) Error() string { return "sber: " + e.Op + ": " + e.Err.Error() }
func (e *SberError) Unwrap() error { return e.Err }
The handler in the core catches *out.PaymentPortError via errors.As, not *sber.SberError. This matters: if tomorrow Sber is replaced by another system, the handler doesn't change.
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd ConfirmOrderCommand) error {
_, err = h.payments.Register(ctx, out.RegisterPaymentCommand{
OrderID: cmd.OrderID,
Amount: order.Total(),
})
if err != nil {
var portErr *out.PaymentPortError
if errors.As(err, &portErr) {
return fmt.Errorf("payment unavailable for order %s: %w", cmd.OrderID, err)
}
return err
}
return nil
}
"Not found" is an error, not a bool
A common mistake when designing ports is returning (value, bool, error), where false means "not found":
// don't do this
FindByID(ctx context.Context, id OrderID) (*aggregate.Order, bool, error)
Better — return an error with the typed error OrderNotFoundError:
// correct
FindByID(ctx context.Context, id OrderID) (*aggregate.Order, error)
The handler itself decides what to do with "not found":
order, err := h.orders.FindByID(ctx, cmd.OrderID)
if err != nil {
var notFound *out.OrderNotFoundError
if errors.As(err, ¬Found) {
return fmt.Errorf("confirm order: %w", err) // becomes a 404 at the edge
}
return fmt.Errorf("load order %s: %w", cmd.OrderID, err)
}
The (value, bool) pair is appropriate in the rare cases where false is a normal branch, not an error: for example, an optional user setting. In a command handler, a missing aggregate is always an error.
An inbound port is a UseCase Handler
Hexagonal Architecture also has "input" points — inbound ports. In Go a separate InboundPort interface isn't needed. The UseCase Handler is the entry into the core. The HTTP handler receives *usecase.ConfirmOrderHandler through the constructor and calls .Handle() directly.
// internal/core/order/usecase/confirm_order.go
package usecase
type ConfirmOrderCommand struct {
OrderID aggregate.OrderID
PaymentRef string
}
type ConfirmOrderHandler struct {
orders out.OrderRepository
payments out.PaymentPort
}
func NewConfirmOrderHandler(orders out.OrderRepository, payments out.PaymentPort) *ConfirmOrderHandler {
return &ConfirmOrderHandler{orders: orders, payments: payments}
}
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd ConfirmOrderCommand) error {
order, err := h.orders.FindByID(ctx, cmd.OrderID)
if err != nil {
return fmt.Errorf("load order %s: %w", cmd.OrderID, err)
}
result, err := h.payments.Register(ctx, out.RegisterPaymentCommand{
OrderID: cmd.OrderID,
CustomerID: order.CustomerID(),
Amount: order.Total(),
})
if err != nil {
return fmt.Errorf("register payment: %w", err)
}
if err := order.Confirm(aggregate.PaymentResult{
PaymentID: result.PaymentID,
Amount: order.Total(),
}); err != nil {
return err
}
return h.orders.Save(ctx, order)
}
There's no additional interface between the HTTP handler and the Handler — that would be an extra layer with no benefit.
Compile-time compatibility check
Every out-adapter must explicitly declare that it implements the required interface:
// internal/adapter/out/sber/payment_adapter.go
package sber
var _ out.PaymentPort = (*PaymentAdapter)(nil)
type PaymentAdapter struct {
client *Client
mapper PaymentMapper
}
func (a *PaymentAdapter) Register(ctx context.Context, cmd out.RegisterPaymentCommand) (out.RegisterPaymentResult, error) {
sberReq := a.mapper.ToSberRequest(cmd)
resp, err := a.client.RegisterPayment(ctx, sberReq)
if err != nil {
return out.RegisterPaymentResult{}, &SberError{Op: "register", Err: err}
}
return a.mapper.ToDomainResult(resp), nil
}
func (a *PaymentAdapter) Cancel(ctx context.Context, paymentID out.PaymentID) error {
if err := a.client.CancelPayment(ctx, string(paymentID)); err != nil {
return &SberError{Op: "cancel", Err: err}
}
return nil
}
The line var _ out.PaymentPort = (*PaymentAdapter)(nil) is a compile-time check. If you add a method to the port and forget to implement it in the adapter, the compiler reports the error right away. Without this line the mismatch could go unnoticed until runtime.
Common mistakes
The port is declared in the adapter, not in core. Then the core depends on the infrastructure — it's exactly backwards. The interface must be in core/<bc>/port/out/, and the adapter is its implementation.
A specific system's type in the port signature. Register(ctx, SberRegisterRequest) makes the port inseparable from Sber. You need the domain type RegisterPaymentCommand; mapping to Sber's format is the adapter's job.
The port is declared as a struct, not an interface. A struct can't be substituted in a test without a real connection. Only an interface gives you the ability to test the core in isolation.
Importing the adapter from the core. import "adapter/out/sber" in core/ is a violation of isolation. The core sees only the port interface; the adapter is wired in bootstrap/.
In short
- A port is an
interfaceincore/<bc>/port/out/. The core declares the contract, the adapter implements it. - A port's methods work with domain types (
Money,OrderID), not with types of specific systems. - Port errors are declared in
core/. The handler catches the port error viaerrors.As, not the system error. - "Not found" is an
errorwith a typed struct, not(value, bool, error). - An inbound port in Go is just a UseCase Handler. It needs no separate interface.
var _ out.PaymentPort = (*PaymentAdapter)(nil)in every adapter is a compile-time compatibility check.
Further reading
- Adapters out — who implements the port interface: adapter structure, mapper, error handling.
- Adapters in — how the HTTP handler hands control to the core through the UseCase Handler.
- Core Layer — the structure of the core: aggregate, value objects, domain events.
- Bootstrap / Composition Root — how
main.gowires adapters and Handlers together. - Module Structure — packages, the dependency arrow, and how to verify it in CI.