Course outline

Go / Advanced: Safety, Performance, and Service Design / Lesson 18

API Design and Deployment Contracts

Carry language-level correctness into versioned APIs, configuration, graceful shutdown, and reproducible release artifacts.

About 5 min readContent reviewed
On this page

Lesson goals

  • Define validation, error, ownership, and compatibility boundaries.
  • Validate configuration before starting resources.
  • Plan reproducible builds and bounded shutdown without publishing automatically.

An API is more than exported signatures

A stable API defines accepted inputs, output ordering, nil and empty meaning, ownership, error categories, cancellation, and concurrency safety. Document whether callers may mutate supplied or returned slices and whether methods retain references. A signature unchanged across a release can still break these behavioral promises.

Prefer small interfaces derived from real consumers and concrete types with enforceable invariants. Keep options explicit and validate incompatible combinations. Avoid a constructor that silently starts unbounded goroutines or opens network listeners without giving the caller a shutdown handle. Starting work transfers a lifetime obligation that must be visible.

Reject invalid configuration before side effects

The first example parses a worker limit from a fixed synthetic string and enforces the application interval. It returns errors rather than silently replacing invalid input with a default. A deployment command should validate configuration before opening listeners or starting workers, so a failed configuration does not leave a partly running service.

Separate representation from policy: strconv.Atoi recognizes an integer and reports conversion problems, while the explicit range check defines the service’s worker bound. Real configuration may come from flags or environment variables, but those sources should converge on the same validation function. Never print secrets as part of a configuration diagnostic.

go Validate a bounded deployment option
package main

import (
	"fmt"
	"strconv"
)

func workerLimit(text string) (int, error) {
	value, err := strconv.Atoi(text)
	if err != nil {
		return 0, fmt.Errorf("worker limit: %w", err)
	}
	if value < 1 || value > 16 {
		return 0, fmt.Errorf("worker limit outside 1..16")
	}
	return value, nil
}

func main() {
	workers, err := workerLimit("4")
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("workers", workers)
}
Expected output
workers 4

The synthetic value four satisfies syntax and the explicit one-through-sixteen policy.

Reject undocumented request shape

JSON decoding is a boundary contract, not an automatic business validator. The second example rejects unknown fields and requires no second JSON value after the first, then validates a nonnegative quantity. A size limit must be applied before decoding untrusted bodies; the fixed string here is already bounded synthetic input.

Unknown-field rejection is a deliberate compatibility choice. Some public APIs allow future fields; others require strict shape. Decide and document the policy rather than enabling a setting blindly. The zero value of a missing field may be indistinguishable from an explicitly supplied zero, so use pointers or another representation when presence matters.

go A strict single-record JSON boundary
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"strings"
)

type Request struct {
	Quantity int `json:"quantity"`
}

func main() {
	decoder := json.NewDecoder(strings.NewReader(`{"quantity":3}`))
	decoder.DisallowUnknownFields()
	var request Request
	if err := decoder.Decode(&request); err != nil {
		panic(err)
	}
	var extra any
	if err := decoder.Decode(&extra); err != io.EOF {
		panic("trailing JSON value")
	}
	if request.Quantity < 0 {
		panic("negative quantity")
	}
	fmt.Println("quantity", request.Quantity)
}
Expected output
quantity 3

One known field is decoded, trailing content is rejected, and the domain check accepts three.

Record the artifact’s actual build inputs

Record the toolchain, module metadata, target GOOS and GOARCH, build tags, and relevant cgo settings. A Go binary is not universally portable to every operating system, architecture, or libc environment. Cross-compilation and static-linking assumptions depend on dependencies and configuration. Verify the artifact in an environment representative of its intended target.

Run formatting, tests, vet, supported race checks, and appropriate vulnerability analysis in a controlled release workflow. Dependency and toolchain updates require review; automatic toolchain selection should be understood rather than ignored. Build reproducibility includes generated files and configuration, not just the text of main.go.

Shutdown is part of the service API

A service should stop admission, request cancellation of owned background work, finish or reject in-flight operations under policy, and wait within a bounded cleanup lifetime. HTTP Shutdown does not automatically solve every application-owned goroutine or hijacked connection. Give each resource an owner and a completion condition, and decide what happens when the shutdown budget expires.

Expose useful health and operational signals without leaking secrets or making diagnostics public by accident. Distinguish readiness to accept work from mere process existence. Tests should cover startup failure and shutdown during blocked work. Deployment itself is a separate authorized action; a lesson or successful build does not imply permission to publish a service.

Common errors and fixes

A missing field is mistaken for an explicitly supplied zero.

Why it happens
The representation records value but not presence.
How to fix it
Use a presence-aware field or explicit validation rule.
Prevent it next time
Test absent, null, zero, and nonzero inputs under the chosen schema.

A server starts before configuration failure is noticed.

Why it happens
Validation and side effects are interleaved.
How to fix it
Parse and validate options before acquiring resources.
Prevent it next time
Test startup rejection without opening listeners.

Shutdown returns while owned workers remain blocked.

Why it happens
The service lacks cancellation or joining for application work.
How to fix it
Define stop and completion protocols for each resource.
Prevent it next time
Exercise shutdown while queues and sends are blocked.

Check your understanding

1. An API formerly returned a copied slice and now returns its internal cache slice with the same signature. Is this necessarily compatible?

Show worked solution

No. Callers may rely on modifying or retaining their independent result without affecting shared state. Returning an alias changes ownership and concurrency semantics even though types match. Preserve the copy, publish an explicitly borrowed immutable view with a deliberate compatibility transition, or introduce another API whose contract makes the difference clear.

2. Specify startup and shutdown behavior for a command owning an HTTP server and one worker pool.

Show worked solution

Validate configuration first, construct resources, and start work only after acquisition succeeds. On shutdown, stop new admission, cancel the pool, drain or reject in-flight work under policy, shut down HTTP with a separate bounded context, and join owned workers before final release. On partial startup failure, release only resources actually acquired and report a non-success status.

Key takeaways

  • Public compatibility includes nilness, ownership, errors, and cancellation.
  • Validate before side effects and record complete build assumptions.
  • A deployable artifact needs a bounded startup and shutdown contract.

Official references