Skip to content

Software Architectures in Go

This is a short, practical tour of the architectural styles you’ll run into most often in Go backends. The goal isn’t to make you an expert in any single one — it’s to help you recognize them, understand the problem each one solves, and see roughly what they look like in real Go code.

All examples use the same small domain — an Order in an e-commerce system — so you can compare how each architecture organizes the same piece of logic.

Every architecture style below is answering the same underlying question: where should a dependency point?

Specifically: should your business logic (the rules that make your product what it is) depend on your database, your web framework, and your third-party libraries — or should those things depend on your business logic?

Most of the patterns below exist to keep business logic independent of frameworks and infrastructure, so you can test it, change your database, or swap your web framework without rewriting how your application actually works.

The simplest and most common starting point. Code is organized into horizontal layers, typically:

handler (HTTP) → service (business logic) → repository (database)

N-tier architecture diagram

handler/order_handler.go
func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
json.NewDecoder(r.Body).Decode(&req)
order, err := h.orderService.Create(r.Context(), req.CustomerID, req.Items)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
json.NewEncoder(w).Encode(order)
}
// service/order_service.go
func (s *OrderService) Create(ctx context.Context, customerID string, items []Item) (*Order, error) {
if len(items) == 0 {
return nil, errors.New("order must have at least one item")
}
order := NewOrder(customerID, items)
return order, s.repo.Save(ctx, order)
}
// repository/order_repository.go
func (r *OrderRepository) Save(ctx context.Context, order *Order) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO orders (id, customer_id, total) VALUES ($1, $2, $3)`,
order.ID, order.CustomerID, order.Total())
return err
}

Good for: small services, CRUD-heavy apps, teams that want something easy to onboard into.

Watch out for: layers quietly depending on each other in both directions (service importing handler types, repository leaking SQL concerns into service), which erodes the whole point of having layers.

Clean Architecture (popularized by Robert “Uncle Bob” Martin) makes the layered idea stricter with one rule: dependencies only point inward, toward the business logic. Outer layers (frameworks, databases, HTTP) know about inner layers; inner layers never know about outer ones.

Clean architecture diagram

entities (core business rules)
↑
use cases (application logic)
↑
interface adapters (controllers, presenters, gateways)
↑
frameworks & drivers (HTTP, database, CLI)

The inner layers define interfaces; outer layers implement them. This is what keeps the dependency pointing inward.

// entity/order.go — core business rule, no framework imports
package entity
type Order struct {
ID string
CustomerID string
Items []Item
}
func NewOrder(customerID string, items []Item) (*Order, error) {
if len(items) == 0 {
return nil, errors.New("order must have at least one item")
}
return &Order{ID: uuid.New().String(), CustomerID: customerID, Items: items}, nil
}
func (o *Order) Total() float64 {
var total float64
for _, item := range o.Items {
total += item.Price * float64(item.Quantity)
}
return total
}
// usecase/create_order.go — defines what it needs, not how it's provided
package usecase
type OrderRepository interface {
Save(ctx context.Context, order *entity.Order) error
}
type CreateOrderUseCase struct {
repo OrderRepository
}
func (uc *CreateOrderUseCase) Execute(ctx context.Context, customerID string, items []entity.Item) (*entity.Order, error) {
order, err := entity.NewOrder(customerID, items)
if err != nil {
return nil, err
}
return order, uc.repo.Save(ctx, order)
}
// adapter/postgres_order_repository.go — implements the interface the use case defined
package adapter
type PostgresOrderRepository struct {
db *sql.DB
}
func (r *PostgresOrderRepository) Save(ctx context.Context, order *entity.Order) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO orders (id, customer_id, total) VALUES ($1, $2, $3)`,
order.ID, order.CustomerID, order.Total())
return err
}

Notice usecase never imports database/sql or any HTTP package. entity never imports anything outside the standard library. That’s the whole point: your business rules don’t know or care that Postgres and HTTP exist.

Good for: applications where business logic is complex enough to be worth protecting, and where you expect infrastructure (database, APIs, frameworks) to change over the project’s life.

Watch out for: applying this to a simple CRUD service. The extra layers and interfaces are a cost — pay it when the business logic actually justifies it.

3. Hexagonal Architecture (Ports & Adapters)

Section titled “3. Hexagonal Architecture (Ports & Adapters)”

Hexagonal architecture, from Alistair Cockburn, is close in spirit to Clean Architecture but frames it differently: your application core defines ports (interfaces describing what it needs or offers), and the outside world connects through adapters that implement those ports.

The useful mental model: your core application sits in the middle. Anything that talks to it — a REST API, a CLI, a test, a message queue consumer — is a driving (primary) adapter. Anything it talks to — a database, an email service, a payment gateway — is a driven (secondary) adapter.

Hexagonal architecture diagram

HTTP handler ──┐
CLI command ───┼──► [ Port: OrderService ] ──► Application Core
gRPC server ───┘ │
▼
[ Port: OrderRepository ]
│ │
Postgres adapter In-memory adapter (tests)
// core/port.go — the ports: what the core needs and offers
package core
// Driven port — what the core needs from the outside
type OrderRepository interface {
Save(ctx context.Context, order *Order) error
FindByID(ctx context.Context, id string) (*Order, error)
}
// Driving port — what the core offers to the outside
type OrderService interface {
CreateOrder(ctx context.Context, customerID string, items []Item) (*Order, error)
}
// core/order_service.go — the application core, only depends on its own ports
package core
type orderService struct {
repo OrderRepository
}
func NewOrderService(repo OrderRepository) OrderService {
return &orderService{repo: repo}
}
func (s *orderService) CreateOrder(ctx context.Context, customerID string, items []Item) (*Order, error) {
order, err := NewOrder(customerID, items)
if err != nil {
return nil, err
}
return order, s.repo.Save(ctx, order)
}
// adapter/http/order_handler.go — a driving adapter
type OrderHandler struct {
service core.OrderService // depends on the port, not a concrete type
}
func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
json.NewDecoder(r.Body).Decode(&req)
order, err := h.service.CreateOrder(r.Context(), req.CustomerID, req.Items)
// ...
}
// adapter/memory/order_repository.go — a driven adapter, swappable for tests
type InMemoryOrderRepository struct {
orders map[string]*core.Order
}
func (r *InMemoryOrderRepository) Save(ctx context.Context, order *core.Order) error {
r.orders[order.ID] = order
return nil
}

Because OrderService only depends on the OrderRepository port, swapping Postgres for an in-memory map in tests — or for Redis, or for a different database entirely — never touches the core logic.

Clean vs. Hexagonal, practically: in Go, these two end up looking very similar — both put interfaces at the center and keep business logic dependency-free. Clean Architecture tends to specify more named layers (entities, use cases, adapters, frameworks); Hexagonal is looser and just asks “is this driving the core, or is the core driving it?” Many Go projects blend the two without worrying about which name applies.

Good for: services with multiple entry points (HTTP + CLI + message consumer) or where you want to swap infrastructure easily, especially in tests.

DDD isn’t an alternative to Clean or Hexagonal architecture — it’s usually paired with one of them. Where Clean/Hexagonal organize your code, DDD is about organizing your understanding of the business domain and expressing it directly in code, using language the business actually uses (the “ubiquitous language”).

A few building blocks you’ll see in Go DDD code:

  • Entity — has identity that persists over time, even if its data changes (an Order is still the same order after you add an item).
  • Value Object — has no identity, defined entirely by its value (Money, Address). Two value objects with the same data are interchangeable.
  • Aggregate — a cluster of entities/value objects treated as one unit for consistency, with one aggregate root controlling access.
  • Repository — loads and saves whole aggregates, not individual rows.
  • Domain event — something meaningful that happened in the domain (OrderPlaced, PaymentFailed).

DDD architecture diagram

// domain/money.go — a value object: no ID, compared by value
type Money struct {
Amount int64 // stored as cents to avoid float rounding issues
Currency string
}
func (m Money) Add(other Money) (Money, error) {
if m.Currency != other.Currency {
return Money{}, errors.New("currency mismatch")
}
return Money{Amount: m.Amount + other.Amount, Currency: m.Currency}, nil
}
// domain/order.go — Order is the aggregate root; OrderLine only exists through it
type Order struct {
id OrderID
customerID CustomerID
lines []OrderLine // internal to the aggregate
status OrderStatus
events []DomainEvent
}
// Business rules live on the aggregate, not scattered across services
func (o *Order) AddLine(product ProductID, qty int, price Money) error {
if o.status != StatusDraft {
return errors.New("cannot modify a confirmed order")
}
if qty <= 0 {
return errors.New("quantity must be positive")
}
o.lines = append(o.lines, OrderLine{Product: product, Quantity: qty, Price: price})
return nil
}
func (o *Order) Confirm() error {
if len(o.lines) == 0 {
return errors.New("cannot confirm an empty order")
}
o.status = StatusConfirmed
o.events = append(o.events, OrderConfirmed{OrderID: o.id, OccurredAt: time.Now()})
return nil
}
// domain/order_repository.go — repositories work with whole aggregates
type OrderRepository interface {
Save(ctx context.Context, order *Order) error
FindByID(ctx context.Context, id OrderID) (*Order, error)
}

The key difference from the layered example at the top: Order.AddLine and Order.Confirm are the business rules, enforced inside the aggregate itself. There’s no separate “service” deciding whether an order is allowed to be confirmed — the Order type guards its own consistency. A service layer on top of this still exists, but it mostly coordinates (load aggregate → call method → save aggregate) rather than containing the rules itself.

Good for: genuinely complex business domains — lots of rules, lots of edge cases, a domain experts actually have strong opinions about. Not every “order” needs an aggregate root; a simple CRUD order might just need a struct and a table.

Watch out for: applying DDD vocabulary to simple data without complex rules. If your “aggregate” is just a database row, you’ve added ceremony without benefit.

Not a layering strategy — a way of organizing a single deployable service into independent modules (e.g., orders/, inventory/, billing/), each with its own internal layering (Clean, Hexagonal, or plain), communicating through well-defined interfaces instead of directly reaching into each other’s internals.

/orders
/domain
/usecase
/adapter
/inventory
/domain
/usecase
/adapter
/billing
...

This is usually the right starting point for most products: you get clear boundaries without the operational cost of running separate services. Split a module into its own deployed service later, only when you have a concrete reason (team ownership, independent scaling, different release cadence).

CQRS (Command Query Responsibility Segregation)

Section titled “CQRS (Command Query Responsibility Segregation)”

Separates the models used for writing data (commands, which change state) from the models used for reading it (queries, which just return data). In Go, this often just means: your write path goes through the domain model and its business rules, while your read path queries a simpler, flatter structure (sometimes even a different table or database) optimized for the UI.

// Command side — goes through the domain model and its rules
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) error {
order, err := domain.NewOrder(cmd.CustomerID, cmd.Items)
if err != nil {
return err
}
return h.repo.Save(ctx, order)
}
// Query side — bypasses the domain model, reads a flat view directly
func (h *GetOrderSummaryHandler) Handle(ctx context.Context, q GetOrderSummaryQuery) (OrderSummaryView, error) {
return h.readDB.QueryOrderSummary(ctx, q.OrderID) // simple SQL, no domain logic
}

Useful when your read and write patterns genuinely diverge (complex writes, simple high-volume reads). Overkill for most CRUD endpoints — it adds a second model and, often, a second data store to keep in sync.

If your situation is… Reach for…
Small service, simple CRUD, short-lived or low-complexity Plain layered architecture
Business logic with real rules you want to protect from framework churn Clean or Hexagonal Architecture
Multiple entry points (HTTP, CLI, queue consumer) or heavy test-swapping needs Hexagonal Architecture
A domain with genuinely complex rules and active input from domain experts DDD, layered on top of Clean/Hexagonal
One codebase, multiple business areas, not ready for microservices Modular Monolith
Reads and writes have very different shapes or scaling needs CQRS, for that specific part of the system

A reasonable default for most new Go backend services: a modular monolith, with each module internally organized using a lightweight version of Clean or Hexagonal Architecture, adding DDD patterns only in the modules where the business rules are actually complex enough to need them. Add CQRS or split into services later, only when you can point to a concrete reason.

Architecture is a tool for managing complexity and change — not a goal in itself. The simplest structure that keeps your business logic testable and your dependencies pointing the right way is usually the right one.