SDK Design for Go API Clients That People Like Using
Design the public surface of a Go SDK: a service per resource, params structs with pointers, caller-owned interfaces, typed errors and versioning.

A good SDK feels like it was written by someone who had to use it. You can guess the method names, the happy path takes five lines, the errors tell you what to do, and your tests do not need a mock framework. A bad one leaks its HTTP library, panics on a nil option, returns half-filled structs next to errors and changes its types between minor versions.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
The previous article built the transport side: retries, timeouts, idempotency. This one is about the public surface, the part users see and the part you cannot take back. Almost every decision here is a trade between convenience today and freedom tomorrow. The examples come from a small orders SDK that ran on Go 1.25.6, and the patterns follow the libraries most Go developers already know, notably go-github and the Stripe and AWS SDKs.
A promise for every exported name
In Go, anything exported is public API, and with modules, users upgrade by changing a version number and expect their code to compile. That means every exported type, method, field, constant and function is a commitment. The most valuable habit in SDK design is exporting less. If something is not part of what a user needs, keep it unexported: the HTTP plumbing, the retry loop, the request builders. Adding an export later is free. Removing one is a major version.
The Go project describes the same thinking for the standard library in its compatibility promise, and tools like gorelease (from golang.org/x/exp) compare two versions of a module and report incompatible changes. I have not run it here, but it belongs in the release process of any SDK, next to the tests.
The layout: a client with services
The shape most Go API clients converge on is a Client holding configuration, with a field per resource that exposes that resource's methods:
client := shop.NewClient("https://api.example.test/v1", shop.WithTokenSource(ts))
order, err := client.Orders.Get(ctx, "o_1")
customer, err := client.Customers.Get(ctx, "c_9")The alternative is a flat client with GetOrder, ListOrders, GetCustomer and forty more methods. It works for small APIs. Past a dozen operations the method list is unreadable in documentation, and finding "everything about customers" means scanning. The service-per-resource layout groups methods, gives autocomplete a natural path (client.Orders. shows only order operations), and lets each service live in its own file.
The implementation is small:
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
// Client is the entry point. Resources hang off it as services, so the call reads client.Orders.Get(...).
type Client struct {
http *http.Client
baseURL string
token func(context.Context) (string, error)
Orders *OrdersService
Customers *CustomersService
}
type service struct{ c *Client }
type OrdersService service
type CustomersService service
type Option func(*Client)
func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.http = h } }
// WithTokenSource lets the caller refresh credentials; the SDK never stores a bare string it cannot update.
func WithTokenSource(f func(context.Context) (string, error)) Option {
return func(c *Client) { c.token = f }
}
func NewClient(baseURL string, opts ...Option) *Client {
c := &Client{http: http.DefaultClient, baseURL: strings.TrimRight(baseURL, "/")}
for _, o := range opts {
o(c)
}
c.Orders = &OrdersService{c}
c.Customers = &CustomersService{c}
return c
}
// Ptr makes optional values readable at the call site: sdk.Ptr(5) instead of a throwaway variable.
func Ptr[T any](v T) *T { return &v }
type Order struct {
ID string `json:"id"`
CustomerID string `json:"customer_id"`
Status string `json:"status"`
Total int64 `json:"total_cents"`
}
// ListOrdersParams is a struct, so a new filter is one more field and no caller breaks.
// Pointers separate "not set" from the zero value.
type ListOrdersParams struct {
Status *string
CustomerID *string
Limit *int
}
func (p *ListOrdersParams) values() url.Values {
v := url.Values{}
if p == nil {
return v
}
if p.Status != nil {
v.Set("status", *p.Status)
}
if p.CustomerID != nil {
v.Set("customer_id", *p.CustomerID)
}
if p.Limit != nil {
v.Set("limit", strconv.Itoa(*p.Limit))
}
return v
}
func (s *OrdersService) Get(ctx context.Context, id string) (*Order, error) {
var o Order
if err := s.c.get(ctx, "/orders/"+url.PathEscape(id), nil, &o); err != nil {
return nil, err // never a half-filled value next to an error
}
return &o, nil
}
func (s *OrdersService) List(ctx context.Context, p *ListOrdersParams) ([]Order, error) {
var out struct{ Data []Order }
if err := s.c.get(ctx, "/orders", p.values(), &out); err != nil {
return nil, err
}
return out.Data, nil
}
type Customer struct {
ID string `json:"id"`
Name string `json:"name"`
}
func (s *CustomersService) Get(ctx context.Context, id string) (*Customer, error) {
var cu Customer
if err := s.c.get(ctx, "/customers/"+url.PathEscape(id), nil, &cu); err != nil {
return nil, err
}
return &cu, nil
}
func (c *Client) get(ctx context.Context, path string, q url.Values, out any) error {
u := c.baseURL + path
if len(q) > 0 {
u += "?" + q.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
if c.token != nil {
t, err := c.token(ctx)
if err != nil {
return fmt.Errorf("token source: %w", err)
}
req.Header.Set("Authorization", "Bearer "+t)
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode/100 != 2 {
return &Error{Status: resp.StatusCode, Body: string(body)}
}
return json.Unmarshal(body, out)
}
type Error struct {
Status int
Body string
}
func (e *Error) Error() string { return fmt.Sprintf("shop api: status %d", e.Status) }
func IsNotFound(err error) bool {
e, ok := err.(*Error)
return ok && e.Status == http.StatusNotFound
}OrdersService and CustomersService are defined from a shared unexported service type, a trick from go-github that lets them share the pointer to the client without exposing it. They are created in NewClient, so a zero Client is not usable; document that, or panic early with a clear message.
Optional parameters
Go has no default arguments, so you need a convention, and the choice has consequences for compatibility.
Positional arguments. List(ctx, status, limit). Adding a filter changes the signature and breaks every caller. Fine for required values that will never grow, such as an id.
A params struct. List(ctx, &ListOrdersParams{Status: Ptr("paid")}). A new field is a compatible change, because existing code does not name it. The struct is the one place that grows. The catch is distinguishing "not set" from the zero value. If Limit is an int, zero might mean "no limit" or "limit of zero". Pointers solve it: nil means not set. They are a little noisy at call sites, which is why the SDK exports a tiny generic helper:
func Ptr[T any](v T) *T { return &v }With it, callers write Limit: sdk.Ptr(2) and Status: sdk.Ptr("paid"). The lab's list call produced the query limit=2&status=paid, and passing nil produced no query at all. Accepting a nil params pointer for "no filters" keeps the simple case short.
Functional options. List(ctx, WithStatus("paid"), WithLimit(2)). Equally compatible, reads cleanly and can validate in the option. The cost is more exported functions, one per option. A good rule: use functional options for client configuration (timeouts, HTTP client, token source) and a params struct for request parameters, because request parameters often map one-to-one to the wire fields and a struct makes them easy to document, copy and print.
Return values
Return a pointer to a domain struct, or the struct and an error, but never both filled. My first draft of the Get methods did return &o, s.c.get(...). It looks tidy and it means that on a failure the caller gets a non-nil pointer to a zero struct. The lab's first run printed {ID: CustomerID: Status: Total:0} for a lookup that had failed, because the example code ignored the error and nothing else made the mistake visible. Returning nil, err makes ignoring the error crash at the point of use, which is annoying in the right way.
Do not return *http.Response from normal methods. It exposes your transport, forces callers to close bodies and ties your public API to net/http. If callers need response metadata (rate limit headers, request id), put it on a small Response value, as go-github does, or on the error for failures.
Make the type reflect the API, not the wire. Use time.Time for timestamps, a named type for an enum with constants, and int64 cents for money. The conversion happens once inside the SDK. When the wire format changes, the Go type may not have to.
Errors
Define one exported error type that carries the HTTP status, the machine code, the message and the request id (the previous article shows it), plus small helpers. The lab includes IsNotFound(err), which let the caller's function turn a missing order into a friendly message:
switch {
case shop.IsNotFound(err):
return "no such order"
case err != nil:
return "error: " + err.Error()
}Helpers beat exported status constants because they hide how "not found" is encoded and let you change it. Keep the Error type unwrappable with errors.As, wrap transport failures with %w, and make sure context.Canceled and context.DeadlineExceeded pass through untouched, since callers test for them.
Context everywhere
Every method that does I/O takes a context.Context as its first parameter. Not as an option, not later "when we need it". Retrofitting a context into a released SDK is a breaking change for every call, and the package is unusable in a service that cares about deadlines without it. Use it for the request, for waiting between retries and for refreshing tokens.
Authentication
Accept a token source, a func(ctx) (string, error), instead of a string. The SDK calls it before each request. That lets applications rotate tokens, use short-lived credentials and fetch them from a secret manager, without rebuilding the client. It is one line more for the SDK and a large flexibility for users. The lab passes WithTokenSource(...) and the transport log shows Authorization: Bearer tok_test on each request. Offer a convenience that wraps a static string for simple cases and for tests.
Do not export interfaces
This runs against habits from other languages. The SDK should not define type OrdersAPI interface { ... } for users to mock. Go's idiom is that the consumer defines the interface it needs, with only the methods it uses. In the lab, the caller's code declares:
type OrderGetter interface {
Get(ctx context.Context, id string) (*Order, error)
}*OrdersService satisfies it without knowing it exists, and a one-method fake satisfies it in tests. If the SDK had exported a fat interface with twenty methods, every new method would break every fake in every user's code, and users would be forced to implement methods they never call. The standard library works the same way: io.Reader is small and defined where it is consumed. See accept interfaces, return structs.
Testing, for you and for users
For your own tests, scripted servers are enough, as in the retries article. For your users' tests, the SDK should make testing easy by accepting an http.Client. The lab replaces the network with a function type implementing RoundTripper, in five lines, and then inspects the requests it saw: method, path, query and the authorisation header. No server, no ports, no mocking framework, and the whole SDK from the public method down to the wire is exercised. Document this pattern in the README. Users who prefer a server can use httptest with the same option.
Pagination, waiting and other conveniences
Some conveniences belong in the SDK because every user would otherwise write them, slightly differently, slightly wrong.
Pagination. Offer a page-level method for callers who want control (List returning a page and a next cursor) and an iterator for everyone else. With iter.Seq2 an All method lets callers write for order, err := range client.Orders.All(ctx, params) and never see a cursor. The iterator must stop cleanly when the loop breaks and must report a request error as the final yielded value.
Waiting for asynchronous work. If an operation returns a job or an operation resource, provide a Wait(ctx, op) helper that polls with backoff, honours the context and returns the final result or error. Users who need different behaviour can poll themselves. The helper covers the 90 percent case and stops people writing a for loop with time.Sleep and no timeout.
Idempotency. For create methods, make the key a visible parameter, as discussed in the previous article, and offer a helper to generate one. Document what happens on a retry within the library and on a repeated call by the user.
Webhook verification. If the API sends webhooks, ship the signature verification function in the SDK. Everyone needs it, and implementations by hand tend to compare signatures with == instead of a constant-time check, or skip the timestamp window. See webhook design for what the verifier must do.
Rate limit awareness. Surface the remaining quota and reset time from response headers on the metadata value, so callers can slow themselves down before they are limited. Do not sleep for them silently beyond the configured retry policy.
Leave out what is better handled elsewhere. Do not add a caching layer, a circuit breaker or a metrics system; accept an http.Client and let users wrap the transport with the middleware their platform uses. An SDK that tries to be the whole client stack is hard to update and awkward to combine with anything else.
Documentation as part of the design
Write the doc comment of each exported identifier before writing the code. If you cannot say in a sentence what ListOrdersParams.Limit does and what the default is, the design is not finished. Add Example functions that go test compiles and pkg.go.dev shows, covering the three or four flows people come for. Mark removed or replaced things with a Deprecated: paragraph so editors and staticcheck warn callers (API deprecation).
Versioning the module
Follow semantic versioning strictly. A compatible addition is a minor release. A breaking change needs a new major version and, from v2 on, a new import path: github.com/you/shop/v2. Both versions can then coexist in one build, which is how large programs migrate gradually. Tag the API version it speaks in the user agent and, if the server supports it, in a header, so you can tell in server logs which SDK versions are still calling. When the API itself adds a field, the SDK minor release adds the struct field; when the API removes a feature, the SDK deprecates the Go method first.
Generated or handwritten
Many SDKs come from an OpenAPI document through a generator, and that is a fine way to get types and request plumbing right. Generated code is rarely pleasant to use directly: names follow the spec, optional fields are pointers everywhere and there is no pagination iterator. A common compromise is to generate the lower layer and write a thin handwritten facade with the service layout, helpers and examples. Users meet the facade, the generator behind it can change, and you keep the freedom to regenerate. The mechanics are covered in OpenAPI client generation.
A checklist before the first release
- Every I/O method takes a
context.Contextfirst. - The public surface is as small as it can be, and every export is documented.
- Optional request parameters live in structs of pointers or in options, never as a growing positional list.
- Failures return
nil, err, and the error type supportserrors.Asand helpers. - No exported interfaces; one
http.Clientoption for testing and instrumentation. - Retries, timeouts and idempotency are safe by default and documented.
- Examples compile in
go test. - A release process that checks for incompatible changes.
The package documentation conventions are in the Go project's guide to writing doc comments, and go-github's source at github.com/google/go-github is a large real example of the services layout described here.


