DEV Community

Cover image for API Versioning with Separate Invoice Contracts
lukman lukman
lukman lukman

Posted on

API Versioning with Separate Invoice Contracts

An invoice endpoint returns customer information as a string. A new representation replaces that string with an object containing the customer's ID, name, and phone number. The response remains valid JSON and the server still returns HTTP 200. The legacy consumer expects a string and cannot decode the new object.

Lab 06 of Software Engineering Lab demonstrates this change using Go HTTP handlers and an explicit consumer model. It then keeps the old representation on V1 and introduces the nested representation on V2. A third example adds a field without changing the fields that the legacy model already understands.

The tests described here are source-defined scenarios and assertions. They were inspected, not executed for this article. The legacy consumer is simulated by a Go struct; this is not an Android device test.

The original invoice contract

The documented invoice response contains four fields:

{
  "id": 1001,
  "customer": "Budi",
  "total": 500000,
  "status": "PAID"
}
Enter fullscreen mode Exit fullscreen mode

domain.go makes the consumer's expectation concrete:

type LegacyInvoice struct {
    ID       int    `json:"id"`
    Customer string `json:"customer"`
    Total    int64  `json:"total"`
    Status   string `json:"status"`
}
Enter fullscreen mode Exit fullscreen mode

The helper ParseLegacyInvoice calls json.Unmarshal into that struct. This turns compatibility into an operation that the tests can exercise. The question is whether the existing model can still consume the body and obtain the expected values.

The fixture uses invoice ID 1001, customer name Budi, total 500000, and status PAID. These are demonstration values, not a production invoice dataset.

The unsafe handler changes an existing field's type

UnsafeHandler returns the following customer representation while retaining the invoice's other fields:

{
  "id": 1001,
  "customer": {
    "id": 15,
    "name": "Budi",
    "phone": "08123"
  },
  "total": 500000,
  "status": "PAID"
}
Enter fullscreen mode Exit fullscreen mode

The new information is useful, but it occupies a field whose published representation was a string. LegacyInvoice.Customer cannot receive this object through the supplied decoder.

TestBreakingChange_LegacyClientFails first requires HTTP 200, then calls ParseLegacyInvoice on the response body. It expects decoding to fail. A passing test in this example would confirm the deliberate incompatibility; it would not certify the handler as backward compatible.

The unsafe endpoint is synthetic. It creates its payload directly after validating the path, rather than retrieving the invoice through the repository used by V1 and V2. It demonstrates the shape change, not a complete persistence-backed invoice service.

One domain model, two response models

The internal Invoice model already contains a nested Customer. The public V1 response does not have to copy that shape.

In safe_server.go, the response types establish separate contracts:

type InvoiceV1Response struct {
    ID       int    `json:"id"`
    Customer string `json:"customer"`
    Total    int64  `json:"total"`
    Status   string `json:"status"`
}

type CustomerV2Response struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Phone string `json:"phone"`
}

type InvoiceV2Response struct {
    ID       int                `json:"id"`
    Customer CustomerV2Response `json:"customer"`
    Total    int64              `json:"total"`
    Status   string             `json:"status"`
}
Enter fullscreen mode Exit fullscreen mode

The mapper for V1 selects only the customer name:

func mapToV1(domain Invoice) InvoiceV1Response {
    return InvoiceV1Response{
        ID:       domain.ID,
        Customer: domain.Customer.Name,
        Total:    domain.Total,
        Status:   domain.Status,
    }
}
Enter fullscreen mode Exit fullscreen mode

mapToV2 maps the same domain invoice into the nested customer DTO, preserving ID, name, and phone. Both handlers use the same repository interface. The repository fixture supplies the same invoice to each mapper.

This is the boundary the lab implements: the domain model holds the invoice information, while a version-specific mapper decides what the consumer receives. There is no database schema or database migration in this lab.

Route each contract explicitly

The supported endpoints are:

GET /api/v1/invoices/1001
GET /api/v2/invoices/1001
Enter fullscreen mode Exit fullscreen mode

V1Handler maps its result through mapToV1; V2Handler maps through mapToV2. Registering V2 does not replace V1's handler or DTO.

TestVersionedRoutes_CanRunSideBySide registers both handlers on the same mux and requests both paths. It decodes each response into its version's type and checks the customer values.

Another regression test registers V2 and then checks V1's wire contract again. That test targets a specific risk: a new route exists, but the old response must still retain the fields the legacy consumer understands.

The lab uses URL versioning. Header-based selection appears in the README as a design alternative; there is no header-version negotiation implementation here.

Test the consumer and inspect the response

The safe tests use two complementary views of the body. One decodes V1 into LegacyInvoice and checks its invoice fields. Another reads the response into a map of json.RawMessage and checks field presence and values.

For the central breaking change, the important assertion is explicit: V1's customer is decoded into a string and compared with Budi. V2's customer is decoded into an object, then its id, name, and phone are checked separately.

Typed response tests also check the invoice ID, total, and status. This gives the tests a concrete fixture-based contract rather than merely requiring that the body be JSON.

These assertions cover the fields and requests they exercise. They are not an exhaustive schema validator for all payloads, nullability combinations, or consumer implementations.

Adding a field is a different change

AdditiveHandler keeps customer as a string and adds currency:

{
  "id": 1001,
  "customer": "Budi",
  "total": 500000,
  "status": "PAID",
  "currency": "IDR"
}
Enter fullscreen mode Exit fullscreen mode

TestAdditiveField_LegacyClientStillWorks verifies that the body contains currency with value IDR. It then decodes the response into the old LegacyInvoice, which has no currency field, and checks the customer and total.

The supplied decoder tolerates the extra field. The README qualifies the broader design rule: additive response fields are usually compatible when consumers tolerate unknown fields. Consumers using strict validation may behave differently.

The demonstrated outcome belongs to this Go decoding path. The lab does not include a strict-schema consumer test, so it does not prove compatibility with every possible reader.

Compatibility includes request requirements

The source also protects an expectation outside the JSON body. TestV1Contract_DoesNotIntroduceRequiredTenantHeader sends a V1 request without X-Tenant-ID and expects HTTP 200.

That test records that this V1 handler does not introduce the header as a requirement. It does not establish a broader authentication policy; the lab contains no tenant authentication middleware.

The README lists other contract changes, such as renamed fields, required inputs, date formats, and changed error representations. The implemented string-to-object case is the main demonstration. The listed possibilities should not be mistaken for separately implemented experiments.

Routing behavior is tested separately

The helper parseInvoiceID validates the prefix, requires an ID, rejects extra path segments, parses a numeric value, and requires it to be positive. The versioned handlers then look up that ID.

For the versioned fixture, the expected cases include:

Request Expected response
GET /api/v1/invoices/1001 200, invoice JSON
GET /api/v1/invoices/ 400, missing ID
GET /api/v1/invoices/abc 400, invalid numeric ID
GET /api/v1/invoices/0 400, nonpositive ID
GET /api/v1/invoices/1001/extra 400, extra segment
GET /api/v1/invoices/9999 404, invoice not found
POST /api/v1/invoices/1001 405, Allow: GET
GET /api/v3/invoices/1001 404 from the router

The fixture repository contains only invoice 1001. Thus 9999 is a valid ID format but has no corresponding invoice in the fixture.

newVersionedMux registers both the exact collection path and the subtree path for each version. The request without a trailing slash therefore reaches the handler and produces the lab's 400 response, rather than relying on an implicit redirect.

Handler-generated responses use application/json, including method errors. An unmatched V3 path is handled by the mux's default 404 behavior; the README documents that response as plain text. The common JSON writer does not wrap every router response.

Migration remains a documented policy

The README describes releasing V2, retaining V1 for older consumers, monitoring adoption, communicating deprecation, and removing V1 after sunset criteria are met.

It includes example timeframes and traffic thresholds. They are illustrative policy values, not measured adoption data or an implemented shutdown schedule. This directory contains no traffic-monitoring pipeline or automated sunset mechanism.

The code demonstrates the prerequisite: both representations can be served through separate routes, with tests that continue checking V1. Deciding when V1 can be removed requires the consumer information described in the README.

Run the lab from its module

The directory contains its own go.mod, declaring Go 1.25.0. Its README lists these test commands:

cd labs/06-api-versioning
go test -v ./...
go test -race -v ./...
Enter fullscreen mode Exit fullscreen mode

The tests use httptest and a mock repository rather than an external database. No test execution results are claimed here.

The lab's mental model is precise: HTTP 200 describes a server response, while compatibility must be evaluated against the consumer's contract. Separate DTOs and regression tests keep that contract visible when a new representation is introduced.

Source

Based solely on the README, Go handlers, models, and tests in Software Engineering Lab, Lab 06. Test outcomes are described from source assertions, not a new run.

https://github.com/lukman-ss/software-engineering-lab/tree/main/labs/06-api-versioning

Top comments (0)