API Client Architecture

7 minute read

Client Architecture Overview

A well-designed API client is modular, testable, and maintainable. This chapter covers architectural patterns for building production-grade clients.

Basic Client Structure

Minimal Client

package client

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
)

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

type Config struct {
    APIKey    string
    BaseURL   string
    UserAgent string
}

func NewClient(config *Config) (*Client, error) {
    if config.APIKey == "" {
        return nil, fmt.Errorf("API key is required")
    }
    
    if config.BaseURL == "" {
        config.BaseURL = "https://api.example.com"
    }
    
    return &Client{
        httpClient: &http.Client{
            Timeout: 30 * time.Second,
        },
        baseURL:   config.BaseURL,
        apiKey:    config.apiKey,
        userAgent: config.UserAgent,
    }, nil
}

Resource Methods

// Database operations
func (c *Client) GetDatabase(ctx context.Context, id string) (*Database, error) {
    path := fmt.Sprintf("/v2/databases/%s", id)
    
    var db Database
    err := c.get(ctx, path, &db)
    
    return &db, err
}

func (c *Client) CreateDatabase(ctx context.Context, input CreateDatabaseInput) (*Database, error) {
    path := "/v2/databases"
    
    var db Database
    err := c.post(ctx, path, input, &db)
    
    return &db, err
}

func (c *Client) UpdateDatabase(ctx context.Context, id string, input UpdateDatabaseInput) (*Database, error) {
    path := fmt.Sprintf("/v2/databases/%s", id)
    
    var db Database
    err := c.put(ctx, path, input, &db)
    
    return &db, err
}

func (c *Client) DeleteDatabase(ctx context.Context, id string) error {
    path := fmt.Sprintf("/v2/databases/%s", id)
    
    return c.delete(ctx, path)
}

func (c *Client) ListDatabases(ctx context.Context) ([]Database, error) {
    path := "/v2/databases"
    
    var response struct {
        Databases []Database `json:"databases"`
    }
    
    err := c.get(ctx, path, &response)
    
    return response.Databases, err
}

Interface-Based Design

Define Interface

package client

type DatabaseAPI interface {
    GetDatabase(ctx context.Context, id string) (*Database, error)
    CreateDatabase(ctx context.Context, input CreateDatabaseInput) (*Database, error)
    UpdateDatabase(ctx context.Context, id string, input UpdateDatabaseInput) (*Database, error)
    DeleteDatabase(ctx context.Context, id string) error
    ListDatabases(ctx context.Context) ([]Database, error)
}

// Ensure Client implements interface
var _ DatabaseAPI = (*Client)(nil)

Benefits:

  • Easy to mock for testing
  • Can swap implementations
  • Clear contract
  • IDE autocomplete

Mock Implementation

package client

type MockClient struct {
    GetDatabaseFunc    func(ctx context.Context, id string) (*Database, error)
    CreateDatabaseFunc func(ctx context.Context, input CreateDatabaseInput) (*Database, error)
    // ... other funcs
}

func (m *MockClient) GetDatabase(ctx context.Context, id string) (*Database, error) {
    if m.GetDatabaseFunc != nil {
        return m.GetDatabaseFunc(ctx, id)
    }
    return nil, fmt.Errorf("not implemented")
}

// Usage in tests
func TestDatabaseResource_Create(t *testing.T) {
    mockClient := &MockClient{
        CreateDatabaseFunc: func(ctx context.Context, input CreateDatabaseInput) (*Database, error) {
            return &Database{
                ID:   "db-12345",
                Name: input.Name,
            }, nil
        },
    }
    
    resource := &DatabaseResource{client: mockClient}
    // Test resource operations...
}

Layered Architecture

Layer 1: HTTP Transport

// http_client.go
package client

type httpClient struct {
    client    *http.Client
    baseURL   string
    userAgent string
}

func (h *httpClient) do(ctx context.Context, req *http.Request) (*http.Response, error) {
    req.Header.Set("User-Agent", h.userAgent)
    
    return h.client.Do(req.WithContext(ctx))
}

Layer 2: Request Builder

// request_builder.go
package client

type requestBuilder struct {
    httpClient *httpClient
    apiKey     string
}

func (rb *requestBuilder) buildRequest(ctx context.Context, method, path string, body interface{}) (*http.Request, error) {
    url := rb.httpClient.baseURL + path
    
    var bodyReader io.Reader
    if body != nil {
        jsonBody, err := json.Marshal(body)
        if err != nil {
            return nil, err
        }
        bodyReader = bytes.NewReader(jsonBody)
    }
    
    req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
    if err != nil {
        return nil, err
    }
    
    // Add authentication
    req.Header.Set("Authorization", "Bearer "+rb.apiKey)
    req.Header.Set("Content-Type", "application/json")
    
    return req, nil
}

Layer 3: Response Handler

// response_handler.go
package client

func (c *Client) handleResponse(resp *http.Response, result interface{}) error {
    defer resp.Body.Close()
    
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return fmt.Errorf("failed to read response: %w", err)
    }
    
    if resp.StatusCode >= 400 {
        return c.parseError(resp.StatusCode, body)
    }
    
    if result != nil {
        if err := json.Unmarshal(body, result); err != nil {
            return fmt.Errorf("failed to parse response: %w", err)
        }
    }
    
    return nil
}

func (c *Client) parseError(statusCode int, body []byte) error {
    var apiErr APIError
    json.Unmarshal(body, &apiErr)
    
    switch statusCode {
    case 404:
        return ErrNotFound
    case 429:
        return ErrRateLimited
    case 401, 403:
        return ErrUnauthorized
    default:
        return &apiErr
    }
}

Layer 4: Resource API

// database_client.go
package client

func (c *Client) GetDatabase(ctx context.Context, id string) (*Database, error) {
    req, err := c.buildRequest(ctx, "GET", fmt.Sprintf("/databases/%s", id), nil)
    if err != nil {
        return nil, err
    }
    
    resp, err := c.httpClient.do(ctx, req)
    if err != nil {
        return nil, err
    }
    
    var db Database
    err = c.handleResponse(resp, &db)
    
    return &db, err
}

Helper Methods Pattern

Common HTTP Methods

// helpers.go
package client

func (c *Client) get(ctx context.Context, path string, result interface{}) error {
    req, err := c.buildRequest(ctx, "GET", path, nil)
    if err != nil {
        return err
    }
    
    resp, err := c.httpClient.Do(req)
    if err != nil {
        return err
    }
    
    return c.handleResponse(resp, result)
}

func (c *Client) post(ctx context.Context, path string, body, result interface{}) error {
    req, err := c.buildRequest(ctx, "POST", path, body)
    if err != nil {
        return err
    }
    
    resp, err := c.httpClient.Do(req)
    if err != nil {
        return err
    }
    
    return c.handleResponse(resp, result)
}

func (c *Client) put(ctx context.Context, path string, body, result interface{}) error {
    req, err := c.buildRequest(ctx, "PUT", path, body)
    if err != nil {
        return err
    }
    
    resp, err := c.httpClient.Do(req)
    if err != nil {
        return err
    }
    
    return c.handleResponse(resp, result)
}

func (c *Client) delete(ctx context.Context, path string) error {
    req, err := c.buildRequest(ctx, "DELETE", path, nil)
    if err != nil {
        return err
    }
    
    resp, err := c.httpClient.Do(req)
    if err != nil {
        return err
    }
    
    return c.handleResponse(resp, nil)
}

Simplified Resource Methods

func (c *Client) GetDatabase(ctx context.Context, id string) (*Database, error) {
    var db Database
    err := c.get(ctx, fmt.Sprintf("/databases/%s", id), &db)
    return &db, err
}

func (c *Client) CreateDatabase(ctx context.Context, input CreateDatabaseInput) (*Database, error) {
    var db Database
    err := c.post(ctx, "/databases", input, &db)
    return &db, err
}

Error Types

Custom Error Types

// errors.go
package client

import "fmt"

var (
    ErrNotFound      = &ClientError{Code: "NOT_FOUND", Message: "Resource not found"}
    ErrRateLimited   = &ClientError{Code: "RATE_LIMITED", Message: "API rate limit exceeded"}
    ErrUnauthorized  = &ClientError{Code: "UNAUTHORIZED", Message: "Invalid credentials"}
)

type ClientError struct {
    Code       string `json:"code"`
    Message    string `json:"message"`
    StatusCode int    `json:"status_code"`
}

func (e *ClientError) Error() string {
    return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}

func IsNotFoundError(err error) bool {
    if err == nil {
        return false
    }
    
    if err == ErrNotFound {
        return true
    }
    
    if clientErr, ok := err.(*ClientError); ok {
        return clientErr.Code == "NOT_FOUND"
    }
    
    return false
}

func IsRateLimitedError(err error) bool {
    if err == nil {
        return false
    }
    
    if err == ErrRateLimited {
        return true
    }
    
    if clientErr, ok := err.(*ClientError); ok {
        return clientErr.Code == "RATE_LIMITED" || clientErr.StatusCode == 429
    }
    
    return false
}

Configuration Options

Functional Options Pattern

// config.go
package client

type ClientOption func(*Client) error

func WithHTTPClient(httpClient *http.Client) ClientOption {
    return func(c *Client) error {
        c.httpClient = httpClient
        return nil
    }
}

func WithBaseURL(baseURL string) ClientOption {
    return func(c *Client) error {
        c.baseURL = baseURL
        return nil
    }
}

func WithUserAgent(userAgent string) ClientOption {
    return func(c *Client) error {
        c.userAgent = userAgent
        return nil
    }
}

func WithRetryConfig(config RetryConfig) ClientOption {
    return func(c *Client) error {
        c.retryConfig = config
        return nil
    }
}

func NewClient(apiKey string, opts ...ClientOption) (*Client, error) {
    client := &Client{
        apiKey:    apiKey,
        baseURL:   "https://api.example.com",
        userAgent: "terraform-provider-mycloud/1.0.0",
        httpClient: &http.Client{
            Timeout: 30 * time.Second,
        },
    }
    
    for _, opt := range opts {
        if err := opt(client); err != nil {
            return nil, err
        }
    }
    
    return client, nil
}

// Usage
client, err := NewClient(
    "api-key-123",
    WithBaseURL("https://api.staging.example.com"),
    WithUserAgent("terraform-provider-mycloud/2.0.0"),
    WithRetryConfig(RetryConfig{
        MaxRetries: 5,
        MinWait:    1 * time.Second,
        MaxWait:    30 * time.Second,
    }),
)

Best Practices

1. Use Interfaces

// ✅ Good: Interface for testability
type DatabaseAPI interface {
    GetDatabase(ctx context.Context, id string) (*Database, error)
}

var _ DatabaseAPI = (*Client)(nil)

// ❌ Bad: Concrete type only
type Client struct {
    // ...
}
// Hard to test

2. Accept Context

// ✅ Good: Context for cancellation
func (c *Client) GetDatabase(ctx context.Context, id string) (*Database, error) {
    req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
    // ...
}

// ❌ Bad: No context
func (c *Client) GetDatabase(id string) (*Database, error) {
    req, _ := http.NewRequest("GET", url, nil)
    // Can't be cancelled
}

3. Return Custom Errors

// ✅ Good: Custom error types
if resp.StatusCode == 404 {
    return ErrNotFound
}

// ❌ Bad: Generic errors
if resp.StatusCode == 404 {
    return fmt.Errorf("not found")
}

4. Log Operations

// ✅ Good: Structured logging
func (c *Client) GetDatabase(ctx context.Context, id string) (*Database, error) {
    log.Debug("getting database", "id", id)
    
    db, err := c.get(ctx, path, &db)
    
    if err != nil {
        log.Error("failed to get database", "id", id, "error", err)
        return nil, err
    }
    
    log.Info("got database", "id", db.ID)
    return db, nil
}

Key Takeaways

  • ✅ Use interfaces for testability
  • ✅ Layer architecture for maintainability
  • ✅ Create custom error types
  • ✅ Accept context in all methods
  • ✅ Use functional options for configuration
  • ✅ Implement helper methods for DRY code
  • ✅ Log operations for debugging
  • ✅ Separate concerns (transport, auth, parsing)
  • ✅ Make clients mockable
  • ✅ Version your API paths

What’s Next?

Learn how to configure HTTP clients with timeouts, TLS, and custom transports.

Next: HTTP Client Configuration →


Previous: ← API Client Integration

Last updated: July 2, 2026

Updated: