← Back to the section

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 only net/http and the Sber DTOs. adapter/out/persistence/ — only pgx and 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 from core/.
  • 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'.