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