Hexagonal Architecture in a Go Monorepo

A practical walkthrough of ports & adapters in Go. From project layout to dependency injection.

Most Go projects I’ve seen start clean and get messy fast. A handler that started as 15 lines quietly absorbs database calls, business rules, and HTTP concerns until you can’t test any of it without spinning up Postgres.

Hexagonal Architecture (a.k.a. Ports & Adapters) is a way out. This is how I structure Go services that stay testable as they grow.

The core idea

Your domain — business logic, entities, rules — lives at the centre. It knows nothing about HTTP, databases, or message queues. Those are adapters that plug into ports (interfaces the domain defines).

Project layout

cmd/api/main.go // wires everything together
internal/domain/ // pure business logic — no imports from infra
internal/domain/workflow.go // entities + domain services
internal/ports/ // interfaces (the sockets)
internal/ports/repository.go // what the domain needs from storage
internal/ports/notifier.go // what the domain needs to send events
internal/adapters/postgres/ // implements ports.Repository
internal/adapters/nats/ // implements ports.Notifier
internal/app/ // application services — orchestrates domain + ports

Defining a port

A port is just a Go interface. It lives in the domain layer and describes what the domain needs — not what infrastructure provides.

// internal/ports/repository.go
package ports

import (
    "context"
    "github.com/you/iris/internal/domain"
)

type WorkflowRepository interface {
    Save(ctx context.Context, w domain.Workflow) error
    FindByID(ctx context.Context, id string) (domain.Workflow, error)
    ListPending(ctx context.Context) ([]domain.Workflow, error)
}

Notice: no *sql.DB, no pgx, no nothing. The domain doesn’t care how data is stored.

The domain entity

// internal/domain/workflow.go
package domain

import (
    "errors"
    "time"
)

type Status string

const (
    StatusPending  Status = "pending"
    StatusRunning  Status = "running"
    StatusDone     Status = "done"
    StatusFailed   Status = "failed"
)

type Workflow struct {
    ID        string
    Name      string
    Status    Status
    CreatedAt time.Time
    UpdatedAt time.Time
}

// domain logic lives here, not in handlers
func (w *Workflow) Start() error {
    if w.Status != StatusPending {
        return errors.New("workflow must be pending to start")
    }
    w.Status = StatusRunning
    w.UpdatedAt = time.Now()
    return nil
}

func (w *Workflow) Complete() error {
    if w.Status != StatusRunning {
        return errors.New("workflow must be running to complete")
    }
    w.Status = StatusDone
    w.UpdatedAt = time.Now()
    return nil
}

An application service

The application layer orchestrates — it calls domain logic and uses ports to persist or notify.

// internal/app/workflow_service.go
package app

import (
    "context"
    "fmt"

    "github.com/you/iris/internal/domain"
    "github.com/you/iris/internal/ports"
)

type WorkflowService struct {
    repo     ports.WorkflowRepository
    notifier ports.Notifier
}

func NewWorkflowService(
    repo ports.WorkflowRepository,
    notifier ports.Notifier,
) *WorkflowService {
    return &WorkflowService{repo: repo, notifier: notifier}
}

func (s *WorkflowService) StartWorkflow(ctx context.Context, id string) error {
    w, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return fmt.Errorf("find workflow: %w", err)
    }

    if err := w.Start(); err != nil {
        return fmt.Errorf("start workflow: %w", err)      // domain rule enforced here
    }

    if err := s.repo.Save(ctx, w); err != nil {
        return fmt.Errorf("save workflow: %w", err)
    }

    s.notifier.Publish(ctx, "workflow.started", w.ID)
    return nil
}

The Postgres adapter

The adapter implements the port interface. This is the only place Postgres knowledge lives.

// internal/adapters/postgres/workflow_repo.go
package postgres

import (
    "context"
    "fmt"

    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/you/iris/internal/domain"
)

type WorkflowRepo struct {
    db *pgxpool.Pool
}

func NewWorkflowRepo(db *pgxpool.Pool) *WorkflowRepo {
    return &WorkflowRepo{db: db}
}

func (r *WorkflowRepo) FindByID(ctx context.Context, id string) (domain.Workflow, error) {
    var w domain.Workflow
    err := r.db.QueryRow(ctx,
        `SELECT id, name, status, created_at, updated_at
         FROM workflows WHERE id = $1`, id,
    ).Scan(&w.ID, &w.Name, &w.Status, &w.CreatedAt, &w.UpdatedAt)
    if err != nil {
        return w, fmt.Errorf("query workflow: %w", err)
    }
    return w, nil
}

// Save, ListPending ...

Wiring it up in main

// cmd/api/main.go
package main

import (
    "log"
    "os"

    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/you/iris/internal/adapters/nats"
    "github.com/you/iris/internal/adapters/postgres"
    "github.com/you/iris/internal/app"
)

func main() {
    db, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Fatal(err)
    }

    repo     := postgres.NewWorkflowRepo(db)
    notifier := nats.NewNotifier(os.Getenv("NATS_URL"))
    svc      := app.NewWorkflowService(repo, notifier)

    // pass svc into HTTP handlers, gRPC servers, etc.
    startHTTPServer(svc)
}

main.go is the only place that knows about Postgres, NATS, and HTTP simultaneously. Everything else is cleanly separated.

Testing without infrastructure

The real payoff: you can test WorkflowService with a fake repository — no Docker, no Postgres.

// internal/app/workflow_service_test.go
package app_test

import (
    "context"
    "testing"

    "github.com/you/iris/internal/app"
    "github.com/you/iris/internal/domain"
)

// in-memory fake — implements ports.WorkflowRepository
type fakeRepo struct {
    store map[string]domain.Workflow
}

func (f *fakeRepo) FindByID(_ context.Context, id string) (domain.Workflow, error) {
    return f.store[id], nil
}
func (f *fakeRepo) Save(_ context.Context, w domain.Workflow) error {
    f.store[w.ID] = w
    return nil
}
func (f *fakeRepo) ListPending(_ context.Context) ([]domain.Workflow, error) {
    return nil, nil
}

func TestStartWorkflow(t *testing.T) {
    repo := &fakeRepo{store: map[string]domain.Workflow{
        "wf-1": {ID: "wf-1", Status: domain.StatusPending},
    }}
    svc := app.NewWorkflowService(repo, &fakeNotifier{})

    if err := svc.StartWorkflow(context.Background(), "wf-1"); err != nil {
        t.Fatal(err)
    }

    if repo.store["wf-1"].Status != domain.StatusRunning {
        t.Error("expected workflow to be running")
    }
}

When not to use it

Hexagonal architecture adds indirection. For a 200-line script or a simple CRUD API, it’s overkill. Reach for it when:

  • You have real business rules that need to be tested in isolation
  • You expect to swap infrastructure (e.g. SQLite in dev, Postgres in prod)
  • Multiple teams touch the same codebase and need clear boundaries

The full source for Hermes, my workflow automation platform is structured this way. It’s made adding new triggers, adapters, and executors significantly easier than if I’d put everything in handlers from the start.