You’ve opened your editor to start a new SDK. You typed type Client struct { and paused. Whatever fields go on the next line will define what every caller ever does with this SDK — the auth story lives there, the observability seams live there, the difference between a client that composes with the caller’s tracing and one that fights it lives there. There are more design choices packed into that one struct than in most of the code you will write after it.

The design choices below are not aesthetic — each has a specific failure mode when you get it wrong, and the failure modes compound. Getting the client constructor right buys you nothing if your errors carry no request IDs; getting typed errors right buys you nothing if pagination leaks cursor plumbing to the caller. The seven parts of the anatomy — client, resource groupings, typed requests and responses, typed errors, pagination, streaming/retries/instrumentation, docs — have to work together, and the easiest way to see how they interlock is to walk them in order.

This is the second in a four-post series on SDK design; the fundamentals post covered what an SDK is at a conceptual level and when the effort is worth it. Go is a useful lens for this walkthrough because it forces every design choice to be explicit — no reflection tricks, no magic decorators, no dynamic dispatch to hide behind. The patterns generalize to any language, but they land more visibly in Go, where the type system is the entire contract.

1. The client and its construction

The entry point is always a client value the caller constructs once and reuses. In Go, the two idiomatic construction patterns are the config struct and functional options. Both are variants of the Builder pattern; both compose well. Functional options tend to age better because adding a new option doesn’t break existing call sites.

package example

import (
    "log/slog"
    "net/http"
    "time"
)

type Client struct {
    apiKey     string
    baseURL    string
    httpClient *http.Client
    logger     *slog.Logger
    userAgent  string
}

type Option func(*Client)

func WithBaseURL(url string) Option        { return func(c *Client) { c.baseURL = url } }
func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.httpClient = h } }
func WithLogger(l *slog.Logger) Option     { return func(c *Client) { c.logger = l } }
func WithUserAgent(ua string) Option       { return func(c *Client) { c.userAgent = ua } }

func NewClient(apiKey string, opts ...Option) *Client {
    c := &Client{
        apiKey:     apiKey,
        baseURL:    "https://api.example.com",
        httpClient: &http.Client{Timeout: 30 * time.Second},
        logger:     slog.Default(),
        userAgent:  "example-go/1.0",
    }
    for _, opt := range opts {
        opt(c)
    }
    return c
}

Three things are worth noticing. The client accepts an apiKey positionally — it is required, so make it hard to forget. Everything else is optional and defaulted, so the ninety-percent case is a one-line construction. The http.Client is injectable, which sounds like a small detail but is what lets the caller add tracing, set custom transports, or plug the SDK into their existing observability stack without you having to design an escape hatch for each concern.

Subsequent snippets elide imports for brevity — assume the usual context, errors, fmt, net/http, and iter where relevant.

2. Resource groupings and method signatures

The public surface should mirror the platform’s resource model. If the platform has orders, customers, and refunds, expose them as client.Orders, client.Customers, client.Refunds — each with its own methods. This is not a stylistic preference; it directly determines how discoverable the SDK is via editor autocomplete. In practice, extend the Client struct from the previous section with a service pointer per resource, and initialize them at the end of NewClient:

// Additional fields on Client (from §1):
//     Orders    *OrdersService
//     Customers *CustomersService
//     Refunds   *RefundsService
// Assigned at the end of NewClient: c.Orders = &OrdersService{client: c}, etc.

type OrdersService struct {
    client *Client
}

func (s *OrdersService) Create(ctx context.Context, req CreateOrderRequest) (*Order, error) {
    var out Order
    if err := s.client.do(ctx, "POST", "/v1/orders", req, &out); err != nil {
        return nil, err
    }
    return &out, nil
}

Every method that talks to the platform takes a context.Context as its first argument. This is non-negotiable in Go — it’s how the caller cancels in-flight requests, propagates deadlines, and carries trace metadata. An SDK that omits ctx on request-issuing methods is broken; it will need to be rewritten before it gets used seriously.

3. Typed requests and typed responses

The types are the SDK’s contract in code form. Callers should never have to build a map[string]any and hope the JSON serializes correctly.

type CreateOrderRequest struct {
    CustomerID string      `json:"customer_id"`
    Currency   string      `json:"currency"`
    Items      []OrderItem `json:"items"`
    Metadata   map[string]string `json:"metadata,omitempty"`
}

type OrderItem struct {
    SKU         string `json:"sku"`
    Quantity    int    `json:"quantity"`
    UnitAmount  int64  `json:"unit_amount"` // cents
}

type Order struct {
    ID         string     `json:"id"`
    Status     string     `json:"status"`
    Total      int64      `json:"total"`
    CreatedAt  time.Time  `json:"created_at"`
    Customer   Customer   `json:"customer"`
}

If the platform has enum-like fields, model them as string constants and expose them as typed values so the compiler catches typos:

type OrderStatus string

const (
    OrderStatusPending   OrderStatus = "pending"
    OrderStatusPaid      OrderStatus = "paid"
    OrderStatusShipped   OrderStatus = "shipped"
    OrderStatusCancelled OrderStatus = "cancelled"
)

4. Typed errors that carry the response context

The error surface tells you whether the SDK was designed by someone who has debugged one. A well-shaped error carries the status code, the platform’s error code, the human-readable message, and — critically — the request ID so the caller can hand it to support.

type APIError struct {
    StatusCode int    `json:"-"`
    Code       string `json:"code"`
    Message    string `json:"message"`
    RequestID  string `json:"-"`
}

func (e *APIError) Error() string {
    return fmt.Sprintf("example: %s (%d): %s [request_id=%s]",
        e.Code, e.StatusCode, e.Message, e.RequestID)
}

// Callers can use errors.As to inspect.
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusTooManyRequests {
    // handle rate limiting
}

Note the errors.As pattern — the SDK returns a *APIError inside a wrapped error chain, and the caller pulls it back out with the standard-library facility. Do not invent your own error-inspection function; use what the language already provides.

5. Pagination as a first-class iterator

Pagination is where SDKs most often leak. If the SDK exposes raw cursor handling to the caller (ListOrders(cursor) returning (items, nextCursor, err)), every caller reimplements the same pagination loop, badly. Since Go 1.23, iter.Seq2 gives you an idiomatic way to hide the loop entirely.

import "iter"

func (s *OrdersService) List(ctx context.Context, filter OrderFilter) iter.Seq2[*Order, error] {
    return func(yield func(*Order, error) bool) {
        cursor := ""
        for {
            page, err := s.listPage(ctx, filter, cursor)
            if err != nil {
                yield(nil, err)
                return
            }
            for _, order := range page.Items {
                if !yield(order, nil) {
                    return
                }
            }
            if page.NextCursor == "" {
                return
            }
            cursor = page.NextCursor
        }
    }
}

// Caller code — a single loop over all orders, regardless of page count:
for order, err := range client.Orders.List(ctx, OrderFilter{Status: OrderStatusPaid}) {
    if err != nil {
        return err
    }
    fmt.Println(order.ID)
}

The caller writes a plain for range. The SDK handles page turnover, cursor plumbing, and end-of-results — the three places pagination goes wrong. A caller who wants to stop early does so with break, and the iterator function returns cleanly.

6. Streaming, retries, and instrumentation

These are the three places where “just wrap HTTP” reveals itself as insufficient.

Streaming — for server-sent events or chunked responses, the SDK owns the parser. The caller gets an iterator or a channel of events, not a raw io.Reader to pick apart themselves.

Retries — the SDK ships with sensible defaults (exponential backoff, jittered, capped, only for idempotent methods or explicit Idempotency-Key headers) and lets the caller override them.

Instrumentation — every request has a hook point for logging, tracing, and metrics. In Go, the cleanest form is a middleware slot on the injectable *http.Client, plus an emitted slog line per request at debug level.

Callers who never touch these things get correct behavior anyway; callers who need to customize have a clear seam.

7. Docs, samples, and a changelog

The last part of the surface isn’t code. It’s the README that shows the ninety-percent case in the first ten lines, the samples directory with a runnable example per method, and a changelog that says exactly what changed between versions. Without these, everything above is a client library that is technically installable and functionally invisible. What separates a great docs surface from an adequate one is covered in a companion post on this site — What makes a good SDK.

The seven parts above are the surface an SDK exposes, and whether the resulting SDK is any good depends on whether those parts were assembled with the care the rubric post walks through.

If you’re building an SDK: use this walkthrough as a scaffold, but write your first Client struct against the specific failure modes your platform’s API creates — the seven parts are universal, the shape of each one is not.