The Domain Concept for Hexagonal Architecture in Go (Golang)

Here is the English version of the Go (Golang) Hexagonal Architecture example, with the same structure and explanations.


πŸ“ Folder Structure

hexagonal-go/
β”œβ”€β”€ go.mod
β”œβ”€β”€ cmd/
β”‚   └── api/
β”‚       └── main.go
└── internal/
    β”œβ”€β”€ domain/
    β”‚   └── order/
    β”‚       β”œβ”€β”€ money.go
    β”‚       β”œβ”€β”€ order.go
    β”‚       β”œβ”€β”€ order_id.go
    β”‚       β”œβ”€β”€ order_status.go
    β”‚       └── repository.go
    β”œβ”€β”€ application/
    β”‚   └── order_service.go
    └── adapters/
        β”œβ”€β”€ http/
        β”‚   └── order_handler.go
        └── persistence/
            └── memory/
                └── order_repository.go

1) go.mod

module example.com/hexagonal-go

go 1.22

2) Domain Layer

internal/domain/order/order_id.go

package order

type OrderID string

internal/domain/order/order_status.go

package order

type OrderStatus string

const (
    OrderStatusCreated   OrderStatus = "CREATED"
    OrderStatusCompleted OrderStatus = "COMPLETED"
)

internal/domain/order/money.go

package order

import "fmt"

type Money struct {
    Cents int64
}

func NewMoney(cents int64) (Money, error) {
    if cents < 0 {
        return Money{}, fmt.Errorf("money cannot be negative")
    }
    return Money{Cents: cents}, nil
}

func (m Money) Add(other Money) Money {
    return Money{Cents: m.Cents + other.Cents}
}

internal/domain/order/order.go

package order

import "fmt"

type OrderItem struct {
    ProductID string
    Quantity  int
    UnitPrice Money
}

type Order struct {
    id     OrderID
    items  []OrderItem
    status OrderStatus
}

func NewOrder(id OrderID) *Order {
    return &Order{
        id:     id,
        items:  make([]OrderItem, 0),
        status: OrderStatusCreated,
    }
}

func (o *Order) ID() OrderID {
    return o.id
}

func (o *Order) Status() OrderStatus {
    return o.status
}

func (o *Order) Items() []OrderItem {
    copied := make([]OrderItem, len(o.items))
    copy(copied, o.items)
    return copied
}

func (o *Order) AddItem(productID string, quantity int, unitPrice Money) error {
    if o.status != OrderStatusCreated {
        return fmt.Errorf("order cannot be modified after completion")
    }
    if productID == "" {
        return fmt.Errorf("product id cannot be empty")
    }
    if quantity <= 0 {
        return fmt.Errorf("quantity must be greater than zero")
    }

    o.items = append(o.items, OrderItem{
        ProductID: productID,
        Quantity:  quantity,
        UnitPrice: unitPrice,
    })

    return nil
}

func (o *Order) Complete() error {
    if len(o.items) == 0 {
        return fmt.Errorf("order must have at least one item")
    }
    o.status = OrderStatusCompleted
    return nil
}

func (o *Order) Total() Money {
    total := Money{Cents: 0}
    for _, item := range o.items {
        total = total.Add(Money{Cents: item.UnitPrice.Cents * int64(item.Quantity)})
    }
    return total
}

internal/domain/order/repository.go

package order

import "context"

type Repository interface {
    Save(ctx context.Context, order *Order) error
    FindByID(ctx context.Context, id OrderID) (*Order, error)
}

3) Application Layer

internal/application/order_service.go

package application

import (
    "context"
    "fmt"
    "time"

    "example.com/hexagonal-go/internal/domain/order"
)

type OrderService struct {
    repo order.Repository
}

func NewOrderService(repo order.Repository) *OrderService {
    return &OrderService{repo: repo}
}

func (s *OrderService) CreateOrder(ctx context.Context) (*order.Order, error) {
    id := order.OrderID(fmt.Sprintf("ord_%d", time.Now().UnixNano()))
    newOrder := order.NewOrder(id)

    if err := s.repo.Save(ctx, newOrder); err != nil {
        return nil, err
    }

    return newOrder, nil
}

func (s *OrderService) AddItem(ctx context.Context, orderID order.OrderID, productID string, quantity int, priceCents int64) (*order.Order, error) {
    o, err := s.repo.FindByID(ctx, orderID)
    if err != nil {
        return nil, err
    }

    price, err := order.NewMoney(priceCents)
    if err != nil {
        return nil, err
    }

    if err := o.AddItem(productID, quantity, price); err != nil {
        return nil, err
    }

    if err := s.repo.Save(ctx, o); err != nil {
        return nil, err
    }

    return o, nil
}

func (s *OrderService) CompleteOrder(ctx context.Context, orderID order.OrderID) (*order.Order, error) {
    o, err := s.repo.FindByID(ctx, orderID)
    if err != nil {
        return nil, err
    }

    if err := o.Complete(); err != nil {
        return nil, err
    }

    if err := s.repo.Save(ctx, o); err != nil {
        return nil, err
    }

    return o, nil
}

func (s *OrderService) GetOrder(ctx context.Context, orderID order.OrderID) (*order.Order, error) {
    return s.repo.FindByID(ctx, orderID)
}

4) Persistence Adapter (In-Memory)

internal/adapters/persistence/memory/order_repository.go

package memory

import (
    "context"
    "fmt"
    "sync"

    "example.com/hexagonal-go/internal/domain/order"
)

type OrderRepository struct {
    mu     sync.RWMutex
    orders map[order.OrderID]*order.Order
}

func NewOrderRepository() *OrderRepository {
    return &OrderRepository{
        orders: make(map[order.OrderID]*order.Order),
    }
}

func (r *OrderRepository) Save(ctx context.Context, o *order.Order) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    r.orders[o.ID()] = o
    return nil
}

func (r *OrderRepository) FindByID(ctx context.Context, id order.OrderID) (*order.Order, error) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    o, ok := r.orders[id]
    if !ok {
        return nil, fmt.Errorf("order not found: %s", id)
    }

    return o, nil
}

5) HTTP Adapter

internal/adapters/http/order_handler.go

package httpadapter

import (
    "encoding/json"
    "net/http"
    "strings"

    "example.com/hexagonal-go/internal/application"
    "example.com/hexagonal-go/internal/domain/order"
)

type OrderHandler struct {
    service *application.OrderService
}

func NewOrderHandler(service *application.OrderService) *OrderHandler {
    return &OrderHandler{service: service}
}

func (h *OrderHandler) RegisterRoutes(mux *http.ServeMux) {
    mux.HandleFunc("POST /orders", h.createOrder)
    mux.HandleFunc("GET /orders/", h.getOrder)
    mux.HandleFunc("POST /orders/{id}/items", h.addItem)
    mux.HandleFunc("POST /orders/{id}/complete", h.completeOrder)
}

func (h *OrderHandler) createOrder(w http.ResponseWriter, r *http.Request) {
    o, err := h.service.CreateOrder(r.Context())
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    respondJSON(w, http.StatusCreated, map[string]any{
        "id":     o.ID(),
        "status": o.Status(),
        "total":  o.Total().Cents,
    })
}

func (h *OrderHandler) getOrder(w http.ResponseWriter, r *http.Request) {
    id := extractID(r.URL.Path)
    o, err := h.service.GetOrder(r.Context(), order.OrderID(id))
    if err != nil {
        http.Error(w, err.Error(), http.StatusNotFound)
        return
    }

    respondJSON(w, http.StatusOK, map[string]any{
        "id":     o.ID(),
        "status": o.Status(),
        "items":  o.Items(),
        "total":  o.Total().Cents,
    })
}

func (h *OrderHandler) addItem(w http.ResponseWriter, r *http.Request) {
    id := extractID(r.URL.Path)

    var req struct {
        ProductID string `json:"product_id"`
        Quantity  int    `json:"quantity"`
        PriceCents int64  `json:"price_cents"`
    }

    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid json", http.StatusBadRequest)
        return
    }

    o, err := h.service.AddItem(r.Context(), order.OrderID(id), req.ProductID, req.Quantity, req.PriceCents)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    respondJSON(w, http.StatusOK, map[string]any{
        "id":     o.ID(),
        "status": o.Status(),
        "total":  o.Total().Cents,
    })
}

func (h *OrderHandler) completeOrder(w http.ResponseWriter, r *http.Request) {
    id := extractID(r.URL.Path)
    o, err := h.service.CompleteOrder(r.Context(), order.OrderID(id))
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    respondJSON(w, http.StatusOK, map[string]any{
        "id":     o.ID(),
        "status": o.Status(),
        "total":  o.Total().Cents,
    })
}

func extractID(path string) string {
    parts := strings.Split(strings.Trim(path, "/"), "/")
    if len(parts) >= 2 {
        return parts[1]
    }
    return ""
}

func respondJSON(w http.ResponseWriter, status int, payload any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(payload)
}

6) Application Entry Point

cmd/api/main.go

package main

import (
    "log"
    "net/http"

    httpadapter "example.com/hexagonal-go/internal/adapters/http"
    "example.com/hexagonal-go/internal/adapters/persistence/memory"
    "example.com/hexagonal-go/internal/application"
)

func main() {
    repo := memory.NewOrderRepository()
    service := application.NewOrderService(repo)
    handler := httpadapter.NewOrderHandler(service)

    mux := http.NewServeMux()
    handler.RegisterRoutes(mux)

    log.Println("server started on :8080")
    if err := http.ListenAndServe(":8080", mux); err != nil {
        log.Fatal(err)
    }
}

7) How It Works

  • HTTP adapter receives the request
  • Application service executes the use case
  • Domain applies business rules
  • Repository (port) is defined in the domain
  • Adapter (memory) implements it

πŸ‘‰ The domain layer:

  • does NOT know net/http
  • does NOT know databases
  • does NOT know frameworks

8) Simple Test Example

internal/domain/order/order_test.go

package order

import "testing"

func TestOrderCompleteWithoutItems(t *testing.T) {
    o := NewOrder("1")

    if err := o.Complete(); err == nil {
        t.Fatal("expected error when completing empty order")
    }
}

func TestOrderTotal(t *testing.T) {
    o := NewOrder("1")
    price, _ := NewMoney(500)

    if err := o.AddItem("p1", 2, price); err != nil {
        t.Fatal(err)
    }

    if o.Total().Cents != 1000 {
        t.Fatalf("expected 1000, got %d", o.Total().Cents)
    }
}

9) Run the Project

go run ./cmd/api

Example requests:

curl -X POST localhost:8080/orders
curl -X POST localhost:8080/orders/ord_xxx/items \
  -H 'Content-Type: application/json' \
  -d '{"product_id":"p1","quantity":2,"price_cents":500}'
curl -X POST localhost:8080/orders/ord_xxx/complete