← Back to list

Hex architecture simplified part 2

The idea is:

urgensherpa · 2026-04-20 08:21 · 0 claps · 5.2 min read
#hexagonal-architecture #self-note #notetaking
Open on Medium ↗
Wiki topics: 🏛️ · Architecture

Hex architecture simplified part 2

The idea is:

Kitchen = domain logic which only makes sandwiches

Input port = what can ask people ask the kitchen to do?

Output port = what the kitchen needs someone else to do?

REST adapter = waiter taking HTTP orders

SQLite adapter = storage clerk writing orders to the database

The kitchen only knows sandwich rules. It does not know HTTP, SQL, JSON, or SQLite.

sandwich-shop/
├── main.go
├── domain/
│   └── order.go
├── ports/
│   ├── input.go
│   └── output.go
├── application/
│   └── kitchen.go
└── adapters/
    ├── rest/
    │   └── handler.go
    └── sqlite/
        └── repository.go

1) Domain model — the kitchen language only

domain/order.go

package domain

import (
 "errors"
 "fmt"
 "strings"
)

type SandwichType string

const (
 HamCheese SandwichType = "ham_cheese"
 Veggie    SandwichType = "veggie"
 Tuna      SandwichType = "tuna"
)

type Order struct {
 ID       int64
 Customer string
 Type     SandwichType
 Quantity int
 TotalCents int
 Status   string
}

func NewOrder(customer string, sandwichType SandwichType, quantity int) (Order, error) {
 customer = strings.TrimSpace(customer)
 if customer == "" {
  return Order{}, errors.New("customer name is required")
 }

 if quantity <= 0 {
  return Order{}, errors.New("quantity must be greater than zero")
 }

 if !isSupportedSandwich(sandwichType) {
  return Order{}, fmt.Errorf("unsupported sandwich type: %s", sandwichType)
 }

 price, err := priceFor(sandwichType)
 if err != nil {
  return Order{}, err
 }

 return Order{
  Customer:   customer,
  Type:       sandwichType,
  Quantity:   quantity,
  TotalCents: price * quantity,
  Status:     "PREPARING",
 }, nil
}

func isSupportedSandwich(t SandwichType) bool {
 switch t {
 case HamCheese, Veggie, Tuna:
  return true
 default:
  return false
 }
}

func priceFor(t SandwichType) (int, error) {
 switch t {
 case HamCheese:
  return 700, nil // $7.00
 case Veggie:
  return 650, nil // $6.50
 case Tuna:
  return 800, nil // $8.00
 default:
  return 0, fmt.Errorf("no price for sandwich type: %s", t)
 }
}

This file is the purest part of the system. It says:

  • what kinds of sandwiches exist
  • what makes an order valid
  • how pricing works

It says nothing about HTTP or SQLite. That is the whole point.

2) Ports — the contracts

ports/input.go

package ports

import (
 "context"

 "sandwich-shop/domain"
)

type OrderService interface {
 PlaceOrder(ctx context.Context, customer string, sandwichType domain.SandwichType, quantity int) (domain.Order, error)
}

ports/output.go

package ports

import (
 "context"

 "sandwich-shop/domain"
)
//Domain says ..I need a place to save orders
//I need a way to notify that an order was accepted
type OrderRepository interface {
 Save(ctx context.Context, order domain.Order) (domain.Order, error)
}

type KitchenNotifier interface {
 NotifyOrderAccepted(ctx context.Context, order domain.Order) error
}
  • OrderService is the input port: what the domain offers : “Here is how you may ask me to make a sandwich order.”
  • OrderRepository is the output port: what the domain needs : “If I (kitchen)need storage, someone else must provide that through this contract.”

3) Application service — the kitchen itself

application/kitchen.go

package application

import (
 "context"

 "sandwich-shop/domain"
 "sandwich-shop/ports"
)

type Kitchen struct {
 repo ports.OrderRepository
}

func NewKitchen(repo ports.OrderRepository) *Kitchen {
 return &Kitchen{repo: repo}
}

// Compile-time check: Kitchen implements the input port.
var _ ports.OrderService = (*Kitchen)(nil)

func (k *Kitchen) PlaceOrder(ctx context.Context, customer string, sandwichType domain.SandwichType, quantity int) (domain.Order, error) {
 // Business rules live here and in the domain model.
 order, err := domain.NewOrder(customer, sandwichType, quantity)
 if err != nil {
  return domain.Order{}, err
 }

 // The kitchen does not know *how* saving happens.
 // It only knows the repository contract.
 saved, err := k.repo.Save(ctx, order)
 if err != nil {
  return domain.Order{}, err
 }

 return saved, nil
}

This is the “kitchen window.”

It accepts a valid order request, applies domain rules, and asks the repository to save it. It does not know whether storage is SQLite today, Postgres tomorrow, or an in-memory fake during testing.

4) SQLite adapter — one concrete way to satisfy the output port

adapters/sqlite/repository.go

package sqlite

import (
 "context"
 "database/sql"
 "fmt"

 _ "modernc.org/sqlite"

 "sandwich-shop/domain"
 "sandwich-shop/ports"
)

type OrderRepository struct {
 db *sql.DB
}

func NewOrderRepository(db *sql.DB) *OrderRepository {
 return &OrderRepository{db: db}
}

// Compile-time check: SQLite adapter implements the output port.
var _ ports.OrderRepository = (*OrderRepository)(nil)

func (r *OrderRepository) Save(ctx context.Context, order domain.Order) (domain.Order, error) {
 result, err := r.db.ExecContext(
  ctx,
  `INSERT INTO orders (customer, sandwich_type, quantity, total_cents, status)
   VALUES (?, ?, ?, ?, ?)`,
  order.Customer,
  string(order.Type),
  order.Quantity,
  order.TotalCents,
  order.Status,
 )
 if err != nil {
  return domain.Order{}, fmt.Errorf("insert order: %w", err)
 }

 id, err := result.LastInsertId()
 if err != nil {
  return domain.Order{}, fmt.Errorf("last insert id: %w", err)
 }

 order.ID = id
 return order, nil
}

func InitSchema(ctx context.Context, db *sql.DB) error {
 _, err := db.ExecContext(ctx, `
  CREATE TABLE IF NOT EXISTS orders (
   id INTEGER PRIMARY KEY AUTOINCREMENT,
   customer TEXT NOT NULL,
   sandwich_type TEXT NOT NULL,
   quantity INTEGER NOT NULL,
   total_cents INTEGER NOT NULL,
   status TEXT NOT NULL
  )
 `)
 return err
}

This adapter knows SQL and SQLite. That is allowed, because adapters are supposed to know outside-world details.

The domain never imports database/sql. That is the dependency rule in action.

5) REST adapter — one concrete way to satisfy the input side

adapters/rest/handler.go

package rest

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

 "sandwich-shop/domain"
 "sandwich-shop/ports"
)

type Handler struct {
 service ports.OrderService
}

func NewHandler(service ports.OrderService) *Handler {
 return &Handler{service: service}
}

type placeOrderRequest struct {
 Customer string `json:"customer"`
 Sandwich string `json:"sandwich"`
 Quantity int    `json:"quantity"`
}

type placeOrderResponse struct {
 ID         int64  `json:"id"`
 Customer   string `json:"customer"`
 Sandwich   string `json:"sandwich"`
 Quantity   int    `json:"quantity"`
 TotalCents int    `json:"total_cents"`
 Status     string `json:"status"`
}

type errorResponse struct {
 Error string `json:"error"`
}

func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
 mux.HandleFunc("/orders", h.placeOrder)
}

func (h *Handler) placeOrder(w http.ResponseWriter, r *http.Request) {
 if r.Method != http.MethodPost {
  writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"})
  return
 }

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

 order, err := h.service.PlaceOrder(
  r.Context(),
  req.Customer,
  domain.SandwichType(req.Sandwich),
  req.Quantity,
 )
 if err != nil {
  writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()})
  return
 }

 resp := placeOrderResponse{
  ID:         order.ID,
  Customer:   order.Customer,
  Sandwich:   string(order.Type),
  Quantity:   order.Quantity,
  TotalCents: order.TotalCents,
  Status:     order.Status,
 }

 writeJSON(w, http.StatusCreated, resp)
}

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

This adapter knows: HTTP methods,JSON,status codes. That is exactly what the waiter should know. It translates the outside request into a call to the input port. It does not decide sandwich prices or menu validity.

6) Wiring everything together

main.go

package main

import (
 "context"
 "database/sql"
 "log"
 "net/http"
 "time"

 "sandwich-shop/adapters/rest"
 sqliteAdapter "sandwich-shop/adapters/sqlite"
 "sandwich-shop/application"
)

func main() {
 ctx := context.Background()

 db, err := sql.Open("sqlite", "file:sandwich-shop.db?_pragma=foreign_keys(1)")
 if err != nil {
  log.Fatalf("open db: %v", err)
 }
 defer db.Close()

 if err := sqliteAdapter.InitSchema(ctx, db); err != nil {
  log.Fatalf("init schema: %v", err)
 }

 orderRepo := sqliteAdapter.NewOrderRepository(db)
 kitchen := application.NewKitchen(orderRepo)
 httpHandler := rest.NewHandler(kitchen)

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

 server := &http.Server{
  Addr:              ":8080",
  Handler:           mux,
  ReadHeaderTimeout: 5 * time.Second,
 }

 log.Println("listening on :8080")
 log.Println("try: curl -X POST http://localhost:8080/orders -H 'Content-Type: application/json' -d '{\"customer\":\"Alice\",\"sandwich\":\"veggie\",\"quantity\":2}'")

 if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
  log.Fatalf("server failed: %v", err)
 }
}

Minimal go.mod

module sandwich-shop

go 1.22

require modernc.org/sqlite v1.34.5

example request

curl -X POST http://localhost:8080/orders \
  -H "Content-Type: application/json" \
  -d '{
    "customer": "Alice",
    "sandwich": "veggie",
    "quantity": 2
  }'

example response

{
  "id": 1,
  "customer": "Alice",
  "sandwich": "veggie",
  "quantity": 2,
  "total_cents": 1300,
  "status": "PREPARING"
}

In the request flow

Here is the direction:

Input side

HTTP request -> REST adapter -> OrderService -> Kitchen logic

This is why OrderService is input. It is the doorway into the kitchen.

Output side

Kitchen logic -> OrderRepository -> SQLite adapter -> database

This is why OrderRepository is output. It is the doorway from the kitchen to an outside helper.

Add a notification adapter

A simple first adapter is a logging notifier. It is deliberately boring, which is useful because it shows the shape clearly.

adapters/notify/log_notifier.go

package notify

import (
 "context"
 "log"

 "sandwich-shop/domain"
 "sandwich-shop/ports"
)

type LogNotifier struct{}

func NewLogNotifier() *LogNotifier {
 return &LogNotifier{}
}

var _ ports.KitchenNotifier = (*LogNotifier)(nil)

func (n *LogNotifier) NotifyOrderAccepted(ctx context.Context, order domain.Order) error {
 log.Printf(
  "[NOTIFY] order accepted id=%d customer=%s sandwich=%s quantity=%d total_cents=%d status=%s",
  order.ID,
  order.Customer,
  order.Type,
  order.Quantity,
  order.TotalCents,
  order.Status,
 )
 return nil
}

This adapter proves the point:

  • the domain triggers a notification
  • the adapter decides how to perform it

Later, you could swap this with:

  • EmailNotifier
  • SMSNotifier
  • KafkaNotifier
  • SlackNotifier

without rewriting the kitchen.


메타데이터
post_id
d72c30c964eb
slug
hex-design-pattern-part-2-d72c30c964eb
url
https://medium.com/@norbuurgen/hex-design-pattern-part-2-d72c30c964eb
canonical_url
https://medium.com/@norbuurgen/hex-design-pattern-part-2-d72c30c964eb
author_url
https://medium.com/@norbuurgen
status
ok
fetched_at
2026-07-12 00:48:14