DEV Community

Cover image for Your Repository Is Not Your ORM: Hexagonal Persistence in Go
Gabriel Anhaia
Gabriel Anhaia

Posted on

Your Repository Is Not Your ORM: Hexagonal Persistence in Go


You open a Go service that calls itself hexagonal. The domain package has an Order struct. It also has db tags on every field, a gorm.Model embed, and a BeforeSave hook that writes to an audit table. The "repository" is a thin pass-through to GORM's Create and First. Somebody drew the hexagon on a whiteboard, then let the ORM walk straight through the middle of it.

This is the most common way hexagonal architecture dies in Go. Not because the team skipped the pattern, but because they let the persistence library define the domain type. Once Order knows about table names and foreign keys, the core is no longer the core. It is a thin opinion on top of the database schema.

A repository is a port. It belongs to the domain. The ORM, the query builder, the raw pgx calls all belong to one adapter that satisfies the port. The line between them is the whole point. This post is about keeping that line sharp in Go, with sqlc and pgx doing the SQL at the edge.

A repository is a domain interface

Start from what the domain needs, not from what the database has. The orders code wants to load an order by ID and save one back. That is the contract:

// internal/domain/order.go
package domain

type Order struct {
    ID         OrderID
    CustomerID CustomerID
    Lines      []Line
    Status     Status
}

func (o *Order) Confirm() error {
    if len(o.Lines) == 0 {
        return ErrEmptyOrder
    }
    o.Status = StatusConfirmed
    return nil
}
Enter fullscreen mode Exit fullscreen mode
// internal/domain/repository.go
package domain

import "context"

type OrderRepository interface {
    ByID(ctx context.Context, id OrderID) (*Order, error)
    Save(ctx context.Context, o *Order) error
}
Enter fullscreen mode Exit fullscreen mode

Read what is missing. No db tag. No *sql.DB. No table name. No SQL string. The domain package imports context and nothing else from the outside. Order has behavior. Confirm enforces a rule, and the rule lives next to the data it guards.

The interface is the port. The application service depends on OrderRepository, never on a concrete database type. A thin confirm service shows the shape:

// internal/domain/confirm_service.go
package domain

import "context"

type ConfirmService struct {
    repo OrderRepository
}

func NewConfirmService(r OrderRepository) *ConfirmService {
    return &ConfirmService{repo: r}
}

func (s *ConfirmService) Confirm(
    ctx context.Context, id OrderID,
) error {
    o, err := s.repo.ByID(ctx, id)
    if err != nil {
        return err
    }
    if err := o.Confirm(); err != nil {
        return err
    }
    return s.repo.Save(ctx, o)
}
Enter fullscreen mode Exit fullscreen mode

That is what lets you swap Postgres for an in-memory fake in a unit test without the domain noticing.

The ORM is an adapter detail, not a domain type

The trap is mapping the ORM struct one-to-one onto the domain struct and calling it a day. The moment you do that, every schema decision leaks upward. Add a deleted_at column for soft deletes and your domain type grows a nullable timestamp it has no business knowing about.

Keep two types. The domain Order above, and a row type that mirrors the table. With sqlc you do not even write the row type by hand — it generates one from your schema:

-- db/query/orders.sql
-- name: GetOrder :one
SELECT id, customer_id, status
FROM orders
WHERE id = $1;

-- name: GetOrderLines :many
SELECT order_id, sku, quantity, price_cents
FROM order_lines
WHERE order_id = $1;
Enter fullscreen mode Exit fullscreen mode

sqlc generate produces typed Go for each query: an OrdersQuerier, a GetOrderRow, a GetOrderLinesRow. Those generated types live in the adapter package, internal/adapter/postgres. They never cross into domain. The compiler will not let them, because domain does not import the adapter.

Mapping rows to aggregates

The adapter's job is two-way translation. Database rows in, domain aggregate out. Domain aggregate in, parameters out. That mapping is the seam where the schema and the model are allowed to disagree.

// internal/adapter/postgres/order_repo.go
package postgres

import (
    "context"
    "errors"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"

    "github.com/acme/orders/internal/adapter/postgres/db"
    "github.com/acme/orders/internal/domain"
)

type OrderRepo struct {
    q    *db.Queries // sqlc-generated
    pool *pgxpool.Pool
}

func NewOrderRepo(pool *pgxpool.Pool) *OrderRepo {
    return &OrderRepo{q: db.New(pool), pool: pool}
}

func (r *OrderRepo) ByID(
    ctx context.Context, id domain.OrderID,
) (*domain.Order, error) {
    row, err := r.q.GetOrder(ctx, string(id))
    if errors.Is(err, pgx.ErrNoRows) {
        return nil, domain.ErrOrderNotFound
    }
    if err != nil {
        return nil, err
    }
    lines, err := r.q.GetOrderLines(ctx, string(id))
    if err != nil {
        return nil, err
    }
    return toDomain(row, lines), nil
}
Enter fullscreen mode Exit fullscreen mode

The mapper is a plain function. It takes generated row types and returns a domain aggregate. No reflection, no tags, just field assignment:

// internal/adapter/postgres/mapper.go
package postgres

import (
    "github.com/acme/orders/internal/adapter/postgres/db"
    "github.com/acme/orders/internal/domain"
)

func toDomain(
    o db.GetOrderRow, rows []db.GetOrderLinesRow,
) *domain.Order {
    lines := make([]domain.Line, 0, len(rows))
    for _, l := range rows {
        lines = append(lines, domain.Line{
            SKU:        l.Sku,
            Quantity:   int(l.Quantity),
            PriceCents: l.PriceCents,
        })
    }
    return &domain.Order{
        ID:         domain.OrderID(o.ID),
        CustomerID: domain.CustomerID(o.CustomerID),
        Status:     domain.Status(o.Status),
        Lines:      lines,
    }
}
Enter fullscreen mode Exit fullscreen mode

This function is where a deleted_at column dies quietly. The row type has it; the domain type does not; the mapper ignores it. The schema can carry as many operational columns as the DBA wants, and none of them reach the model. When the database stores status as a smallint and the domain wants a typed string enum, the translation happens here and nowhere else.

The write side maps the other way

Save runs the reverse. It pulls the aggregate apart into the parameters each statement needs. An order with line items is more than one table, so the write is a transaction:

func (r *OrderRepo) Save(
    ctx context.Context, o *domain.Order,
) error {
    return pgx.BeginFunc(ctx, r.pool,
        func(tx pgx.Tx) error {
            qtx := r.q.WithTx(tx)
            err := qtx.UpsertOrder(ctx, db.UpsertOrderParams{
                ID:         string(o.ID),
                CustomerID: string(o.CustomerID),
                Status:     string(o.Status),
            })
            if err != nil {
                return err
            }
            if err := qtx.DeleteOrderLines(
                ctx, string(o.ID),
            ); err != nil {
                return err
            }
            for _, l := range o.Lines {
                err := qtx.InsertOrderLine(ctx,
                    db.InsertOrderLineParams{
                        OrderID:    string(o.ID),
                        Sku:        l.SKU,
                        Quantity:   int32(l.Quantity),
                        PriceCents: l.PriceCents,
                    })
                if err != nil {
                    return err
                }
            }
            return nil
        })
}
Enter fullscreen mode Exit fullscreen mode

The transaction boundary lives in the adapter, where it belongs. The domain service called repo.Save(ctx, order) and has no idea three tables and a transaction were involved. The aggregate is the unit of consistency; the repository makes the persistence match that unit. WithTx is the sqlc mechanism that runs the generated queries inside the transaction you opened.

Why not just let the ORM do all of this

The honest answer: an ORM can map rows to structs, and for a CRUD admin panel that is fine. The cost shows up when the domain type and the table stop being the same shape.

An aggregate is loaded and saved as a whole. A table is rows. The moment your Order owns its Line items as a value-object slice, "save the order" means "upsert one row, replace N rows, in one transaction." ORMs model that with cascade options, association modes, and dirty-tracking: configuration that lives on the struct, in tags, coupling the model to the persistence rules. The explicit mapper makes the same logic readable Go that a new engineer can step through in a debugger.

You also get to test the domain without a database. Because OrderRepository is an interface in the domain package, the unit test for Order.Confirm and the service that calls it uses a map-backed fake:

// internal/domain/order_test.go
package domain_test

import (
    "context"
    "testing"

    "github.com/acme/orders/internal/domain"
)

type fakeRepo struct {
    store map[domain.OrderID]*domain.Order
}

func (f *fakeRepo) ByID(
    _ context.Context, id domain.OrderID,
) (*domain.Order, error) {
    o, ok := f.store[id]
    if !ok {
        return nil, domain.ErrOrderNotFound
    }
    return o, nil
}

func (f *fakeRepo) Save(
    _ context.Context, o *domain.Order,
) error {
    f.store[o.ID] = o
    return nil
}

func TestConfirmEmptyOrderFails(t *testing.T) {
    o := &domain.Order{ID: "o-1"}
    if err := o.Confirm(); err == nil {
        t.Fatal("expected error for empty order")
    }
}

func TestConfirmServiceSavesOrder(t *testing.T) {
    repo := &fakeRepo{
        store: map[domain.OrderID]*domain.Order{
            "o-1": {
                ID:    "o-1",
                Lines: []domain.Line{{SKU: "abc"}},
            },
        },
    }
    svc := domain.NewConfirmService(repo)
    if err := svc.Confirm(
        context.Background(), "o-1",
    ); err != nil {
        t.Fatalf("confirm failed: %v", err)
    }
    got := repo.store["o-1"]
    if got.Status != domain.StatusConfirmed {
        t.Fatalf("status = %q, want confirmed",
            got.Status)
    }
}
Enter fullscreen mode Exit fullscreen mode

No Postgres, no testcontainers, no migration step. The fake satisfies the port because the port is small and lives in the domain. The pgx adapter gets its own integration test against a real database, separately, on its own schedule.

Where the line sits

Three rules keep the repository honest:

  1. The repository interface lives in domain and speaks only domain types. If a signature mentions pgx, sql.Rows, or a generated row type, the port has leaked.
  2. SQL exists in exactly one place: the adapter package. sqlc keeps it in .sql files, type-checked against the schema at generate time. The domain never sees a query string.
  3. The mapper is plain code, not reflection. Every field crossing the boundary is assigned by hand, which means every schema-versus-model disagreement is visible in one function.

Get those three right and the database becomes swappable in the way the hexagon promised. Move from pgx to a different driver, add a read replica, put a cache in front — the domain does not change, because it was never holding a database in the first place. It was holding a port.


If this was useful

The repository-as-port split, the mapper seam, and the transaction-per-aggregate rule are the spine of the persistence chapter in Hexagonal Architecture in Go. The book walks the same sqlc-and-pgx setup end to end: where the ports live, how the adapters wire up, and how to grow the read and write sides without dragging SQL back into the core. The Complete Guide to Go Programming covers the language pieces the adapters lean on — context, error wrapping, and the database/sql foundations underneath pgx.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)