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
Confirmchecks 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*Servicestructs 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.
What to read next
- Ports in Go — port interfaces in
port/out/, port errors,errors.Asin 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.