Get a quote

Designing Production-Grade REST APIs in Go for SaaS Backend Teams

Inconsistent API design is one of the most expensive engineering problems to fix after the fact. These are the design standards Voxire follows across all Go APIs for RTYLR and client systems built in Lebanon and the Gulf.

Why Inconsistent API Design Is Expensive

Inconsistent API design is one of the most expensive engineering problems to fix after the fact. Every convention that seems harmless early on: mixed naming conventions, inconsistent error formats, ad-hoc versioning, creates compounding pain as the client count grows. A response that returns { "data": [...] } on one endpoint and { "items": [...] } on another doubles the integration surface area for every client who consumes both.

Voxire has shipped Go APIs for RTYLR and several client systems across Lebanon and the Gulf. The standards in this post are what we settled on after iterating through inconsistencies that cost real engineering time to untangle. They are not the only valid approach, but they are internally consistent, which is the property that matters most.

URL Structure and Resource Naming

URLs identify resources. They should read as nouns, not verbs. The resource name should be the plural form of the entity.

Good:

GET    /api/v1/tenants/{tenant_id}/locations
POST   /api/v1/tenants/{tenant_id}/locations
GET    /api/v1/tenants/{tenant_id}/locations/{location_id}
PATCH  /api/v1/tenants/{tenant_id}/locations/{location_id}
DELETE /api/v1/tenants/{tenant_id}/locations/{location_id}

Bad:

GET  /api/v1/getLocations
POST /api/v1/createLocation
GET  /api/v1/location?id=123

The nesting depth should reflect actual ownership. A location belongs to a tenant. An order belongs to a location. Nesting beyond two levels becomes awkward. For RTYLR, the pattern is /api/v1/tenants/{tenant_id}/locations/{location_id}/orders for listing orders at a specific location. For listing all orders across a tenant's locations, a query parameter is cleaner: /api/v1/tenants/{tenant_id}/orders?location_id=....

Actions that do not map cleanly to CRUD can use a sub-resource verb as a last resort: POST /api/v1/tenants/{tenant_id}/locations/{location_id}/orders/{order_id}/void. Use this sparingly and document it explicitly.

Versioning Strategy

RTYLR and all Voxire-built APIs use URL path versioning: /api/v1/. Header versioning (Accept: application/vnd.voxire.v2+json) works fine but creates friction for clients who use curl or basic HTTP tools. URL versioning is visible, cacheable, and testable without special tooling.

The rule for incrementing the version: any breaking change requires a new version. Breaking changes are:

  • Removing a field from a response
  • Changing a field's type (string to integer, etc.)
  • Changing a field's name
  • Removing an endpoint
  • Changing authentication requirements

Non-breaking changes (adding optional fields, adding new endpoints) do not require a version bump. The old version stays available until all clients have migrated. In RTYLR's case, the mobile apps and POS terminals cannot be force-updated simultaneously, so version support windows of 6 months minimum are standard.

Consistent Response Envelope

Every API response uses the same JSON envelope:

type Response[T any] struct {
    Data  T           `json:"data"`
    Meta  *Meta       `json:"meta,omitempty"`
    Error *ErrorBody  `json:"error,omitempty"`
}

type Meta struct {
    Page       int    `json:"page,omitempty"`
    PageSize   int    `json:"page_size,omitempty"`
    Total      int64  `json:"total,omitempty"`
    NextCursor string `json:"next_cursor,omitempty"`
}

type ErrorBody struct {
    Code    string `json:"code"`
    Message string `json:"message"`
    Details any    `json:"details,omitempty"`
}

A successful list response looks like:

{
  "data": [{ "id": "...", "name": "..." }],
  "meta": { "page": 1, "page_size": 25, "total": 143 }
}

An error response looks like:

{
  "data": null,
  "error": {
    "code": "LOCATION_NOT_FOUND",
    "message": "No location with this ID exists under your account."
  }
}

The data field is always present (null on errors). The error field is always present (null on success). This predictability means clients can check response.error without inspecting the HTTP status code first, which simplifies mobile SDK implementation.

Pagination Patterns for SaaS

RTYLR uses two pagination patterns depending on the use case.

Offset pagination for bounded lists where total count matters:

GET /api/v1/tenants/{id}/locations?page=2&page_size=25

Response meta includes total, page, and page_size. This is appropriate for UI lists where the user sees a page number and total count.

Cursor-based pagination for time-series data and large datasets:

GET /api/v1/tenants/{id}/orders?cursor=eyJpZCI6IjEyMyJ9&limit=50

The cursor encodes the last seen record's position (typically a base64-encoded JSON of the sort key and ID). Response meta includes next_cursor which is null when no more results exist. This is appropriate for order history exports, audit logs, and any list that a mobile app scrolls incrementally.

The Go implementation of cursor decoding:

type OrderCursor struct {
    CreatedAt time.Time `json:"created_at"`
    ID        uuid.UUID `json:"id"`
}

func decodeCursor(encoded string) (*OrderCursor, error) {
    if encoded == "" {
        return nil, nil
    }
    data, err := base64.StdEncoding.DecodeString(encoded)
    if err != nil {
        return nil, ErrInvalidCursor
    }
    var cursor OrderCursor
    if err := json.Unmarshal(data, &cursor); err != nil {
        return nil, ErrInvalidCursor
    }
    return &cursor, nil
}

The corresponding SQL condition uses a tuple comparison for efficiency:

WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3

Error Handling Conventions

The error format follows the spirit of RFC 7807 (Problem Details) without strict compliance. Each error has:

  • A machine-readable code in SCREAMING_SNAKE_CASE
  • A human-readable message in the request language
  • An optional details object for field-level validation errors
var (
    ErrLocationNotFound  = &APIError{Code: "LOCATION_NOT_FOUND", Status: 404}
    ErrOrderAlreadyVoid  = &APIError{Code: "ORDER_ALREADY_VOIDED", Status: 409}
    ErrInvalidInput      = &APIError{Code: "INVALID_INPUT", Status: 422}
    ErrUnauthorized      = &APIError{Code: "UNAUTHORIZED", Status: 401}
    ErrTenantMismatch    = &APIError{Code: "TENANT_MISMATCH", Status: 403}
)

The HTTP status code and the code string are both informative. Clients may key their error handling on either or both. The rule is: never return a 200 with an error in the body (a surprisingly common pattern in older APIs), and never return a 500 with stack trace details in production.

Internal errors are logged with the full stack trace but the client receives only:

{
  "error": {
    "code": "INTERNAL_ERROR",
    "message": "An unexpected error occurred. Please try again or contact support."
  }
}

Authentication in Go APIs

RTYLR uses Bearer tokens in the Authorization header. The middleware extracts the token, validates the JWT, and injects a request context value:

type RequestContext struct {
    TenantID uuid.UUID
    UserID   uuid.UUID
    Role     string
}

type contextKey struct{}

func WithRequestContext(ctx context.Context, rc RequestContext) context.Context {
    return context.WithValue(ctx, contextKey{}, rc)
}

func GetRequestContext(ctx context.Context) (RequestContext, bool) {
    rc, ok := ctx.Value(contextKey{}).(RequestContext)
    return rc, ok
}

Handler functions retrieve the tenant ID from context, never from URL parameters or request body. A handler that constructs a query using a tenant_id from the request body instead of the JWT claims is a security bug waiting to happen.

OpenAPI Documentation With swaggo in Go

RTYLR's API documentation is generated from struct tags using swaggo. Keeping docs in sync with code matters: documentation that drifts from the actual API creates confusion for integration partners.

// GetLocations returns all locations for a tenant.
// @Summary List locations
// @Tags locations
// @Produce json
// @Param tenant_id path string true "Tenant ID"
// @Param page query int false "Page number" default(1)
// @Param page_size query int false "Items per page" default(25)
// @Success 200 {object} Response[[]Location]
// @Failure 401 {object} Response[any]
// @Failure 404 {object} Response[any]
// @Router /api/v1/tenants/{tenant_id}/locations [get]
func (h *LocationHandler) GetLocations(w http.ResponseWriter, r *http.Request) {
    // implementation
}

Running swag init regenerates the OpenAPI spec. The CI pipeline runs this step and fails if the generated spec has uncommitted changes, which enforces that docs stay current.

Rate Limiting Per Tenant

Per-tenant rate limiting prevents a single high-volume client from degrading the API for others. RTYLR uses a sliding window counter stored in Redis:

func (rl *RateLimiter) Allow(ctx context.Context, tenantID uuid.UUID, limit int, window time.Duration) (bool, int, error) {
    key := fmt.Sprintf("rl:%s:%d", tenantID, window.Seconds())
    count, err := rl.redis.Incr(ctx, key).Result()
    if err != nil {
        return true, 0, err // fail open on Redis errors
    }
    if count == 1 {
        rl.redis.Expire(ctx, key, window)
    }
    remaining := max(0, limit-int(count))
    return int(count) <= limit, remaining, nil
}

The rate limit headers on every response give clients visibility:

X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 743
X-RateLimit-Reset: 1716278400

The policy is to fail open on Redis errors: if the rate limiter cannot reach Redis, the request proceeds. The alternative (fail closed) causes a Redis outage to take down the API, which is a worse outcome.

Lessons From Maintaining a Go API Across Multiple MENA Clients

Building and maintaining RTYLR's API across clients in Lebanon and Gulf markets has produced a few patterns worth documenting explicitly.

Multi-tenant URL paths have a tradeoff. Including tenant_id in the URL path is explicit and easy to route, but it means every client must know their tenant ID before making any API call. An alternative is inferring the tenant from the JWT claims and using /api/v1/locations without the tenant prefix. RTYLR uses the explicit path because the POS terminals are shared across multiple tenant contexts in franchise setups, and having the tenant ID in the path makes request logs much easier to read.

API clients diverge. Mobile apps, POS terminals, web dashboards, and third-party integrations all consume the same API but have different tolerance for breaking changes. Version the API conservatively and maintain at least two supported versions simultaneously.

Test your API with a real HTTP client, not just unit tests. Integration tests that boot the actual HTTP server and make real requests catch a class of routing, middleware, and serialization bugs that unit tests miss. RTYLR's test suite boots the full handler stack with a test database for integration tests.

Not sure where to start? If your team is building a Go API for a SaaS product and wants a second opinion on architecture, or if you need help establishing these standards on an existing codebase, the Voxire engineering team is available. Start the conversation at https://voxire.com/get-a-quote/

Free PDF Download

Enjoying this article?

Enter your email and get a clean, formatted PDF of this article - free, no spam.

Free. No spam. Unsubscribe any time.

Back to blog
Chat on WhatsApp