DEV Community

Cover image for Ship /v2 of a Go API Without Forking the Service: Two HTTP Adapters, One Use Case
Gabriel Anhaia
Gabriel Anhaia

Posted on Edited on AI-assisted

Ship /v2 of a Go API Without Forking the Service: Two HTTP Adapters, One Use Case


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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Notice what this file does not know: JSON, HTTP, URL paths, version numbers. When v3 shows up, this file does not change.

Two doorways, one older and one newer, both opening into the same central hexagon core
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))
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)))
}
Enter fullscreen mode Exit fullscreen mode

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.

A single conveyor of orders splitting into two output chutes, each stamping a different label shape onto the same box
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)
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)