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.
Why architecture matters at all
Section titled “Why architecture matters at all”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.
1. Layered (N-Tier) Architecture
Section titled “1. Layered (N-Tier) Architecture”The simplest and most common starting point. Code is organized into horizontal layers, typically:
handler (HTTP) → service (business logic) → repository (database)
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.gofunc (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.gofunc (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.
2. Clean Architecture
Section titled “2. Clean Architecture”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.

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 importspackage 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 providedpackage 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 definedpackage 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.

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 offerspackage core
// Driven port — what the core needs from the outsidetype 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 outsidetype OrderService interface { CreateOrder(ctx context.Context, customerID string, items []Item) (*Order, error)}// core/order_service.go — the application core, only depends on its own portspackage 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 adaptertype 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 teststype 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.
4. Domain-Driven Design (DDD)
Section titled “4. Domain-Driven Design (DDD)”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
Orderis 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).

// domain/money.go — a value object: no ID, compared by valuetype 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 ittype 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 servicesfunc (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 aggregatestype 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.
5. A couple of others you’ll encounter
Section titled “5. A couple of others you’ll encounter”Modular Monolith
Section titled “Modular Monolith”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 rulesfunc (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 directlyfunc (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.
Choosing one, practically
Section titled “Choosing one, practically”| 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.
Learning resources
Section titled “Learning resources”- Clean Architecture in Go (bxcodec/go-clean-arch) — a widely referenced open-source example project.
- Standard Go Project Layout — community conventions for structuring Go repositories.
- Hexagonal Architecture — Alistair Cockburn — the original description of ports and adapters.
- Domain-Driven Design Reference — Eric Evans — a free condensed reference to DDD’s core patterns and vocabulary.
- A Pattern Language for Microservices — includes CQRS, aggregates, and related patterns with broader context.