← Back to the section

As a service grows, it starts to accumulate a tangled mix: HTTP handlers with SQL queries inside them, functions with dependencies on a specific database driver, business rules mixed up with serialization details. Change one thing and you break another. Test the logic and you have to spin up half the infrastructure.

Hexagonal architecture solves this through separation: all of the service's business meaning lives in core/, isolated from any infrastructure. In this article we'll look at what goes there, how to organize it, and why it's done this way.

What the core layer is

core/ is the heart of the service. This is where aggregates, business rules, invariants, and events live. Everything that does not depend on any infrastructure: the core works without an HTTP router, without a database driver, without a message broker.

In practice the service runs on chi + pgx + Kafka, but the core doesn't know that. It gets what it needs through interfaces — and the adapters on the outside implement them.

The allowed dependencies of the core are only the Go standard library: context, errors, time, fmt, strings, math. Plus the internal core/apperr package for typed errors.

Anything related to infrastructure is forbidden: chi, pgx, go-redis, kafka-go, slog, sqlc-generated types, HTTP DTOs. If such an import shows up in core/ — something is in the wrong place.

The structure of core/

A typical layout for the order bounded context:

internal/
  core/
    apperr/
      kind.go              # apperr.Kind: Domain, Validation, Integration, Technical
      errors.go            # KindOf, NewValidation, Categorized interface
    order/
      aggregate/
        order.go           # Order aggregate (rich domain)
        order_item.go      # OrderItem entity
      value_object/
        money.go           # Money VO
        order_id.go        # OrderID typed alias
      event/
        order_confirmed.go # OrderConfirmed domain event
      port/out/
        order_repo.go      # OrderRepository interface
        payment_port.go    # PaymentPort interface
        errors.go          # PaymentPortError (port error)
      usecase/
        confirm_order.go   # ConfirmOrderCommand + ConfirmOrderHandler
        create_order.go    # CreateOrderCommand + CreateOrderHandler
      service/
        pricing_service.go # Domain Service (if cross-aggregate logic is needed)
    customer/
      aggregate/
        customer.go
      port/out/
        customer_repo.go

What's in each folder:

  • aggregate/ — the Aggregate Root and the entities it contains. The aggregate encapsulates invariants and business rules.
  • value_object/ — immutable types (Money, OrderID, CustomerEmail). Compared by value, not by pointer.
  • port/out/ — interfaces: "what the core needs from the outside world". Repositories, clients of external systems, event publishers.
  • usecase/ — Command + Handler pairs. The Handler coordinates: load the aggregate → call a domain method → save.
  • service/ — shared domain logic that doesn't fit into a single aggregate. Used rarely.

Business logic inside the aggregate

A common mistake is treating the aggregate as just a data container, with all the logic living in OrderService. This is called an "anemic model" and it creates serious problems.

Let's look at the difference.

The anemic variant — logic outside the aggregate:

// AVOID — an aggregate with no logic, logic lives in the Service
type Order struct {
    Status Status
    Items  []OrderItem
    Total  Money
}

type OrderService struct {
    orders   OrderRepository
    payments PaymentPort
}

func (s *OrderService) ConfirmOrder(ctx context.Context, id OrderID) error {
    order, _ := s.orders.FindByID(ctx, id)
    if order.Status != StatusPending { // invariant outside the aggregate
        return errors.New("wrong status")
    }
    if len(order.Items) == 0 { // duplicated in the Kafka handler, in the admin handler
        return errors.New("no items")
    }
    order.Status = StatusConfirmed
    return s.orders.Save(ctx, order)
}

Problems:

  • The Confirm checks are scattered across the HTTP handler, the Kafka consumer, the admin CLI — sooner or later one of the copies falls out of sync.
  • You can't test the order logic itself without infrastructure.
  • To understand when an order becomes Confirmed, you have to walk through every method of every service.

The correct variant — logic inside the aggregate:

// internal/core/order/aggregate/order.go
package aggregate

import (
    "errors"
    "time"

    "myservice/internal/core/order/value_object"
)

type Order struct {
    id         value_object.OrderID
    customerID value_object.CustomerID
    items      []OrderItem
    status     Status
    total      value_object.Money
    confirmedAt *time.Time
}

func (o *Order) Confirm(paymentResult PaymentResult) error {
    if o.status != StatusPending {
        return &InvalidStatusTransitionError{From: o.status, To: StatusConfirmed}
    }
    if len(o.items) == 0 {
        return &EmptyOrderError{OrderID: o.id}
    }
    if paymentResult.Amount.IsLessThan(o.total) {
        return &InsufficientPaymentError{Required: o.total, Provided: paymentResult.Amount}
    }
    now := time.Now().UTC()
    o.status = StatusConfirmed
    o.confirmedAt = &now
    return nil
}

All of the order's invariants are in one place. The Handler becomes thin coordination:

// internal/core/order/usecase/confirm_order.go
package usecase

import (
    "context"
    "fmt"

    "myservice/internal/core/order/port/out"
)

type ConfirmOrderCommand struct {
    OrderID    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,
        Amount:  order.Total(),
    })
    if err != nil {
        return fmt.Errorf("register payment: %w", err)
    }
    if err := order.Confirm(aggregate.PaymentResult{Amount: result.Amount}); err != nil {
        return err
    }
    return h.orders.Save(ctx, order)
}

The Handler doesn't know about HTTP, doesn't know about SQL — it works with port interfaces and calls the aggregate's methods.

Errors as values

In Go, errors are values, not exceptions. Domain errors in the core are typed structs with a category:

// internal/core/order/aggregate/errors.go
package aggregate

import "myservice/internal/core/apperr"

type InvalidStatusTransitionError struct {
    From Status
    To   Status
}

func (e *InvalidStatusTransitionError) Error() string {
    return "invalid status transition: " + string(e.From) + " → " + string(e.To)
}

func (e *InvalidStatusTransitionError) Kind() apperr.Kind { return apperr.Domain }

type EmptyOrderError struct{ OrderID OrderID }

func (e *EmptyOrderError) Error() string {
    return "order " + string(e.OrderID) + " has no items"
}

func (e *EmptyOrderError) Kind() apperr.Kind { return apperr.Validation }

Errors of port interfaces are declared in core/<bc>/port/out/, not in the adapter:

// internal/core/order/port/out/errors.go
package out

import "myservice/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 }

The Handler catches *out.PaymentPortError via errors.As — without being tied to a specific adapter. The HTTP handler on the outside decides, based on apperr.Kind, whether to return a 400 or a 500.

How to pass dependencies

Go has no DI annotations. The core exports constructors; assembling all the dependencies happens only in bootstrap/main.go:

// The constructor takes dependencies through port interfaces
func NewConfirmOrderHandler(orders out.OrderRepository, payments out.PaymentPort) *ConfirmOrderHandler {
    return &ConfirmOrderHandler{orders: orders, payments: payments}
}

What you shouldn't do:

// AVOID — a global singleton
var globalOrderRepo *persistence.OrderRepository

func init() {
    globalOrderRepo = persistence.NewOrderRepository(globalDB)
}

init() creates a database connection right inside the package, and it's impossible to override in tests. Only constructors and explicit assembly in main.go.

Common beginner mistakes

An infrastructure import ended up in core/. For example, pgx or chi. This is a signal that business logic has leaked into the adapter, or that adapter code ended up in the core. It needs to be moved to the right package.

A sqlc-generated type as an aggregate field. db.Order is a persistence detail; it knows about the table structure. The aggregate should have its own type aggregate.Order; the mapping between them lives in adapter/out/persistence/.

An HTTP DTO in the core. CreateOrderRequest from the HTTP handler must not end up in usecase/. Instead, use usecase.CreateOrderCommand with domain types; the mapping from DTO to command is done in adapter/in/http/.

Logic in OrderService instead of the aggregate. The order state checks will scatter across the whole codebase. Invariants belong to the aggregate.

In short

  • core/ depends only on the Go standard library; no chi, pgx, kafka-go, slog.
  • The core includes: aggregates, value objects, domain events, port interfaces (port/out/), use case handlers, domain services.
  • Business logic lives inside the aggregate's methods — order.Confirm() encapsulates all the invariants. Logic in *Service structs is a common mistake that leads to duplication.
  • The Handler is thin coordination: load the aggregate → call a domain method → save.
  • Domain errors are typed structs with a category (apperr.Kind), not strings.
  • Port errors are declared in core/<bc>/port/out/, not in the adapter.
  • Dependencies are passed through constructors; assembly happens only in bootstrap/main.go.
  • Ports in Go — port interfaces in port/out/, port errors, errors.As in the handler.
  • In adapters in Go — the chi handler, the request → command mapper.
  • Out adapters in Go — implementing the port interface, the domain ↔ persistence mapper.
  • Bootstrap and Composition Root — assembling dependencies in main.go, graceful shutdown.
  • Package structure — package layout, banning cross-adapter imports.