Backend Services
Let’s implement the three backend services that power our order management system: a data storage service for persisting orders, and two validation services that check if an order can be placed.
Data Service
The Data Service provides a DynamoDB-like interface for storing and querying orders. Following Go idioms, the endpoint package (consumer) defines the DataService interface, while the service package (provider) defines the types and implements the interface.
HTTP Client Implementation
Create service/data.go with the types and HTTP client implementation:
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)
// OrderStatus represents the current state of an order.
type OrderStatus string
const (
// OrderStatusPending indicates the order is waiting to be processed.
OrderStatusPending OrderStatus = "pending"
// OrderStatusProcessing indicates the order is currently being processed.
OrderStatusProcessing OrderStatus = "processing"
// OrderStatusCompleted indicates the order has been successfully completed.
OrderStatusCompleted OrderStatus = "completed"
// OrderStatusFailed indicates the order has failed.
OrderStatusFailed OrderStatus = "failed"
)
// Order represents a customer order.
type Order struct {
OrderID string `json:"order_id"`
AccountID string `json:"account_id"`
CustomerID string `json:"customer_id"`
Status OrderStatus `json:"status"`
}
// QueryRequest contains the parameters for a Query operation.
type QueryRequest struct {
AccountID string `json:"account_id"`
Status OrderStatus `json:"status,omitempty"`
Cursor string `json:"cursor,omitempty"`
Limit int `json:"limit"`
}
// QueryResponse contains the result of a Query operation.
type QueryResponse struct {
Orders []Order `json:"orders"`
HasMore bool `json:"has_more"`
NextCursor string `json:"next_cursor"`
}
// PutItemRequest contains the parameters for a PutItem operation.
type PutItemRequest struct {
Order Order `json:"order"`
}
// PutItemResponse contains the result of a PutItem operation.
type PutItemResponse struct {
}
// DataClient is a client for the data service.
type DataClient struct {
baseURL string
httpClient *http.Client
}
// NewDataClient creates a new data service client.
func NewDataClient(baseURL string, httpClient *http.Client) *DataClient {
return &DataClient{
baseURL: baseURL,
httpClient: httpClient,
}
}
Query Implementation
The Query method sends all parameters in a JSON request body, following DynamoDB’s API pattern:
func (s *DataClient) Query(ctx context.Context, req *QueryRequest) (*QueryResponse, error) {
u := s.baseURL + "/data/orders"
var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(req); err != nil {
return nil, fmt.Errorf("failed to encode request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, u, &body)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var result QueryResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
PutItem Implementation
func (s *DataClient) PutItem(ctx context.Context, req *PutItemRequest) (*PutItemResponse, error) {
u := s.baseURL + "/data/orders"
var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(req); err != nil {
return nil, fmt.Errorf("failed to encode request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, u, &body)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return &PutItemResponse{}, nil
}
Restriction Service
Create service/restriction.go:
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
// Restriction represents a single restriction on an account.
type Restriction struct {
Code string `json:"code"`
Description string `json:"description"`
}
// CheckRestrictionsRequest contains the parameters for checking account restrictions.
type CheckRestrictionsRequest struct {
AccountID string `json:"account_id"`
}
// CheckRestrictionsResponse contains the result of a restriction check.
type CheckRestrictionsResponse struct {
Restrictions []Restriction `json:"restrictions"`
}
// RestrictionClient is a client for the restriction service.
type RestrictionClient struct {
baseURL string
httpClient *http.Client
}
// NewRestrictionClient creates a new restriction service client.
func NewRestrictionClient(baseURL string, httpClient *http.Client) *RestrictionClient {
return &RestrictionClient{
baseURL: baseURL,
httpClient: httpClient,
}
}
func (s *RestrictionClient) CheckRestrictions(ctx context.Context, req *CheckRestrictionsRequest) (*CheckRestrictionsResponse, error) {
u := fmt.Sprintf("%s/restrictions/%s", s.baseURL, req.AccountID)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := s.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var result CheckRestrictionsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
Eligibility Service
Create service/eligibility.go:
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
// CheckEligibilityRequest contains the parameters for checking order eligibility.
type CheckEligibilityRequest struct {
AccountID string `json:"account_id"`
}
// CheckEligibilityResponse contains the result of an eligibility check.
type CheckEligibilityResponse struct {
Eligible bool `json:"eligible"`
Reason string `json:"reason"`
}
// EligibilityClient is a client for the eligibility service.
type EligibilityClient struct {
baseURL string
httpClient *http.Client
}
// NewEligibilityClient creates a new eligibility service client.
func NewEligibilityClient(baseURL string, httpClient *http.Client) *EligibilityClient {
return &EligibilityClient{
baseURL: baseURL,
httpClient: httpClient,
}
}
func (s *EligibilityClient) CheckEligibility(ctx context.Context, req *CheckEligibilityRequest) (*CheckEligibilityResponse, error) {
u := fmt.Sprintf("%s/eligibility/%s", s.baseURL, req.AccountID)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := s.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var result CheckEligibilityResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
Business Logic Scenarios
The Wiremock stubs provide several test scenarios:
Restriction Service:
ACC-001- No restrictions (order can proceed)ACC-FRAUD- Fraud restriction (order blocked)ACC-BLOCKED- Multiple restrictions
Eligibility Service:
ACC-001- Eligible (order can proceed)ACC-INELIGIBLE- Ineligible - account type not supportedACC-NOFUNDS- Ineligible - insufficient funds
Common Patterns
All three service clients follow the same structure:
- Export domain types -
Restriction,Order,OrderStatus - Define Request/Response types - Each method takes a
*Requestand returns a*Response - Export client struct - Holds
baseURLandhttpClient - Constructor function - Returns pointer to concrete type
- Methods with consistent signature -
(ctx context.Context, req *Request) (*Response, error)
Key Benefits of Request/Response Pattern:
- Extensibility - Easy to add new fields without breaking method signatures
- Consistency - All service methods follow the same pattern
- Clarity - Request types document what data is needed
- Testability - Easy to construct test cases with different request parameters
Key Architectural Decision: Services define their own types (like Restriction, CheckRestrictionsResponse, Order, OrderStatus) and never import from the endpoint package. This ensures clean separation of concerns and prevents circular dependencies.
Data Service Specific Patterns:
- DynamoDB-Like API - All parameters sent via JSON request body instead of query parameters, matching DynamoDB’s API pattern
- Context Propagation - All methods accept
context.Contextfor timeouts and trace propagation - Error Wrapping - Use
fmt.Errorf("message: %w", err)for error chains - Resource Cleanup - Use
defer func() { _ = resp.Body.Close() }()to ensure bodies are closed while satisfying the linter’s error checking - Status Code Validation - Check for expected HTTP status codes
- Automatic Request Body Reuse - Passing
*bytes.Buffertohttp.NewRequestWithContext()automatically setsGetBody, enabling HTTP redirects and retries without manual configuration - Optional Fields - Use
omitemptyJSON tags for optional parameters (status, cursor) so they’re omitted when empty
Idiomatic Go: Consumer-Defined Interfaces
Following idiomatic Go, interfaces are defined by the consumer, not the provider. The service package provides concrete implementations, while the endpoint package defines the interfaces it needs:
endpoint/interfaces.go:
type DataService interface {
Query(ctx context.Context, req *service.QueryRequest) (*service.QueryResponse, error)
PutItem(ctx context.Context, req *service.PutItemRequest) (*service.PutItemResponse, error)
}
type RestrictionService interface {
CheckRestrictions(ctx context.Context, req *service.CheckRestrictionsRequest) (*service.CheckRestrictionsResponse, error)
}
type EligibilityService interface {
CheckEligibility(ctx context.Context, req *service.CheckEligibilityRequest) (*service.CheckEligibilityResponse, error)
}
This approach:
- Keeps dependencies minimal (endpoints only depend on methods they use)
- Makes testing easier (mock only what you need)
- Follows Go proverbs: “Accept interfaces, return structs”
Testing with httptest.Server
The service clients include comprehensive table-driven tests using httptest.Server:
service/restriction_test.go:
func TestRestrictionServiceClient_CheckRestrictions(t *testing.T) {
tests := []struct {
name string
req *CheckRestrictionsRequest
mockResponse string
mockStatusCode int
wantErr bool
wantRestrictions int
}{
{
name: "no restrictions",
req: &CheckRestrictionsRequest{AccountID: "ACC-001"},
mockResponse: `{
"restrictions": []
}`,
mockStatusCode: http.StatusOK,
wantErr: false,
wantRestrictions: 0,
},
// ... more test cases
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.mockStatusCode)
_, _ = w.Write([]byte(tt.mockResponse))
}))
defer server.Close()
client := NewRestrictionClient(server.URL, http.DefaultClient)
resp, err := client.CheckRestrictions(context.Background(), tt.req)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Len(t, resp.Restrictions, tt.wantRestrictions)
})
}
}
Key testing patterns:
- Table-driven tests - Easy to add new scenarios
- Request/Response types - Clear test case structure
- httptest.Server - Mock HTTP backends without network calls
- testify/require - Fail-fast assertions (repository standard)
See service/*_test.go for complete test suites covering success cases, errors, and edge cases.
What’s Next
With all backend services implemented and tested, let’s implement the first endpoint.