- Book: Hexagonal Architecture in Go
- Also by me: Pocket Guides for Developers — the 8-book series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Product wants /v2/orders/{id} to return customer instead of customer_name, and money as an integer instead of a float. Someone copies handlers/orders.go to handlers/v2/orders.go. Then the service it calls needs a different return shape, so OrderService gets copied to OrderServiceV2. Three months later a soft-delete bug gets fixed in v2 and nobody remembers v1 has the same bug. Your two versions now disagree about which orders exist.
A better copy won't save you. A version is a wire contract, and wire contracts belong at the edge. Stripe described this approach back in 2017: their API is written against the current version, and version change modules transform responses backward for older clients, which "keep older API versions abstracted out of core code paths." You don't need Stripe's machinery to get the same property in a Go service. Ports and adapters gives it to you for free.
In hexagonal terms, v1 and v2 are two driving adapters. Each owns its request and response types and the mapping to and from the domain. Both call the same use case through the same port, and the whole thing runs on the standard library.
The layout: one core, two doors
Here is the whole tree. order is the core. Everything under adapter is replaceable. The module needs nothing beyond the standard library:
module example.com/orders
go 1.22
orders/
├── go.mod
├── cmd/api/
│ ├── main.go
│ └── main_test.go
└── internal/
├── order/ # domain + ports + use case
│ └── order.go
└── adapter/
├── httpv1/ # driving adapter, old contract
├── httpv2/ # driving adapter, new contract
└── memrepo/ # driven adapter, in-memory store
The domain holds money the way money should be held: an integer count of minor units plus a currency code. That float in v1's JSON was a wire decision, so it lives in the v1 adapter and nowhere else.
package order
import (
"context"
"errors"
)
var ErrNotFound = errors.New("order not found")
type Order struct {
ID string
Customer string
TotalMinor int64 // 1999 = 19.99 in a 2-decimal currency
Currency string
Deleted bool
}
// Repository is the driven port.
type Repository interface {
ByID(ctx context.Context, id string) (Order, error)
}
// Getter is the driving port. Both HTTP versions call it.
type Getter interface {
Get(ctx context.Context, id string) (Order, error)
}
type Service struct{ repo Repository }
func NewService(r Repository) *Service {
return &Service{repo: r}
}
func (s *Service) Get(
ctx context.Context, id string,
) (Order, error) {
o, err := s.repo.ByID(ctx, id)
if err != nil {
return Order{}, err
}
// The soft-delete fix lives here, once, for every version.
if o.Deleted {
return Order{}, ErrNotFound
}
return o, nil
}
Notice what this file does not know: JSON, HTTP, URL paths, version numbers. When v3 shows up, this file does not change.

Two driving adapters, one use case: the version lives at the door, not in the room.
The v1 adapter keeps the old contract, warts included
v1 promised clients customer_name and a float total. The adapter keeps that promise. It also does the division that v1 always did, which assumes two decimal places. For a zero-decimal currency like JPY, where Stripe's API takes 10 to mean 10 JPY, that division was already wrong. That is one of the reasons v2 exists, and it is a good reason not to "fix" v1 quietly: clients have coded around its behavior.
package httpv1
import (
"encoding/json"
"errors"
"net/http"
"example.com/orders/internal/order"
)
type orderResponse struct {
ID string `json:"id"`
CustomerName string `json:"customer_name"`
Total float64 `json:"total"`
Currency string `json:"currency"`
}
func toResponse(o order.Order) orderResponse {
return orderResponse{
ID: o.ID,
CustomerName: o.Customer,
Total: float64(o.TotalMinor) / 100,
Currency: o.Currency,
}
}
type Handler struct {
uc order.Getter
policy Policy
}
func New(uc order.Getter, p Policy) *Handler {
return &Handler{uc: uc, policy: p}
}
func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/orders/{id}",
deprecated(h.getOrder, h.policy))
}
func (h *Handler) getOrder(w http.ResponseWriter, r *http.Request) {
o, err := h.uc.Get(r.Context(), r.PathValue("id"))
if errors.Is(err, order.ErrNotFound) {
http.Error(w, "order not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(toResponse(o))
}
v1 also has to tell clients it is going away. Two headers do the work. RFC 9745, published in March 2025 on the Standards Track, defines Deprecation as a structured-field date: an @ followed by Unix seconds, like Deprecation: @1688169599. It also defines a deprecation link relation that points at your migration docs. RFC 8594 defines Sunset, the moment the endpoint is expected to stop responding, as a plain HTTP-date. RFC 9745 adds one rule tying them together: the Sunset timestamp "MUST NOT be earlier than" the Deprecation timestamp.
All of that is wire behavior, so it goes in the v1 package:
package httpv1
import (
"fmt"
"net/http"
"time"
)
type Policy struct {
Deprecated time.Time
Sunset time.Time
DocsURL string
}
func deprecated(next http.HandlerFunc, p Policy) http.HandlerFunc {
dep := fmt.Sprintf("@%d", p.Deprecated.Unix())
sun := p.Sunset.UTC().Format(http.TimeFormat)
link := "<" + p.DocsURL + `>; rel="deprecation"`
return func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Deprecation", dep)
h.Set("Sunset", sun)
h.Add("Link", link)
next(w, r)
}
}
The headers are set before next runs, so they are already in the header map when http.Error writes a 404 or 500. Clients that hit a missing order still learn the endpoint is going away. The .UTC() call matters: the http.TimeFormat docs say the time "must be in UTC for Format to generate the correct format."
The v2 adapter renames a field and fixes the money
v2 is a sibling package with its own DTO and its own mapping. It calls the same order.Getter.
package httpv2
import (
"encoding/json"
"errors"
"net/http"
"example.com/orders/internal/order"
)
type money struct {
AmountMinor int64 `json:"amount_minor"`
Currency string `json:"currency"`
}
type orderResponse struct {
ID string `json:"id"`
Customer string `json:"customer"`
Total money `json:"total"`
}
func toResponse(o order.Order) orderResponse {
return orderResponse{
ID: o.ID,
Customer: o.Customer,
Total: money{
AmountMinor: o.TotalMinor,
Currency: o.Currency,
},
}
}
type Handler struct{ uc order.Getter }
func New(uc order.Getter) *Handler { return &Handler{uc: uc} }
func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /v2/orders/{id}", h.getOrder)
}
func (h *Handler) getOrder(w http.ResponseWriter, r *http.Request) {
o, err := h.uc.Get(r.Context(), r.PathValue("id"))
if errors.Is(err, order.ErrNotFound) {
http.Error(w, "order not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(toResponse(o))
}
Yes, the error branches look the same as v1's. Leave them duplicated. Error shape is part of the contract, and the day v2 moves to a JSON error body you want to change one file without a shared helper dragging v1 along. The duplication that hurts is duplicated business rules, and there is none here: the soft-delete check exists exactly once, in order.Service.
Mounting both on one ServeMux
Since Go 1.22, net/http.ServeMux patterns accept a method and wildcards, so GET /v1/orders/{id} is a complete route and r.PathValue("id") returns the segment. The routing announcement also notes that a GET pattern matches HEAD, and that when two patterns overlap the most specific one wins. Keep the go line in go.mod at 1.22 or later: the new matching is controlled by the httpmuxgo121 setting, and GODEBUG defaults follow the Go version in your go.mod.
The in-memory store is the driven adapter. It doubles as the test fake.
package memrepo
import (
"context"
"example.com/orders/internal/order"
)
type Repo map[string]order.Order
func (r Repo) ByID(
_ context.Context, id string,
) (order.Order, error) {
o, ok := r[id]
if !ok {
return order.Order{}, order.ErrNotFound
}
return o, nil
}
Wiring happens in one function, so the test can call the exact same code main runs:
package main
import (
"log"
"net/http"
"time"
"example.com/orders/internal/adapter/httpv1"
"example.com/orders/internal/adapter/httpv2"
"example.com/orders/internal/adapter/memrepo"
"example.com/orders/internal/order"
)
var v1Policy = httpv1.Policy{
Deprecated: time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC),
Sunset: time.Date(2027, 5, 1, 0, 0, 0, 0, time.UTC),
DocsURL: "https://api.example.com/docs/v2-migration",
}
func newMux(uc order.Getter) *http.ServeMux {
mux := http.NewServeMux()
httpv1.New(uc, v1Policy).Register(mux)
httpv2.New(uc).Register(mux)
return mux
}
func main() {
repo := memrepo.Repo{
"ord_1": {ID: "ord_1", Customer: "Acme GmbH",
TotalMinor: 1999, Currency: "EUR"},
}
uc := order.NewService(repo)
log.Fatal(http.ListenAndServe(":8080", newMux(uc)))
}
Count the places that know there are two versions: two Register calls and two packages. The service, the repository port and the domain type are shared, and none of them carry a version number.

Same order, two shapes on the wire. The mapping is the only thing that forks.
One table test, both versions, one fake repository
This is where the design pays rent. One fake repo, one mux, and a table that sends the same orders through both doors. The deleted-order row is the regression test for the bug from the opening: fix it once in the use case, and both versions return 404.
package main
import (
"net/http/httptest"
"strings"
"testing"
"example.com/orders/internal/adapter/memrepo"
"example.com/orders/internal/order"
)
const v1Body = `{"id":"ord_1","customer_name":"Acme GmbH",` +
`"total":19.99,"currency":"EUR"}`
const v2Body = `{"id":"ord_1","customer":"Acme GmbH",` +
`"total":{"amount_minor":1999,"currency":"EUR"}}`
func TestOrdersBothVersions(t *testing.T) {
repo := memrepo.Repo{
"ord_1": {ID: "ord_1", Customer: "Acme GmbH",
TotalMinor: 1999, Currency: "EUR"},
"ord_2": {ID: "ord_2", Customer: "Gone Ltd",
TotalMinor: 500, Currency: "EUR", Deleted: true},
}
mux := newMux(order.NewService(repo))
tests := []struct {
name, path, body string
code int
deprecated bool
}{
{"v1 found", "/v1/orders/ord_1", v1Body, 200, true},
{"v2 found", "/v2/orders/ord_1", v2Body, 200, false},
{"v1 missing", "/v1/orders/nope", "", 404, true},
{"v2 missing", "/v2/orders/nope", "", 404, false},
{"v1 deleted", "/v1/orders/ord_2", "", 404, true},
{"v2 deleted", "/v2/orders/ord_2", "", 404, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("GET", tc.path, nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != tc.code {
t.Fatalf("code = %d, want %d", rec.Code, tc.code)
}
got := strings.TrimSpace(rec.Body.String())
if tc.body != "" && got != tc.body {
t.Errorf("body = %s\nwant %s", got, tc.body)
}
dep := rec.Header().Get("Deprecation") != ""
if dep != tc.deprecated {
t.Errorf("Deprecation set = %v, want %v",
dep, tc.deprecated)
}
})
}
}
Three things in that table are worth a second look. The body constants are the contract, written down: if someone reorders fields in a DTO or renames a JSON tag, a row fails. The Deprecation column proves the header is scoped to v1, including on its 404s. And the two deleted rows go through one rule in one place, which is the whole argument of this post in two lines of test data.
strings.TrimSpace is there because json.Encoder.Encode writes a trailing newline. The test goes through mux.ServeHTTP instead of calling the handlers directly, because r.PathValue is filled in by the mux during routing, and because you want the test to fail if a route pattern breaks.
Where this stops working
This pattern covers read-shape changes: renames, nesting, unit changes, header policy. It does not cover everything, and it is better to know the edges before you rely on it.
Behavior changes are the hard case. If v2 must validate an order differently, or charge a card at a different step, that is not a mapping. Put the choice in the use case as an explicit option the adapter passes in, with a name that says what it does rather than which version wants it. Stripe hits the same wall: their post describes flagging changes with side effects, turning their response transformation into a no-op, and handling them with checks elsewhere in the code.
Request bodies follow the same rule as responses. A POST /v2/orders adapter decodes its own DTO into a domain command. When v2 accepts amount_minor and v1 accepts a float, each adapter converts to TotalMinor before calling the core, and the v1 adapter is the one place that rounds.
On sunset day you remove internal/adapter/httpv1, delete one Register line, and drop the v1 rows from the table. If the domain had grown a V1Total field or an if version == 1 branch, you would be hunting through the core instead.
If this was useful
The move in this post (a version is an adapter, the use case is the product) is the same move Hexagonal Architecture in Go makes for databases, queues and third-party APIs. The book walks through the layout, the port boundaries and the persistence side, which is where most Go services start leaking SQL types into the core. If your /v2 folder is already a copy of /v1, it shows you how to pull the shared part back into the middle.

Top comments (0)