API Client Architecture
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