When a service talks to a database, a payment system, or sends an event to Kafka — it does so through out-adapters. This is the "outbound" part of hexagonal architecture: the core calls an interface (a port), and the adapter translates that call into a concrete technical protocol.
Let's look at what an out-adapter looks like in Go, why adapters should be split by system, and how to handle errors correctly.
Why one big "outbound adapter" is a bad idea
If you dump everything into a single adapter/out/ package — the database, the payment gateway, Kafka — you get a mess of dependencies. A failure in one system starts affecting the others. A database test drags in a mock of the payment API. Switching SMS providers means digging into a shared file.
The rule is simple: one separate package per external system.
internal/
adapter/out/
persistence/ # database via sqlc + pgx
sber/ # Sber REST API payment gateway
odna_kassa/ # alternative payment provider
kafka/ # event publishing
s3/ # file storage
What this gives you:
adapter/out/sber/imports onlynet/httpand the Sber DTOs.adapter/out/persistence/— onlypgxand sqlc. Replacing one provider doesn't touch the others.- Each adapter gets its own test server or container — not one "mock for everything".
- Metrics and logs are written with a system attribute (
system=sber,system=persistence) — you immediately see who slowed down.
The adapter implements a port-interface from core
The service core doesn't know how the payment system actually works. It only knows the interface — what can be done and what data comes back:
// core/order/port/out/payment_port.go
package out
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
}
The adapter in adapter/out/sber/ implements this interface:
// adapter/out/sber/payment_adapter.go
package sber
type PaymentAdapter struct {
client *Client
mapper PaymentMapper
log *slog.Logger
}
var _ out.PaymentPort = (*PaymentAdapter)(nil) // compile-time check
func NewPaymentAdapter(client *Client, log *slog.Logger) *PaymentAdapter {
return &PaymentAdapter{client: client, log: log}
}
func (a *PaymentAdapter) Register(ctx context.Context, cmd out.RegisterPaymentCommand) (out.RegisterPaymentResult, error) {
req := a.mapper.ToSberRequest(cmd)
resp, err := a.client.RegisterPayment(ctx, req)
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 standard Go idiom. If the adapter doesn't implement all the interface methods, the compiler shows the error right away. No need to wait for tests.
The database adapter via sqlc is structured the same way:
// adapter/out/persistence/order_repository.go
package persistence
type OrderRepository struct {
q *db.Queries // generated by sqlc
log *slog.Logger
}
var _ out.OrderRepository = (*OrderRepository)(nil)
func NewOrderRepository(pool *pgxpool.Pool, log *slog.Logger) *OrderRepository {
return &OrderRepository{q: db.New(pool), log: log}
}
func (r *OrderRepository) FindByID(ctx context.Context, id out.OrderID) (*aggregate.Order, error) {
row, err := r.q.GetOrder(ctx, string(id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, &out.OrderNotFoundError{OrderID: id}
}
return nil, fmt.Errorf("persistence: find order %s: %w", id, err)
}
o := OrderMapper{}.ToDomain(row)
return &o, nil
}
func (r *OrderRepository) Save(ctx context.Context, order *aggregate.Order) error {
params := OrderMapper{}.ToInsertParams(order)
if err := r.q.UpsertOrder(ctx, params); err != nil {
return fmt.Errorf("persistence: save order %s: %w", order.ID(), err)
}
return nil
}
db.Queries and db.GetOrderRow are sqlc-generated types. They live only inside adapter/out/persistence/ and never make it into core/.
Mapper: translation between the domain and the external format
The adapter receives domain objects — a RegisterPaymentCommand with Money, OrderID, CustomerID. The external system expects its own format — a SberRegisterRequest with the amount in kopecks, numeric codes, and system-specific fields.
This translation is done by a mapper — a separate struct in the adapter package:
// adapter/out/sber/payment_mapper.go
package sber
type PaymentMapper struct{}
func (PaymentMapper) ToSberRequest(cmd out.RegisterPaymentCommand) SberRegisterRequest {
return SberRegisterRequest{
OrderID: string(cmd.OrderID),
Amount: cmd.Amount.Kopecks(), // Sber accepts kopecks
Currency: "RUB",
Customer: SberCustomer{
ID: string(cmd.CustomerID),
},
}
}
func (PaymentMapper) ToDomainResult(resp SberRegisterResponse) out.RegisterPaymentResult {
return out.RegisterPaymentResult{
PaymentID: out.PaymentID(resp.PaymentID),
ConfirmedAt: resp.CreatedAt,
}
}
For the database adapter, the mapper translates between the domain aggregate and the sqlc structs:
// adapter/out/persistence/order_mapper.go
package persistence
type OrderMapper struct{}
func (OrderMapper) ToDomain(row db.GetOrderRow) aggregate.Order {
return aggregate.Restore(
aggregate.OrderID(row.ID),
aggregate.CustomerID(row.CustomerID),
mapItems(row.Items),
mapStatus(row.Status),
value_object.NewMoney(row.TotalAmount, row.Currency),
)
}
func (OrderMapper) ToInsertParams(o aggregate.Order) db.UpsertOrderParams {
return db.UpsertOrderParams{
ID: string(o.ID()),
CustomerID: string(o.CustomerID()),
Status: string(o.Status()),
TotalAmount: o.Total().Amount(),
Currency: o.Total().Currency(),
}
}
All the quirks of a specific system — kopecks instead of rubles, numeric statuses, date formats — stay inside the adapter and don't leak into the core.
Errors in out-adapters
Errors are organized in two layers. In core/, the domain error types that the core knows about are declared:
// core/order/port/out/errors.go
package out
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 }
type OrderNotFoundError struct {
OrderID OrderID
}
func (e *OrderNotFoundError) Error() string { return "order not found: " + string(e.OrderID) }
The adapter declares its own system-specific error type:
// 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 core/ uses errors.As with the types from core/ — it doesn't know about SberError, and it shouldn't:
// core/order/usecase/confirm_order.go
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd ConfirmOrderCommand) error {
order, err := h.orders.FindByID(ctx, cmd.OrderID)
if err != nil {
return fmt.Errorf("confirm order: load order %s: %w", cmd.OrderID, err)
}
result, err := h.payments.Register(ctx, out.RegisterPaymentCommand{
OrderID: cmd.OrderID,
Amount: order.Total(),
})
if err != nil {
return fmt.Errorf("confirm order: register payment: %w", err)
}
_ = result
return nil
}
If tomorrow Sber is swapped for another provider, the handler doesn't change — only the adapter does.
A common mistake: business logic in the adapter
The adapter translates and calls — it doesn't decide. The decisions are made by the handler in core/.
Bad example:
// don't do this — business logic in the adapter
func (a *PaymentAdapter) Register(ctx context.Context, cmd out.RegisterPaymentCommand) (out.RegisterPaymentResult, error) {
if cmd.Amount.Kopecks() > 10_000_000 { // checking the amount is a business rule
return out.RegisterPaymentResult{}, errors.New("amount too large")
}
resp, err := a.client.RegisterPayment(ctx, a.mapper.ToSberRequest(cmd))
if err != nil {
return out.RegisterPaymentResult{}, &SberError{Op: "register", Err: err}
}
if resp.Status == 4 { // interpreting the response code
a.notifier.SendAlert(ctx, cmd.OrderID) // calling another adapter from an adapter
}
return a.mapper.ToDomainResult(resp), nil
}
Correct version:
// the adapter simply translates
func (a *PaymentAdapter) Register(ctx context.Context, cmd out.RegisterPaymentCommand) (out.RegisterPaymentResult, error) {
resp, err := a.client.RegisterPayment(ctx, a.mapper.ToSberRequest(cmd))
if err != nil {
return out.RegisterPaymentResult{}, &SberError{Op: "register", Err: err}
}
return a.mapper.ToDomainResult(resp), nil
}
Checking the amount and reacting to the response status are the handler's responsibility. The adapter returns a result or an error, and the handler decides what to do with it.
Another common mistake is a single "universal" adapter that implements several unrelated ports: PaymentPort, SmsPort, StoragePort. They can't be mixed — these are three separate packages with separate clients and their own tests.
If a handler needs to call two adapters within a single use case, it injects both ports itself and coordinates them. The adapters don't know about each other.
In short
- One separate package
adapter/out/<system>/per external system. A single package knows only its own infrastructure. - The adapter implements an interface from
core/<bc>/port/out/. Compile-time check:var _ out.PaymentPort = (*PaymentAdapter)(nil). - The mapper is a separate struct in the adapter package. It translates domain objects into the external format and back. System details (kopecks, status codes) don't leak into
core/. - Errors in two layers: the system type in the adapter, the domain type in
core/. The handler works only with types fromcore/. - Business logic in the adapter is a design mistake. The adapter translates, the handler decides.
- Coordinating multiple adapters is the handler's responsibility, not the adapters'.
What to read next
- Ports in Hexagonal in Go — how to declare a port-interface and port errors in
core/. - In Adapters in Hexagonal in Go — the symmetric side: chi handler → mapper → UseCase.
- Core layer in Hexagonal in Go — what is allowed and forbidden to import in the core.
- Bootstrap and composition root in Go — where adapters are created and how they're passed to handlers.