Hex architecture simplified part 2
The idea is:
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
}
OrderServiceis the input port: what the domain offers : “Here is how you may ask me to make a sandwich order.”OrderRepositoryis 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:
EmailNotifierSMSNotifierKafkaNotifierSlackNotifier
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