Design Document: Domain API POC

Overview

This design describes a proof-of-concept implementation for a multi-API domain architecture representing portions of the UK tax system. The system demonstrates how to model complex domains across multiple RESTful APIs while maintaining clear boundaries, shared components, and cross-API resource traversal capabilities.

Critical Design Decision: API Maturity Levels

To demonstrate real-world integration challenges, the POC implements three backend APIs with different maturity levels, showcasing how the gateway can normalize legacy systems into a consistent external interface:

  1. Taxpayer API (High Maturity): Modern JSON-based API that natively provides hypermedia links

    • Returns JSON responses with _links field
    • Implements the target architecture natively
    • No gateway transformation needed beyond aggregation
  2. Income Tax API (Medium Maturity): JSON-based API without native hypermedia support

    • Returns JSON responses but without _links field
    • Gateway injects links based on configuration
    • Demonstrates link injection for APIs that can’t be modified
  3. Payment API (Low Maturity): Legacy XML-based API

    • Returns XML responses
    • Gateway transforms XML to JSON and injects links
    • Demonstrates full transformation for legacy systems

This maturity spectrum reflects the reality of enterprise estates where modern and legacy systems must coexist and be presented consistently to consumers.

The POC will implement three separate Domain APIs:

  1. Taxpayer API: Manages taxpayer identity and registration information (High Maturity - JSON with links)
  2. Income Tax API: Handles income tax returns, assessments, and calculations (Medium Maturity - JSON without links)
  3. Payment API: Manages tax payments and payment allocations (Low Maturity - XML)

Each API will have its own OpenAPI specification, but they will share common components (e.g., Address, Money, Date types) through reusable specification fragments. The gateway layer ensures all APIs present a consistent interface to clients with hypermedia navigation capabilities, regardless of backend maturity.

Key Design Principles

  • Domain Boundaries: Each API represents a distinct subdomain with clear responsibilities
  • Loose Coupling: APIs are independently deployable and versioned
  • Shared Vocabulary: Common types are defined once and reused via OpenAPI $ref
  • Hypermedia Navigation: Resources include links to related resources, enabling discovery
  • Simplicity: Lightweight JSON structure without full JSON API compliance overhead
  • Legacy Integration: Gateway normalizes varying backend maturity levels into consistent external interface

Architecture

System Components

graph TB
    subgraph "Client Layer"
        CLIENT[API Clients]
    end
    
    subgraph "Gateway Layer (Optional)"
        APIGW[AWS API Gateway<br/>LocalStack for local dev]
        AGG_LAMBDA[Aggregation Lambda<br/>Handles ?include parameter]
    end
    
    subgraph "API Layer"
        TP[Taxpayer API<br/>v1]
        IT[Income Tax API<br/>v1]
        PM[Payment API<br/>v1]
    end
    
    subgraph "Specification Layer"
        TP_OAS[taxpayer-api.yaml]
        IT_OAS[income-tax-api.yaml]
        PM_OAS[payment-api.yaml]
        SHARED[shared-components.yaml]
    end
    
    subgraph "Tooling"
        VALIDATOR[OAS Validator]
        MOCK[Mock Server]
        DOCS[Documentation Generator]
        VIEWER[OAS Viewer/Executor]
    end
    
    CLIENT -->|Option 1: Direct Access| TP
    CLIENT -->|Option 1: Direct Access| IT
    CLIENT -->|Option 1: Direct Access| PM
    CLIENT -->|Option 2: With Aggregation| APIGW
    
    APIGW --> AGG_LAMBDA
    AGG_LAMBDA --> TP
    AGG_LAMBDA --> IT
    AGG_LAMBDA --> PM
    
    TP_OAS --> SHARED
    IT_OAS --> SHARED
    PM_OAS --> SHARED
    
    TP_OAS --> TP
    IT_OAS --> IT
    PM_OAS --> PM
    
    TP_OAS --> VALIDATOR
    IT_OAS --> VALIDATOR
    PM_OAS --> VALIDATOR
    
    TP_OAS --> MOCK
    IT_OAS --> MOCK
    PM_OAS --> MOCK
    
    TP_OAS --> DOCS
    IT_OAS --> DOCS
    PM_OAS --> DOCS
    
    TP_OAS --> VIEWER
    IT_OAS --> VIEWER
    PM_OAS --> VIEWER
    
    VIEWER --> TP
    VIEWER --> IT
    VIEWER --> PM

API Boundaries

Taxpayer API (/taxpayers) - High Maturity

  • Maturity Level: Modern JSON API with native hypermedia support
  • Response Format: JSON with _links field
  • Gateway Role: Minimal - only handles aggregation via include parameter
  • Manages taxpayer registration and identity
  • Resources: Taxpayer, Address, ContactDetails
  • Relationships: Links to tax returns (Income Tax API), payments (Payment API)

Income Tax API (/tax-returns) - Medium Maturity

  • Maturity Level: JSON API without native hypermedia support
  • Response Format: JSON without _links field
  • Gateway Role: Injects _links based on configuration, handles aggregation
  • Handles income tax returns and assessments
  • Resources: TaxReturn, Assessment, TaxCalculation
  • Relationships: Links to taxpayer (Taxpayer API), payments (Payment API) - injected by gateway

Payment API (/payments) - Low Maturity

  • Maturity Level: Legacy XML-based API
  • Response Format: XML
  • Gateway Role: Transforms XML to JSON, injects _links based on configuration, handles aggregation
  • Manages tax payments and allocations
  • Resources: Payment, PaymentAllocation, PaymentMethod
  • Relationships: Links to taxpayer (Taxpayer API), tax returns (Income Tax API) - injected by gateway

Gateway Layer

The gateway layer provides cross-API aggregation capabilities, allowing clients to request related resources from multiple APIs in a single request using the include query parameter.

AWS API Gateway:

  • REST API endpoint that proxies requests to backend APIs
  • Routes requests based on path and method
  • Integrates with Lambda for aggregation logic
  • Local development via LocalStack (emulates AWS API Gateway locally)
  • Production deployment to AWS API Gateway

Aggregation Lambda:

  • Parses include query parameter from requests
  • Makes parallel requests to backend APIs
  • Merges responses into unified _included structure
  • Handles partial failures gracefully
  • Returns standard error responses for gateway-level issues
  • Implementation: Node.js/TypeScript for type safety and OpenAPI integration

Gateway Access Patterns:

  • Direct Access: Clients call backend APIs directly, follow _links for traversal
  • Gateway Access: Clients call through API Gateway, use include for aggregation

LocalStack Setup:

  • Docker-based AWS service emulation
  • Supports API Gateway, Lambda, and other AWS services
  • Enables local development without AWS account
  • Configuration via docker-compose or CLI
  • Same API Gateway configuration works in both LocalStack and AWS

Tooling Components

OAS Validator:

  • Validates OpenAPI specification files against OpenAPI 3.0+ schema
  • Checks $ref resolution and shared component references
  • Runs as part of build/CI pipeline

Mock Server:

  • Generates mock API servers from OAS files
  • Provides realistic responses based on examples in OAS
  • Enables testing without full implementation
  • Tools: Prism, OpenAPI Generator

Documentation Generator:

  • Generates human-readable API documentation from OAS files
  • Produces static HTML documentation
  • Tools: Redoc, Swagger UI, Stoplight

OAS Viewer/Executor:

  • Interactive tool for viewing and executing API requests
  • Combines documentation viewing with request execution
  • Key features:
    • Browse API endpoints and schemas
    • Execute requests directly from the interface
    • View request/response examples
    • Test cross-API traversal by following relationship links
    • Validate responses against OAS schemas
  • Tools: Swagger UI (with “Try it out” feature), Postman, Insomnia
  • Distinction from Documentation Generator:
    • Documentation Generator: Static, read-only HTML documentation
    • OAS Viewer/Executor: Interactive tool for both viewing AND executing API calls

The OAS Viewer/Executor is essential for the POC as it allows developers to:

  1. Explore the API structure interactively
  2. Test cross-API relationships by following links
  3. Validate that responses match the OAS specifications
  4. Demonstrate the multi-API architecture to stakeholders

Cross-API Relationships

graph LR
    TP[Taxpayer] -->|has many| TR[Tax Returns]
    TP -->|has many| PM[Payments]
    TR -->|has many| AS[Assessments]
    TR -->|allocated by| PA[Payment Allocations]
    PM -->|allocates to| PA
    PA -->|references| TR

Gateway Implementation

The gateway layer implements cross-API aggregation using AWS API Gateway and Lambda functions. This section describes the implementation approach for both local development (LocalStack) and production (AWS).

Architecture Pattern

Client Request with ?include
         ↓
   AWS API Gateway
         ↓
   Aggregation Lambda
         ↓
    ┌────┴────┬────────┐
    ↓         ↓        ↓
Taxpayer  Income Tax  Payment
   API       API       API
    ↓         ↓        ↓
    └────┬────┴────────┘
         ↓
   Merged Response

AWS API Gateway Configuration

The API Gateway acts as a proxy to the aggregation Lambda:

# API Gateway REST API (OpenAPI 3.0 format)
openapi: 3.0.3
info:
  title: Domain API Gateway
  version: 1.0.0
paths:
  /{proxy+}:
    x-amazon-apigateway-any-method:
      parameters:
        - name: proxy
          in: path
          required: true
          schema:
            type: string
        - name: include
          in: query
          required: false
          schema:
            type: string
          description: Comma-separated list of relationships to include
      x-amazon-apigateway-integration:
        type: aws_proxy
        httpMethod: POST
        uri: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${AggregationLambda.Arn}/invocations
        passthroughBehavior: when_no_match

Aggregation Lambda Implementation

The Lambda function handles the aggregation logic:

Key Responsibilities:

  1. Parse incoming API Gateway event
  2. Extract include parameter from query string
  3. Forward request to appropriate backend API
  4. If include is present, fetch related resources
  5. Merge responses into unified structure
  6. Return aggregated response

Implementation Approach (TypeScript/Node.js):

// Lambda handler structure
export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> {
  const { path, httpMethod, queryStringParameters, body } = event;
  const includeParam = queryStringParameters?.include;
  
  try {
    // 1. Route to appropriate backend API
    const backendUrl = routeToBackend(path);
    
    // 2. Fetch primary resource
    const primaryResponse = await fetch(backendUrl, {
      method: httpMethod,
      body: body,
      headers: { 'Content-Type': 'application/json' }
    });
    
    const primaryData = await primaryResponse.json();
    
    // 3. If no include parameter, return as-is
    if (!includeParam) {
      return {
        statusCode: primaryResponse.status,
        body: JSON.stringify(primaryData),
        headers: { 'Content-Type': 'application/json' }
      };
    }
    
    // 4. Parse include parameter and fetch related resources
    const includes = includeParam.split(',').map(s => s.trim());
    const includedData = await fetchIncludedResources(primaryData, includes);
    
    // 5. Merge and return
    const aggregatedResponse = {
      ...primaryData,
      _included: includedData
    };
    
    return {
      statusCode: 200,
      body: JSON.stringify(aggregatedResponse),
      headers: { 'Content-Type': 'application/json' }
    };
    
  } catch (error) {
    return {
      statusCode: 502,
      body: JSON.stringify({
        error: {
          code: 'GATEWAY_ERROR',
          message: 'Failed to aggregate resources',
          details: error.message
        }
      }),
      headers: { 'Content-Type': 'application/json' }
    };
  }
}
 
// Helper: Route request to appropriate backend API
function routeToBackend(path: string): string {
  const backends = {
    '/taxpayer/': process.env.TAXPAYER_API_URL,
    '/income-tax/': process.env.INCOME_TAX_API_URL,
    '/payment/': process.env.PAYMENT_API_URL
  };
  
  for (const [prefix, url] of Object.entries(backends)) {
    if (path.startsWith(prefix)) {
      return `${url}${path}`;
    }
  }
  
  throw new Error(`No backend found for path: ${path}`);
}
 
// Helper: Fetch included resources based on _links
async function fetchIncludedResources(
  primaryData: any,
  includes: string[]
): Promise<Record<string, any[]>> {
  const links = primaryData._links || {};
  const includedData: Record<string, any[]> = {};
  
  // Fetch each requested relationship in parallel
  const fetchPromises = includes.map(async (relationshipName) => {
    const link = links[relationshipName];
    if (!link || !link.href) {
      return; // Skip if link doesn't exist
    }
    
    try {
      const response = await fetch(link.href);
      if (response.ok) {
        const data = await response.json();
        // Handle both single resources and collections
        includedData[relationshipName] = Array.isArray(data.items) 
          ? data.items 
          : [data];
      }
    } catch (error) {
      console.error(`Failed to fetch ${relationshipName}:`, error);
      // Continue with partial results
    }
  });
  
  await Promise.all(fetchPromises);
  return includedData;
}

LocalStack Configuration

For local development, use LocalStack to emulate AWS services:

docker-compose.yml:

version: '3.8'
 
services:
  localstack:
    image: localstack/localstack:latest
    ports:
      - "4566:4566"  # LocalStack gateway
      - "4510-4559:4510-4559"  # External services port range
    environment:
      - SERVICES=apigateway,lambda,iam,cloudformation
      - DEBUG=1
      - LAMBDA_EXECUTOR=docker
      - DOCKER_HOST=unix:///var/run/docker.sock
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock"
      - "./localstack-init:/etc/localstack/init/ready.d"  # Init scripts
  
  taxpayer-api:
    image: stoplight/prism:latest
    command: mock -h 0.0.0.0 /specs/taxpayer-api.yaml
    ports:
      - "8081:4010"
    volumes:
      - ./specs/taxpayer:/specs
  
  income-tax-api:
    image: stoplight/prism:latest
    command: mock -h 0.0.0.0 /specs/income-tax-api.yaml
    ports:
      - "8082:4010"
    volumes:
      - ./specs/income-tax:/specs
  
  payment-api:
    image: stoplight/prism:latest
    command: mock -h 0.0.0.0 /specs/payment-api.yaml
    ports:
      - "8083:4010"
    volumes:
      - ./specs/payment:/specs

LocalStack Initialization Script (tools/localstack-init.sh):

#!/bin/bash
# LocalStack initialization script for API Gateway and Lambda setup
# This script is called by the Taskfile during gateway setup
 
set -e
 
echo "Waiting for LocalStack to be ready..."
awslocal apigateway get-rest-apis || exit 1
 
echo "Creating Lambda function..."
cd /tmp
zip aggregation-lambda.zip index.js
awslocal lambda create-function \
  --function-name aggregation-lambda \
  --runtime nodejs18.x \
  --role arn:aws:iam::000000000000:role/lambda-role \
  --handler index.handler \
  --zip-file fileb://aggregation-lambda.zip
 
echo "Creating API Gateway with custom ID..."
# Use a custom API ID for cleaner URLs
CUSTOM_API_ID="domain-api"
 
awslocal apigateway create-rest-api \
  --name domain-api-gateway \
  --rest-api-id $CUSTOM_API_ID
 
# Get root resource
ROOT_ID=$(awslocal apigateway get-resources \
  --rest-api-id $CUSTOM_API_ID \
  --query 'items[0].id' \
  --output text)
 
# Create proxy resource
RESOURCE_ID=$(awslocal apigateway create-resource \
  --rest-api-id $CUSTOM_API_ID \
  --parent-id $ROOT_ID \
  --path-part '{proxy+}' \
  --query 'id' \
  --output text)
 
# Create ANY method
awslocal apigateway put-method \
  --rest-api-id $CUSTOM_API_ID \
  --resource-id $RESOURCE_ID \
  --http-method ANY \
  --authorization-type NONE
 
# Create Lambda integration
awslocal apigateway put-integration \
  --rest-api-id $CUSTOM_API_ID \
  --resource-id $RESOURCE_ID \
  --http-method ANY \
  --type AWS_PROXY \
  --integration-http-method POST \
  --uri arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:aggregation-lambda/invocations
 
# Deploy API
awslocal apigateway create-deployment \
  --rest-api-id $CUSTOM_API_ID \
  --stage-name dev
 
echo "✓ API Gateway setup complete"
echo ""
echo "Gateway URL (LocalStack format): http://localhost:4566/restapis/$CUSTOM_API_ID/dev/_user_request_"
echo "Gateway URL (Custom domain): http://$CUSTOM_API_ID.execute-api.localhost.localstack.cloud:4566"
echo ""
echo "Example requests:"
echo "  curl http://$CUSTOM_API_ID.execute-api.localhost.localstack.cloud:4566/taxpayers"
echo "  curl http://$CUSTOM_API_ID.execute-api.localhost.localstack.cloud:4566/taxpayers/TP123456?include=taxReturns"

Note on LocalStack Custom Domains: LocalStack supports custom API IDs and provides a special domain format {api-id}.execute-api.localhost.localstack.cloud:4566 that maps to the API Gateway. This provides cleaner URLs without requiring the full /restapis/{id}/dev/_user_request_ path. The custom domain format works with LocalStack’s DNS resolution and is the recommended approach for local development.

Content Negotiation: Pass-Through vs Aggregated Modes

The gateway supports three distinct operational modes, selected via the Accept header:

1. Aggregated Mode (Default):

  • Trigger: No Accept header, or Accept: application/vnd.domain+json (future: Accept: application/vnd.domain+xml for XML aggregation)
  • Behavior:
    • Processes include parameter for cross-API aggregation
    • Adds _included section when includes are requested
    • Returns application/vnd.domain+json (or requested format in future extensions)
  • Use Case: Clients want the enhanced gateway features (includes)
  • Media Type: application/vnd.domain+json indicates the enriched response structure with aggregation support
  • Note: No URL rewriting needed since gateway is at root path (/)

2. Simple REST Mode:

  • Trigger: Accept: application/json header
  • Behavior:
    • Proxies request to backend API
    • Ignores include parameter (not processed)
    • No _included section added
    • Returns application/json
  • Use Case: Clients want standard REST JSON responses without aggregation overhead
  • Media Type: application/json indicates standard REST JSON response

3. Pass-Through Mode:

  • Trigger: Accept: application/vnd.raw header
  • Behavior:
    • Proxies request directly to backend API without modification
    • Returns response exactly as received from backend (no transformation)
    • Preserves backend’s Content-Type header (typically application/json)
    • Ignores include parameter (not processed)
  • Use Case: Clients want direct access to backend API behavior, testing, or when aggregation is not needed
  • Media Type: application/vnd.raw indicates raw backend response

Implementation in Lambda:

export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> {
  const { path, httpMethod, queryStringParameters, body, headers } = event;
  const acceptHeader = headers['Accept'] || headers['accept'] || '';
  const isPassThrough = acceptHeader.includes('application/vnd.raw');
  const isSimpleRest = acceptHeader.includes('application/json');
  
  try {
    // Route to appropriate backend API
    const backendUrl = routeToBackend(path);
    
    // Fetch from backend
    const backendResponse = await fetch(backendUrl, {
      method: httpMethod,
      body: body,
      headers: { 'Content-Type': 'application/json' }
    });
    
    // Pass-through mode: return backend response as-is
    if (isPassThrough) {
      const backendData = await backendResponse.text();
      return {
        statusCode: backendResponse.status,
        body: backendData,
        headers: {
          'Content-Type': backendResponse.headers.get('Content-Type') || 'application/json'
        }
      };
    }
    
    const primaryData = await backendResponse.json();
    
    // Simple REST mode: no transformation, just return
    if (isSimpleRest) {
      return {
        statusCode: backendResponse.status,
        body: JSON.stringify(primaryData),
        headers: { 'Content-Type': 'application/json' }
      };
    }
    
    // Aggregated mode: process includes
    // Triggered by no Accept header or Accept: application/vnd.domain+json
    // Future: Accept: application/vnd.domain+xml for XML aggregation
    const includeParam = queryStringParameters?.include;
    
    if (!includeParam) {
      // No includes requested
      return {
        statusCode: backendResponse.status,
        body: JSON.stringify(primaryData),
        headers: { 'Content-Type': 'application/vnd.domain+json' }
      };
    }
    
    // Process includes and merge
    const includes = includeParam.split(',').map(s => s.trim());
    const includedData = await fetchIncludedResources(primaryData, includes);
    
    const aggregatedResponse = {
      ...primaryData,
      _included: includedData
    };
    
    return {
      statusCode: 200,
      body: JSON.stringify(aggregatedResponse),
      headers: { 'Content-Type': 'application/vnd.domain+json' }
    };
    
  } catch (error) {
    return {
      statusCode: 502,
      body: JSON.stringify({
        error: {
          code: 'GATEWAY_ERROR',
          message: 'Failed to process request',
          details: error.message
        }
      }),
      headers: { 'Content-Type': 'application/json' }
    };
  }
}

OpenAPI Specification Integration:

Each API specification includes three server entries to document all modes:

servers:
  - url: https://domain-api.lab.local.ctoaas.co
    description: "Gateway API (aggregated with includes) - Default"
  - url: https://domain-api.lab.local.ctoaas.co
    description: "Gateway API (simple REST, use Accept: application/json)"
  - url: https://domain-api.lab.local.ctoaas.co
    description: "Gateway API (pass-through, use Accept: application/vnd.raw)"

Swagger UI Integration:

Swagger UI can be configured with a request interceptor to automatically set the Accept header based on the selected server:

SwaggerUIBundle({
  // ... other config
  requestInterceptor: (request) => {
    // Check which server is selected
    const selectedServer = SwaggerUIBundle.getConfigs().url;
    if (selectedServer && selectedServer.description.includes('pass-through')) {
      request.headers['Accept'] = 'application/vnd.raw';
    } else if (selectedServer && selectedServer.description.includes('simple REST')) {
      request.headers['Accept'] = 'application/json';
    } else {
      // Default to aggregated mode
      request.headers['Accept'] = 'application/vnd.domain+json';
    }
    return request;
  }
});

Benefits of This Approach:

  • Single ingress/gateway endpoint for all modes
  • No path-based routing complexity
  • No URL rewriting needed (gateway at root path)
  • Standard HTTP content negotiation pattern
  • Three clear modes for different use cases:
    • Aggregated: Full gateway features with includes
    • Simple REST: Standard JSON without aggregation overhead
    • Pass-through: Direct backend access for testing
  • Easy to test all modes from documentation site
  • Backend APIs remain unaware of gateway modes
  • Clear separation of concerns (gateway handles mode switching)
  • Simplified implementation without URL manipulation

Taskfile Integration

Common gateway operations are managed via Taskfile:

Taskfile.yaml:

version: '3'
 
tasks:
  gateway:start:
    desc: Start LocalStack and backend mock servers
    cmds:
      - docker-compose up -d localstack taxpayer-api income-tax-api payment-api
      - echo "Waiting for services to be ready..."
      - sleep 5
      - task: gateway:init
 
  gateway:init:
    desc: Initialize API Gateway and Lambda in LocalStack
    cmds:
      - chmod +x tools/localstack-init.sh
      - docker-compose exec localstack /tools/localstack-init.sh
 
  gateway:stop:
    desc: Stop LocalStack and backend services
    cmds:
      - docker-compose down
 
  gateway:logs:
    desc: View gateway logs
    cmds:
      - docker-compose logs -f localstack
 
  gateway:test:
    desc: Test gateway aggregation
    cmds:
      - |
        echo "Testing direct API access..."
        curl http://localhost:8081/taxpayer/v1/taxpayers/TP123456
        echo ""
        echo "Testing gateway with include parameter..."
        curl "http://localhost:4566/restapis/\${API_ID}/dev/_user_request_/taxpayer/v1/taxpayers/TP123456?include=taxReturns"
 
  gateway:status:
    desc: Check gateway and backend API status
    cmds:
      - docker-compose ps
      - echo "Checking LocalStack API Gateway..."
      - docker-compose exec localstack awslocal apigateway get-rest-apis
 
  lambda:build:
    desc: Build aggregation Lambda function
    dir: gateway/lambda
    cmds:
      - npm install
      - npm run build
      - zip -r aggregation-lambda.zip dist/ node_modules/
 
  lambda:deploy:
    desc: Deploy Lambda to LocalStack
    deps: [lambda:build]
    cmds:
      - docker-compose exec localstack awslocal lambda update-function-code \
          --function-name aggregation-lambda \
          --zip-file fileb:///tmp/aggregation-lambda.zip
 
  lambda:logs:
    desc: View Lambda logs
    cmds:
      - docker-compose exec localstack awslocal logs tail /aws/lambda/aggregation-lambda --follow

Deployment Considerations

Local Development:

  • Use LocalStack for AWS service emulation
  • Use Prism mock servers for backend APIs
  • Lambda runs in Docker containers
  • Fast iteration without AWS costs

Production Deployment:

  • Deploy to AWS API Gateway (REST API)
  • Deploy Lambda to AWS Lambda service
  • Backend APIs run as containers or serverless
  • Use AWS CDK or CloudFormation for infrastructure as code
  • Consider API Gateway caching for performance
  • Implement proper IAM roles and permissions

Environment Variables:

# Backend API URLs (different per environment)
TAXPAYER_API_URL=http://taxpayer-api:8081
INCOME_TAX_API_URL=http://income-tax-api:8082
PAYMENT_API_URL=http://payment-api:8083
 
# LocalStack endpoint (local only)
AWS_ENDPOINT_URL=http://localhost:4566

Error Handling in Gateway

The gateway handles various error scenarios:

Backend API Unavailable:

{
  "error": {
    "code": "UPSTREAM_API_ERROR",
    "message": "Unable to reach Income Tax API",
    "status": 502,
    "upstreamService": "income-tax-api"
  }
}

Partial Include Failure:

  • Gateway returns primary resource successfully
  • Omits failed includes from _included
  • Logs errors for monitoring
  • Client can retry specific relationships

Invalid Include Parameter:

  • Gateway ignores unknown relationship names
  • Only fetches relationships present in _links
  • No error returned for invalid includes

Components and Interfaces

OpenAPI Specification Structure

Each API will have its own OpenAPI 3.0 specification file with the following structure:

openapi: 3.0.3
info:
  title: {API Name}
  version: 1.0.0
  description: {API Description}
servers:
  - url: http://localhost:8080/{domain}/v1
paths:
  # API endpoints
components:
  schemas:
    # Local schemas
  parameters:
    # Reusable parameters
  responses:
    # Reusable responses
  examples:
    # Example data

Shared Components Structure

The shared-components.yaml file will define reusable types:

components:
  schemas:
    Address:
      type: object
      required: [line1, postcode, country]
      properties:
        line1: {type: string}
        line2: {type: string}
        line3: {type: string}
        postcode: {type: string, pattern: '^[A-Z]{1,2}[0-9][A-Z0-9]? ?[0-9][A-Z]{2}$'}
        country: {type: string, enum: [GB, UK]}
    
    Money:
      type: object
      required: [amount, currency]
      properties:
        amount: {type: number, format: decimal}
        currency: {type: string, enum: [GBP], default: GBP}
    
    DateRange:
      type: object
      required: [startDate, endDate]
      properties:
        startDate: {type: string, format: date}
        endDate: {type: string, format: date}
    
    Links:
      type: object
      description: Hypermedia links to related resources
      properties:
        self: {type: string, format: uri}
      additionalProperties:
        type: object
        properties:
          href: {type: string, format: uri}
          type: {type: string}
          title: {type: string}

Resource Structure Pattern

All resources follow a consistent structure:

{
  "id": "string",
  "type": "resource-type",
  "attributes": {
    // Resource-specific attributes
  },
  "_links": {
    "self": {"href": "/resource/id"},
    "related-resource": {
      "href": "/resource/id",
      "type": "resource-type",
      "title": "Human readable description"
    }
  }
}

Design Decision: Response Structure

Decision: Use a flat JSON structure without root-level “data” wrapper, with attributes at the top level and hypermedia links in a _links field.

Rationale:

  • Simplicity: Clients can access resource attributes directly without unwrapping
  • JSON API Inspiration: Borrows the _links pattern from JSON API for hypermedia navigation
  • Not Full JSON API: Avoids the complexity of full JSON API compliance (no required “data” wrapper, no “relationships” object)
  • Developer Experience: Simpler response structure is easier to work with in client code

Alternative Considered: Full JSON API compliance with “data” wrapper and “relationships” object

  • Rejected: Too complex for this POC, adds unnecessary nesting

Implementation:

  • Resource attributes appear at top level of JSON object
  • _links field contains hypermedia links to related resources
  • _included field (when using gateway aggregation) contains embedded related resources
  • type field identifies the resource type (borrowed from JSON API)

Example:

{
  "id": "TP123456",
  "type": "taxpayer",
  "nino": "AB123456C",
  "name": {
    "firstName": "John",
    "lastName": "Smith"
  },
  "_links": {
    "self": {"href": "/taxpayers/TP123456"},
    "taxReturns": {"href": "/tax-returns?taxpayerId=TP123456"}
  }
}

To minimize the number of API requests, clients can use the include query parameter to embed related resources in the response. This feature is implemented by the Gateway Layer (AWS API Gateway + Lambda), not by the backend APIs themselves.

Access Patterns:

  1. Direct API Access (no gateway):

    • Client calls backend APIs directly
    • Client follows _links to traverse relationships
    • Multiple HTTP requests required for related resources
    • Example: GET http://taxpayer-api:8081/taxpayer/v1/taxpayers/TP123456
  2. Gateway Access (with aggregation):

    • Client calls through AWS API Gateway
    • Gateway Lambda handles include parameter
    • Single HTTP request returns aggregated response
    • Example: GET http://api-gateway/taxpayer/v1/taxpayers/TP123456?include=taxReturns

Single Resource with Includes:

GET /taxpayer/v1/taxpayers/{id}?include=taxReturns,payments

Collection with Includes:

GET /taxpayer/v1/taxpayers?include=taxReturns

When the include parameter is used, the gateway aggregation Lambda:

  1. Parses the include parameter
  2. Fetches the primary resource from the backend API
  3. Extracts relationship URLs from _links
  4. Makes parallel requests to related backend APIs
  5. Merges responses into _included field
  6. Returns unified response to client

The response includes an _included field containing the related resources:

{
  "id": "TP123456",
  "type": "taxpayer",
  "nino": "AB123456C",
  "name": {
    "firstName": "John",
    "lastName": "Smith"
  },
  "_links": {
    "self": {"href": "http://localhost:8080/taxpayer/v1/taxpayers/TP123456"},
    "taxReturns": {
      "href": "http://localhost:8080/income-tax/v1/tax-returns?taxpayerId=TP123456",
      "type": "collection"
    }
  },
  "_included": {
    "taxReturns": [
      {
        "id": "TR20230001",
        "type": "tax-return",
        "taxpayerId": "TP123456",
        "taxYear": "2023-24",
        "status": "assessed",
        "_links": {
          "self": {"href": "http://localhost:8080/income-tax/v1/tax-returns/TR20230001"}
        }
      }
    ]
  }
}

Include Parameter Rules:

  • Multiple relationships can be included using comma-separated values: ?include=rel1,rel2
  • Only relationships defined in _links can be included
  • Cross-API includes are supported (e.g., Taxpayer API can include resources from Income Tax API)
  • If a relationship doesn’t exist or returns no data, the field is omitted from _included
  • Backend APIs are unaware of the include parameter - aggregation is purely a gateway concern

Understanding _links vs _includes:

Both fields serve distinct purposes and are present together when includes are used:

  • _links (Always present):

    • Shows what relationships are available for this resource
    • Provides URLs to fetch relationships independently
    • Enables HATEOAS - clients can discover and navigate relationships
    • Allows fetching fresh data, pagination, or filtering
    • Present regardless of whether include parameter was used
  • _includes (Only when included):

    • Shows which specific resources are currently embedded in this response
    • Lists IDs so clients can find them in the _included section
    • Only present when the include parameter requested this relationship
    • Provides the “join key” between parent and included resources

Example showing both fields:

{
  "id": "TR20230002",
  "_links": {
    "assessments": {
      "href": "/assessments?taxReturnId=TR20230002",
      "type": "collection"
    }
  },
  "_includes": {
    "assessments": ["AS20220001"]
  }
}

This tells clients:

  1. From _links: “All assessments are available at this URL” (for pagination, refresh, etc.)
  2. From _includes: “Assessment AS20220001 is embedded in this response’s _included section”

Why both are needed:

  • Pagination: If there are 100 assessments but only 10 included, _links shows where to get more
  • Selective inclusion: Client might include only recent assessments, _links shows where to get all
  • Refresh: Client can fetch fresh data without re-fetching the entire parent resource
  • Discovery: Clients can discover relationships even without prior knowledge

For collection endpoints, _included is at the collection level, not nested in each item, and each item references its included resources by ID.

Collection with Include Example:

Request: GET /taxpayers?include=taxReturns

Response:

{
  "items": [
    {
      "id": "TP123456",
      "type": "taxpayer",
      "nino": "AB123456C",
      "name": {"firstName": "John", "lastName": "Smith"},
      "_links": {
        "self": {"href": "http://localhost:8080/taxpayer/v1/taxpayers/TP123456"},
        "taxReturns": {
          "href": "http://localhost:8080/income-tax/v1/tax-returns?taxpayerId=TP123456",
          "type": "collection"
        }
      },
      "_includes": {
        "taxReturns": ["TR20230001", "TR20220001"]
      }
    },
    {
      "id": "TP789012",
      "type": "taxpayer",
      "nino": "CD789012E",
      "name": {"firstName": "Jane", "lastName": "Doe"},
      "_links": {
        "self": {"href": "http://localhost:8080/taxpayer/v1/taxpayers/TP789012"},
        "taxReturns": {
          "href": "http://localhost:8080/income-tax/v1/tax-returns?taxpayerId=TP789012",
          "type": "collection"
        }
      },
      "_includes": {
        "taxReturns": ["TR20230002"]
      }
    }
  ],
  "_included": {
    "taxReturns": [
      {
        "id": "TR20230001",
        "type": "tax-return",
        "taxpayerId": "TP123456",
        "taxYear": "2023-24",
        "status": "assessed",
        "totalIncome": {"amount": 50000.00, "currency": "GBP"},
        "_links": {
          "self": {"href": "http://localhost:8080/income-tax/v1/tax-returns/TR20230001"}
        }
      },
      {
        "id": "TR20220001",
        "type": "tax-return",
        "taxpayerId": "TP123456",
        "taxYear": "2022-23",
        "status": "closed",
        "totalIncome": {"amount": 48000.00, "currency": "GBP"},
        "_links": {
          "self": {"href": "http://localhost:8080/income-tax/v1/tax-returns/TR20220001"}
        }
      },
      {
        "id": "TR20230002",
        "type": "tax-return",
        "taxpayerId": "TP789012",
        "taxYear": "2023-24",
        "status": "submitted",
        "totalIncome": {"amount": 35000.00, "currency": "GBP"},
        "_links": {
          "self": {"href": "http://localhost:8080/income-tax/v1/tax-returns/TR20230002"}
        }
      }
    ]
  },
  "_links": {
    "self": {"href": "http://localhost:8080/taxpayer/v1/taxpayers?include=taxReturns"}
  }
}

This structure:

  • Avoids duplication when multiple items reference the same included resource
  • Keeps the collection response flat and efficient
  • Each item has an _includes field with IDs referencing resources in the collection-level _included
  • All included resources are in a single _included object at the collection level

Nested Includes with Dot Notation

To support traversing deep relationship graphs, the include parameter supports dot notation for nested relationships. This allows clients to fetch related resources of included resources in a single request.

Syntax: relationshipName.nestedRelationshipName

Examples:

  • ?include=taxReturns.assessments - Includes tax returns AND their assessments (parent is implicit)
  • ?include=taxReturns.assessments,taxReturns.allocations - Includes tax returns with both assessments and allocations
  • ?include=taxReturns,taxReturns.assessments.payments - Includes tax returns, assessments, and payments (all parents in path)
  • ?include=taxReturns,payments - Includes two separate first-level relationships

Nested Include Rules:

  1. Implicit Parent Inclusion: Nested includes automatically include all parent resources in the path
    • ?include=taxReturns.assessments includes BOTH taxReturns AND assessments
    • Rationale: Nested resources need parent context to be meaningful
    • To include only the parent: ?include=taxReturns
    • To include both explicitly: ?include=taxReturns,taxReturns.assessments (equivalent to just taxReturns.assessments)
  2. Depth Limit: Maximum nesting depth is 5 levels (configurable) to prevent infinite loops and excessive API calls
  3. Relationship Validation: Each level must be a valid relationship in the parent resource’s _links field
  4. Deduplication: Resources are deduplicated by type and ID across all include levels
  5. Flat Structure: All included resources remain in the flat _included object, grouped by type
  6. Reference Tracking: Each resource with included relationships has an _includes field listing IDs

Example Request:

GET /taxpayers/TP123456?include=taxReturns.assessments

Example Response:

{
  "id": "TP123456",
  "type": "taxpayer",
  "nino": "AB123456C",
  "name": {
    "firstName": "John",
    "lastName": "Smith"
  },
  "_links": {
    "self": {"href": "/taxpayers/TP123456"},
    "taxReturns": {"href": "/tax-returns?taxpayerId=TP123456", "type": "collection"}
  },
  "_includes": {
    "taxReturns": ["TR20230001", "TR20230002"]
  },
  "_included": {
    "taxReturns": [
      {
        "id": "TR20230001",
        "type": "tax-return",
        "taxpayerId": "TP123456",
        "taxYear": "2023-24",
        "_links": {
          "self": {"href": "/tax-returns/TR20230001"},
          "assessments": {"href": "/assessments?taxReturnId=TR20230001", "type": "collection"}
        },
        "_includes": {
          "assessments": ["AS20230001"]
        }
      },
      {
        "id": "TR20230002",
        "type": "tax-return",
        "taxpayerId": "TP123456",
        "taxYear": "2022-23",
        "_links": {
          "self": {"href": "/tax-returns/TR20230002"},
          "assessments": {"href": "/assessments?taxReturnId=TR20230002", "type": "collection"}
        },
        "_includes": {
          "assessments": ["AS20220001"]
        }
      }
    ],
    "assessments": [
      {
        "id": "AS20230001",
        "type": "assessment",
        "taxReturnId": "TR20230001",
        "assessmentDate": "2024-01-15T10:30:00Z",
        "taxDue": {"amount": 7500.00, "currency": "GBP"},
        "_links": {"self": {"href": "/assessments/AS20230001"}}
      },
      {
        "id": "AS20220001",
        "type": "assessment",
        "taxReturnId": "TR20230002",
        "assessmentDate": "2023-01-15T10:30:00Z",
        "taxDue": {"amount": 6500.00, "currency": "GBP"},
        "_links": {"self": {"href": "/assessments/AS20220001"}}
      }
    ]
  }
}

Gateway Implementation for Nested Includes:

The aggregation Lambda processes nested includes recursively:

  1. Parse Include Tree: Convert taxReturns.assessments,taxReturns.allocations into a tree structure
  2. Fetch Primary Resource: Get the main resource (e.g., taxpayer)
  3. Process First Level: Fetch taxReturns and add to _included
  4. Process Nested Levels: For each tax return, fetch its assessments and allocations
  5. Deduplicate: Merge all resources by type and ID
  6. Add References: Populate _includes fields at each level

Error Handling:

Invalid nested relationships return 400 Bad Request:

{
  "error": {
    "code": "INVALID_INCLUDE_RELATIONSHIP",
    "message": "Invalid relationship 'invalidRel' for resource type 'tax-return'",
    "status": 400,
    "details": {
      "relationship": "invalidRel",
      "parentResourceType": "tax-return",
      "availableRelationships": ["taxpayer", "assessments", "allocations"]
    }
  }
}

Exceeding depth limit returns 400 Bad Request:

{
  "error": {
    "code": "INCLUDE_DEPTH_EXCEEDED",
    "message": "Include depth of 6 exceeds maximum allowed depth of 5",
    "status": 400,
    "details": {
      "requestedDepth": 6,
      "maxDepth": 5
    }
  }
}

Performance Considerations:

  • Each nested level requires additional API calls
  • Gateway fetches sibling relationships in parallel where possible
  • Resources are cached and deduplicated to minimize redundant fetches
  • Depth limit prevents excessive API calls and potential infinite loops
  • Monitoring tracks include depth and API call counts per request

API Endpoints

Taxpayer API Endpoints:

  • GET /taxpayers - List taxpayers
  • GET /taxpayers/{id} - Get taxpayer details
  • POST /taxpayers - Create taxpayer
  • PUT /taxpayers/{id} - Update taxpayer
  • DELETE /taxpayers/{id} - Delete taxpayer

Income Tax API Endpoints:

  • GET /tax-returns - List tax returns
  • GET /tax-returns/{id} - Get tax return details
  • POST /tax-returns - Submit tax return
  • GET /tax-returns/{id}/assessments - Get assessments for a return
  • GET /assessments/{id} - Get assessment details

Payment API Endpoints:

  • GET /payments - List payments
  • GET /payments/{id} - Get payment details
  • POST /payments - Record payment
  • GET /payments/{id}/allocations - Get payment allocations
  • POST /allocations - Create payment allocation

IMPORTANT: URL Format Decision

All URLs in _links fields MUST be path-only (relative URLs without scheme, host, or port). This applies to:

  • Backend API responses (mock servers and real implementations)
  • Gateway responses (after URL rewriting)
  • All OpenAPI specification examples

Rationale:

  • Portability: Path-only URLs work across different environments (local, staging, production) without modification
  • Gateway Flexibility: The gateway can add stage prefixes (e.g., /dev, /prod) without parsing full URLs
  • Simplicity: Clients don’t need to handle different hostnames or ports
  • Standard Practice: Follows REST HATEOAS principles where relative URLs are preferred

Backend API Responsibility:

  • Backend APIs (Taxpayer, Income Tax, Payment) return path-only URLs in all _links fields
  • Example: /taxpayers/TP123456 not http://localhost:8081/taxpayers/TP123456

Gateway Responsibility:

  • Gateway receives path-only URLs from backend APIs
  • Gateway adds stage prefix to all URLs in responses
  • Example: /taxpayers/TP123456 becomes /dev/taxpayers/TP123456
  • Gateway does NOT convert to full URLs - keeps them as paths

Relationships are represented in the _links object:

{
  "_links": {
    "self": {
      "href": "/taxpayers/TP123456"
    },
    "taxReturns": {
      "href": "/tax-returns?taxpayerId=TP123456",
      "type": "collection",
      "title": "Tax returns for this taxpayer"
    },
    "payments": {
      "href": "/payments?taxpayerId=TP123456",
      "type": "collection",
      "title": "Payments made by this taxpayer"
    }
  }
}

After Gateway Processing (with stage prefix):

{
  "_links": {
    "self": {
      "href": "/dev/taxpayers/TP123456"
    },
    "taxReturns": {
      "href": "/dev/tax-returns?taxpayerId=TP123456",
      "type": "collection",
      "title": "Tax returns for this taxpayer"
    },
    "payments": {
      "href": "/dev/payments?taxpayerId=TP123456",
      "type": "collection",
      "title": "Payments made by this taxpayer"
    }
  }
}

Data Models

Taxpayer API Models

Taxpayer:

Taxpayer:
  type: object
  required: [id, type, nino, name, address]
  properties:
    id:
      type: string
      pattern: '^TP[0-9]{6}$'
      example: TP123456
    type:
      type: string
      enum: [taxpayer]
    nino:
      type: string
      pattern: '^[A-Z]{2}[0-9]{6}[A-Z]$'
      description: National Insurance Number
      example: AB123456C
    name:
      type: object
      required: [firstName, lastName]
      properties:
        title: {type: string, enum: [Mr, Mrs, Miss, Ms, Dr]}
        firstName: {type: string}
        middleNames: {type: string}
        lastName: {type: string}
    address:
      $ref: 'shared-components.yaml#/components/schemas/Address'
    dateOfBirth:
      type: string
      format: date
    _links:
      $ref: 'shared-components.yaml#/components/schemas/Links'

Income Tax API Models

TaxReturn:

TaxReturn:
  type: object
  required: [id, type, taxpayerId, taxYear, status]
  properties:
    id:
      type: string
      pattern: '^TR[0-9]{8}$'
      example: TR20230001
    type:
      type: string
      enum: [tax-return]
    taxpayerId:
      type: string
      pattern: '^TP[0-9]{6}$'
    taxYear:
      type: string
      pattern: '^[0-9]{4}-[0-9]{2}$'
      example: 2023-24
    period:
      $ref: 'shared-components.yaml#/components/schemas/DateRange'
    status:
      type: string
      enum: [draft, submitted, assessed, closed]
    totalIncome:
      $ref: 'shared-components.yaml#/components/schemas/Money'
    taxDue:
      $ref: 'shared-components.yaml#/components/schemas/Money'
    _links:
      $ref: 'shared-components.yaml#/components/schemas/Links'

Assessment:

Assessment:
  type: object
  required: [id, type, taxReturnId, assessmentDate, taxDue]
  properties:
    id:
      type: string
      pattern: '^AS[0-9]{8}$'
      example: AS20230001
    type:
      type: string
      enum: [assessment]
    taxReturnId:
      type: string
      pattern: '^TR[0-9]{8}$'
    assessmentDate:
      type: string
      format: date-time
    taxDue:
      $ref: 'shared-components.yaml#/components/schemas/Money'
    dueDate:
      type: string
      format: date
    _links:
      $ref: 'shared-components.yaml#/components/schemas/Links'

Payment API Models

Payment:

Payment:
  type: object
  required: [id, type, taxpayerId, amount, paymentDate, status]
  properties:
    id:
      type: string
      pattern: '^PM[0-9]{8}$'
      example: PM20230001
    type:
      type: string
      enum: [payment]
    taxpayerId:
      type: string
      pattern: '^TP[0-9]{6}$'
    amount:
      $ref: 'shared-components.yaml#/components/schemas/Money'
    paymentDate:
      type: string
      format: date
    paymentMethod:
      type: string
      enum: [bank-transfer, debit-card, cheque]
    reference:
      type: string
    status:
      type: string
      enum: [pending, cleared, failed, refunded]
    _links:
      $ref: 'shared-components.yaml#/components/schemas/Links'

PaymentAllocation:

PaymentAllocation:
  type: object
  required: [id, type, paymentId, taxReturnId, amount]
  properties:
    id:
      type: string
      pattern: '^PA[0-9]{8}$'
      example: PA20230001
    type:
      type: string
      enum: [payment-allocation]
    paymentId:
      type: string
      pattern: '^PM[0-9]{8}$'
    taxReturnId:
      type: string
      pattern: '^TR[0-9]{8}$'
    amount:
      $ref: 'shared-components.yaml#/components/schemas/Money'
    allocationDate:
      type: string
      format: date-time
    _links:
      $ref: 'shared-components.yaml#/components/schemas/Links'

Example Cross-API Traversal

A complete example showing resource relationships:

1. Get Taxpayer (GET /taxpayer/v1/taxpayers/TP123456):

{
  "id": "TP123456",
  "type": "taxpayer",
  "nino": "AB123456C",
  "name": {
    "firstName": "John",
    "lastName": "Smith"
  },
  "address": {
    "line1": "10 Downing Street",
    "postcode": "SW1A 2AA",
    "country": "GB"
  },
  "_links": {
    "self": {
      "href": "http://localhost:8080/taxpayer/v1/taxpayers/TP123456"
    },
    "taxReturns": {
      "href": "http://localhost:8080/income-tax/v1/tax-returns?taxpayerId=TP123456",
      "type": "collection",
      "title": "Tax returns for this taxpayer"
    },
    "payments": {
      "href": "http://localhost:8080/payment/v1/payments?taxpayerId=TP123456",
      "type": "collection",
      "title": "Payments made by this taxpayer"
    }
  }
}

2. Get Taxpayer with Included Tax Returns (GET /taxpayer/v1/taxpayers/TP123456?include=taxReturns):

{
  "id": "TP123456",
  "type": "taxpayer",
  "nino": "AB123456C",
  "name": {
    "firstName": "John",
    "lastName": "Smith"
  },
  "address": {
    "line1": "10 Downing Street",
    "postcode": "SW1A 2AA",
    "country": "GB"
  },
  "_links": {
    "self": {
      "href": "http://localhost:8080/taxpayer/v1/taxpayers/TP123456"
    },
    "taxReturns": {
      "href": "http://localhost:8080/income-tax/v1/tax-returns?taxpayerId=TP123456",
      "type": "collection",
      "title": "Tax returns for this taxpayer"
    },
    "payments": {
      "href": "http://localhost:8080/payment/v1/payments?taxpayerId=TP123456",
      "type": "collection",
      "title": "Payments made by this taxpayer"
    }
  },
  "_includes": {
    "taxReturns": ["TR20230001"]
  },
  "_included": {
    "taxReturns": [
      {
        "id": "TR20230001",
        "type": "tax-return",
        "taxpayerId": "TP123456",
        "taxYear": "2023-24",
        "status": "assessed",
        "totalIncome": {"amount": 50000.00, "currency": "GBP"},
        "taxDue": {"amount": 7500.00, "currency": "GBP"},
        "_links": {
          "self": {
            "href": "http://localhost:8080/income-tax/v1/tax-returns/TR20230001"
          },
          "taxpayer": {
            "href": "http://localhost:8080/taxpayer/v1/taxpayers/TP123456",
            "type": "taxpayer"
          },
          "assessments": {
            "href": "http://localhost:8080/income-tax/v1/tax-returns/TR20230001/assessments",
            "type": "collection"
          }
        }
      }
    ]
  }
}

3. Follow Tax Returns Link (GET /income-tax/v1/tax-returns?taxpayerId=TP123456):

{
  "items": [
    {
      "id": "TR20230001",
      "type": "tax-return",
      "taxpayerId": "TP123456",
      "taxYear": "2023-24",
      "status": "assessed",
      "totalIncome": {"amount": 50000.00, "currency": "GBP"},
      "taxDue": {"amount": 7500.00, "currency": "GBP"},
      "_links": {
        "self": {
          "href": "http://localhost:8080/income-tax/v1/tax-returns/TR20230001"
        },
        "taxpayer": {
          "href": "http://localhost:8080/taxpayer/v1/taxpayers/TP123456",
          "type": "taxpayer",
          "title": "Taxpayer who filed this return"
        },
        "assessments": {
          "href": "http://localhost:8080/income-tax/v1/tax-returns/TR20230001/assessments",
          "type": "collection",
          "title": "Assessments for this return"
        },
        "allocations": {
          "href": "http://localhost:8080/payment/v1/allocations?taxReturnId=TR20230001",
          "type": "collection",
          "title": "Payment allocations for this return"
        }
      }
    }
  ],
  "_links": {
    "self": {
      "href": "http://localhost:8080/income-tax/v1/tax-returns?taxpayerId=TP123456"
    }
  }
}

Correctness Properties

A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.

Property Reflection

After analyzing all acceptance criteria, several properties can be consolidated:

  • Properties about OAS file structure (2.1, 2.2, 2.3, 2.4) can be combined into comprehensive OAS validation properties
  • Properties about resource structure (4.1, 4.2, 4.3) can be combined into a single resource format property
  • Properties about relationship links (5.1, 5.2, 5.5) can be combined into a comprehensive link format property
  • Properties about validation (8.1, 8.2, 8.5) can be combined into comprehensive validation properties

Property 1: Domain API Uniqueness

For any two Domain APIs in the system, their base URL paths must be distinct and each resource type must belong to exactly one API.

Validates: Requirements 1.2, 1.3

Property 2: Independent API Versioning

For any Domain API in the system, it must have its own version identifier that can be changed independently of other APIs.

Validates: Requirements 1.4

Property 3: API Resource Isolation

For any request to a Domain API, the response must only contain resources whose types are defined in that API’s OpenAPI specification.

Validates: Requirements 1.5

Property 4: OAS File Completeness and Validity

For any Domain API, its OpenAPI specification file must be valid according to OpenAPI 3.0+ specification, contain at least one path definition, at least one schema definition, and include examples.

Validates: Requirements 2.1, 2.2, 2.3, 2.4

Property 5: Shared Component References

For any $ref reference in a Domain API’s OpenAPI specification that points to the shared components file, the reference must resolve to a valid schema definition.

Validates: Requirements 3.3, 3.5, 8.2

Property 6: Resource Structure Format

For any resource returned by any Domain API, the response must not have a root-level “data” wrapper, must include resource attributes at the top level, and must include a “_links” field.

Validates: Requirements 4.1, 4.2, 4.3

For any resource that has relationships defined in its schema, the response must include URLs to related resources in the “_links” field.

Validates: Requirements 4.4

Property 8: HTTP Method Support

For any resource endpoint in any Domain API, the OpenAPI specification must define at least GET and POST methods, and may define PUT, PATCH, and DELETE.

Validates: Requirements 4.5

For any relationship link in a resource response, the URL must be a path-only URL (starting with /), must not include scheme, host, or port, and must include metadata fields (type, title) for cross-API relationships.

Validates: Requirements 5.1, 5.2, 5.5

For any relationship link in a resource response, following that link must return a valid resource from the target API.

Validates: Requirements 5.3

Property 11: Bidirectional Relationships

For any resource A that has a relationship link to resource B, resource B must have a relationship link back to resource A.

Validates: Requirements 5.4

Property 12: Documentation Completeness

For any endpoint, parameter, or schema in any OpenAPI specification, it must include a description field.

Validates: Requirements 7.2

Property 13: OpenAPI Specification Validation

For any OpenAPI specification file in the system, it must pass validation against the OpenAPI 3.0+ specification schema.

Validates: Requirements 8.1

Property 14: Relationship URL Validity

For any relationship URL in any resource response, the URL must be syntactically valid and must resolve to an actual resource (return 200 OK or 404 Not Found, not 500 or connection errors).

Validates: Requirements 8.5

Property 15: Include Parameter Embedding

For any resource request with an include query parameter, if the included relationship exists in the resource’s _links, the response must contain an _included field with the related resources, and the _links field must still be present.

Validates: Requirements 4.4, 5.3 (implicit - reduces need for multiple requests while maintaining link structure)

Error Handling

OpenAPI Specification Errors

Invalid OAS File:

  • Detection: OAS validator reports schema violations
  • Response: Build/deployment fails with detailed validation errors
  • Recovery: Fix OAS file and re-validate

Broken $ref Reference:

  • Detection: OAS validator cannot resolve reference
  • Response: Build/deployment fails with reference path details
  • Recovery: Fix reference path or add missing component

Version Conflict:

  • Detection: Shared component change breaks referencing API
  • Response: Validation fails with compatibility report
  • Recovery: Update referencing API or revert shared component change

Runtime Errors

Resource Not Found (404):

  • Scenario: Client follows relationship link to non-existent resource
  • Response: Standard 404 with error details
  • Example:
{
  "error": {
    "code": "RESOURCE_NOT_FOUND",
    "message": "Tax return TR20230001 not found",
    "status": 404
  }
}

Invalid Request (400):

  • Scenario: Client sends malformed data or invalid parameters
  • Response: 400 with validation errors
  • Example:
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request data",
    "status": 400,
    "details": [
      {"field": "nino", "message": "Must match pattern ^[A-Z]{2}[0-9]{6}[A-Z]$"}
    ]
  }
}

Cross-API Reference Error (502):

  • Scenario: Relationship link points to unavailable API
  • Response: 502 Bad Gateway
  • Example:
{
  "error": {
    "code": "UPSTREAM_API_ERROR",
    "message": "Unable to reach Income Tax API",
    "status": 502,
    "upstreamService": "income-tax-api"
  }
}

Validation Errors

Schema Validation Failure:

  • Detection: Request/response doesn’t match OAS schema
  • Response: 400 for requests, 500 for responses
  • Logging: Log schema validation details for debugging

Link Validation Failure:

  • Detection: Relationship URL is malformed or unresolvable
  • Response: Log warning, optionally omit invalid link
  • Recovery: Fix link generation logic

Testing Strategy

Dual Testing Approach

The testing strategy employs both unit tests and property-based tests as complementary approaches:

  • Unit tests: Verify specific examples, edge cases, and error conditions
  • Property tests: Verify universal properties across all inputs

Together, these provide comprehensive coverage where unit tests catch concrete bugs and property tests verify general correctness.

Unit Testing

Unit tests will focus on:

OpenAPI Specification Validation:

  • Test that each OAS file is valid OpenAPI 3.0+
  • Test that shared component references resolve correctly
  • Test specific examples of valid and invalid specifications

Resource Structure:

  • Test that example resources match the expected format
  • Test that _links field is present and correctly structured
  • Test specific examples of cross-API relationships

Error Handling:

  • Test 404 responses for non-existent resources
  • Test 400 responses for invalid requests
  • Test 502 responses for unavailable upstream APIs

Integration Points:

  • Test that mock servers can be generated from OAS files
  • Test that documentation can be generated from OAS files
  • Test that relationship links can be followed across APIs

Property-Based Testing

Property-based tests will be implemented using a PBT library appropriate for the implementation language (e.g., Hypothesis for Python, fast-check for TypeScript, QuickCheck for Haskell).

Each property test will:

  • Run a minimum of 100 iterations
  • Include a comment tag: Feature: domain-api-poc, Property {number}: {property text}
  • Generate random valid inputs to test universal properties

Property Test Examples:

Property 1: Domain API Uniqueness

# Feature: domain-api-poc, Property 1: Domain API Uniqueness
@given(apis=list_of_domain_apis())
def test_domain_api_uniqueness(apis):
    base_paths = [api.base_path for api in apis]
    assert len(base_paths) == len(set(base_paths))  # All unique
    
    all_resources = []
    for api in apis:
        all_resources.extend(api.resource_types)
    
    # Each resource type appears in exactly one API
    assert len(all_resources) == len(set(all_resources))

Property 6: Resource Structure Format

# Feature: domain-api-poc, Property 6: Resource Structure Format
@given(api=domain_api(), resource_id=resource_identifier())
def test_resource_structure_format(api, resource_id):
    response = api.get_resource(resource_id)
    
    # No root-level "data" wrapper
    assert "data" not in response or not isinstance(response.get("data"), dict)
    
    # Has _links field
    assert "_links" in response
    assert isinstance(response["_links"], dict)
    
    # Has resource attributes at top level
    assert "id" in response
    assert "type" in response

Property 10: Link Resolution

# Feature: domain-api-poc, Property 10: Link Resolution
@given(api=domain_api(), resource_id=resource_identifier())
def test_link_resolution(api, resource_id):
    response = api.get_resource(resource_id)
    links = response.get("_links", {})
    
    for link_name, link_obj in links.items():
        if link_name == "self":
            continue
        
        href = link_obj.get("href")
        if href:
            # Following the link should return a valid response
            linked_response = http_get(href)
            assert linked_response.status_code in [200, 404]
            
            if linked_response.status_code == 200:
                # Response should be valid JSON with expected structure
                data = linked_response.json()
                assert "id" in data or "items" in data  # Single resource or collection

Property 11: Bidirectional Relationships

# Feature: domain-api-poc, Property 11: Bidirectional Relationships
@given(api_a=domain_api(), api_b=domain_api(), resource_a_id=resource_identifier())
def test_bidirectional_relationships(api_a, api_b, resource_a_id):
    assume(api_a != api_b)  # Different APIs
    
    resource_a = api_a.get_resource(resource_a_id)
    links_a = resource_a.get("_links", {})
    
    # Find links to api_b
    for link_name, link_obj in links_a.items():
        href = link_obj.get("href", "")
        if api_b.base_path in href:
            # Follow link to resource B
            resource_b = http_get(href).json()
            links_b = resource_b.get("_links", {})
            
            # Resource B should link back to resource A
            back_links = [l["href"] for l in links_b.values() if isinstance(l, dict)]
            assert any(resource_a["id"] in link for link in back_links)

Test Configuration

  • Minimum iterations: 100 per property test
  • Test data generation: Use realistic UK tax domain data (valid NINOs, tax years, amounts)
  • Edge cases: Include boundary values (empty strings, zero amounts, maximum values)
  • Error injection: Test with invalid data to verify error handling

Mock Server Testing

Mock servers will be generated from OpenAPI specifications using tools like Prism or OpenAPI Generator:

# Generate mock server from OAS
prism mock taxpayer-api.yaml --port 8081
prism mock income-tax-api.yaml --port 8082
prism mock payment-api.yaml --port 8083

Mock servers enable:

  • Testing cross-API traversal without full implementation
  • Validating request/response formats against OAS
  • Demonstrating API behavior to stakeholders

Documentation Testing

Documentation generation will be tested using tools like Redoc or Swagger UI:

# Generate documentation
redoc-cli bundle taxpayer-api.yaml -o taxpayer-api-docs.html
redoc-cli bundle income-tax-api.yaml -o income-tax-api-docs.html
redoc-cli bundle payment-api.yaml -o payment-api-docs.html

Tests will verify:

  • Documentation can be generated without errors
  • All endpoints are documented
  • Examples are included and render correctly
  • Cross-API relationships are explained

Kubernetes Deployment Architecture

This section describes how the domain API system is deployed to Kubernetes environments, specifically targeting the k8s-lab environment using ArgoCD and Kustomize.

Deployment Overview

The k8s deployment maintains the same architecture as docker-compose but packages components for Kubernetes. Each API is deployed in its own namespace for isolation, while the gateway remains in the main domain-api namespace.

graph TB
    subgraph "Ingress Layer"
        TRAEFIK[Traefik Ingress]
    end
    
    subgraph "domain-api Namespace"
        subgraph "Gateway Pod"
            LOCALSTACK[LocalStack Container<br/>with Lambda pre-loaded]
        end
        
        subgraph "Docs Pod"
            DOCS_POD[Documentation Server<br/>Static File Server]
        end
        
        GW_SVC[Gateway Service]
        DOCS_SVC[Docs Service]
    end
    
    subgraph "domain-api-taxpayer Namespace"
        TP_POD[Taxpayer API<br/>Prism Mock Server]
        TP_SVC[Taxpayer Service]
    end
    
    subgraph "domain-api-income-tax Namespace"
        IT_POD[Income Tax API<br/>Prism Mock Server]
        IT_SVC[Income Tax Service]
    end
    
    subgraph "domain-api-payment Namespace"
        PM_POD[Payment API<br/>Prism Mock Server]
        PM_SVC[Payment Service]
    end
    
    TRAEFIK -->|domain-api.lab.local.ctoaas.co| GW_SVC
    TRAEFIK -->|domain-api-docs.lab.local.ctoaas.co| DOCS_SVC
    
    GW_SVC --> LOCALSTACK
    DOCS_SVC --> DOCS_POD
    
    LOCALSTACK --> TP_SVC
    LOCALSTACK --> IT_SVC
    LOCALSTACK --> PM_SVC
    
    TP_SVC --> TP_POD
    IT_SVC --> IT_POD
    PM_SVC --> PM_POD

Custom LocalStack Image

Instead of deploying Lambda functions as zips at runtime, we build a custom LocalStack image with the Lambda function pre-loaded. We use LAMBDA_EXECUTOR=local to run Lambda code directly in the LocalStack process, eliminating the need for Docker socket access.

Dockerfile (gateway/Dockerfile):

FROM localstack/localstack:latest
 
# Install build dependencies
RUN apt-get update && apt-get install -y \
    nodejs \
    npm \
    zip \
    && rm -rf /var/lib/apt/lists/*
 
# Copy Lambda function source
COPY lambda /opt/lambda
WORKDIR /opt/lambda
 
# Build Lambda function
RUN npm install && \
    npm run build && \
    zip -r /opt/aggregation-lambda.zip dist/ node_modules/
 
# Copy initialization script
COPY init-scripts/deploy-lambda.sh /etc/localstack/init/ready.d/
RUN chmod +x /etc/localstack/init/ready.d/deploy-lambda.sh
 
# Set environment variables
ENV LAMBDA_EXECUTOR=local
ENV SERVICES=apigateway,lambda,iam
ENV DEBUG=1
 
WORKDIR /opt/code/localstack

Lambda Deployment Script (gateway/init-scripts/deploy-lambda.sh):

#!/bin/bash
# This script runs when LocalStack is ready and deploys the pre-built Lambda
 
set -e
 
echo "Deploying aggregation Lambda..."
 
# Create Lambda function from pre-built zip
awslocal lambda create-function \
  --function-name aggregation-lambda \
  --runtime nodejs18.x \
  --role arn:aws:iam::000000000000:role/lambda-role \
  --handler index.handler \
  --zip-file fileb:///opt/aggregation-lambda.zip
 
# Create API Gateway
CUSTOM_API_ID="domain-api"
 
awslocal apigateway create-rest-api \
  --name domain-api-gateway \
  --rest-api-id $CUSTOM_API_ID
 
# Get root resource
ROOT_ID=$(awslocal apigateway get-resources \
  --rest-api-id $CUSTOM_API_ID \
  --query 'items[0].id' \
  --output text)
 
# Create proxy resource
RESOURCE_ID=$(awslocal apigateway create-resource \
  --rest-api-id $CUSTOM_API_ID \
  --parent-id $ROOT_ID \
  --path-part '{proxy+}' \
  --query 'id' \
  --output text)
 
# Create ANY method
awslocal apigateway put-method \
  --rest-api-id $CUSTOM_API_ID \
  --resource-id $RESOURCE_ID \
  --http-method ANY \
  --authorization-type NONE
 
# Create Lambda integration
awslocal apigateway put-integration \
  --rest-api-id $CUSTOM_API_ID \
  --resource-id $RESOURCE_ID \
  --http-method ANY \
  --type AWS_PROXY \
  --integration-http-method POST \
  --uri arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:aggregation-lambda/invocations
 
# Deploy API
awslocal apigateway create-deployment \
  --rest-api-id $CUSTOM_API_ID \
  --stage-name dev
 
echo "✓ Gateway and Lambda deployed successfully"
echo "Gateway available at: http://localhost:4566/restapis/$CUSTOM_API_ID/dev/_user_request_"

Kubernetes Manifests Structure

Directory Structure:

domain-apis/
├── kustomize/
│   ├── base/
│   │   ├── kustomization.yaml
│   │   ├── gateway/
│   │   │   ├── namespace.yaml
│   │   │   ├── deployment.yaml
│   │   │   ├── service.yaml
│   │   │   └── configmap.yaml
│   │   ├── taxpayer-api/
│   │   │   ├── namespace.yaml
│   │   │   ├── deployment.yaml
│   │   │   └── service.yaml
│   │   ├── income-tax-api/
│   │   │   ├── namespace.yaml
│   │   │   ├── deployment.yaml
│   │   │   └── service.yaml
│   │   ├── payment-api/
│   │   │   ├── namespace.yaml
│   │   │   ├── deployment.yaml
│   │   │   └── service.yaml
│   │   ├── docs/
│   │   │   ├── deployment.yaml
│   │   │   └── service.yaml
│   │   └── ingress/
│   │       ├── gateway-ingress.yaml
│   │       └── docs-ingress.yaml
│   └── overlays/
│       └── lab/
│           ├── kustomization.yaml
│           └── ingress-patch.yaml
├── gateway/
│   ├── Dockerfile
│   ├── lambda/
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── init-scripts/
│       └── deploy-lambda.sh
└── docs/
    └── Dockerfile

# ArgoCD Application manifest is added to k8s-lab repo:
k8s-lab/
└── other-seeds/
    └── domain-api.yaml

Namespace Configuration

kustomize/base/gateway/namespace.yaml:

apiVersion: v1
kind: Namespace
metadata:
  name: domain-api
  labels:
    name: domain-api
    secrets/gh-docker-registry: "true"  # For pulling custom images from GHCR

kustomize/base/taxpayer-api/namespace.yaml:

apiVersion: v1
kind: Namespace
metadata:
  name: domain-api-taxpayer
  labels:
    name: domain-api-taxpayer

kustomize/base/income-tax-api/namespace.yaml:

apiVersion: v1
kind: Namespace
metadata:
  name: domain-api-income-tax
  labels:
    name: domain-api-income-tax

kustomize/base/payment-api/namespace.yaml:

apiVersion: v1
kind: Namespace
metadata:
  name: domain-api-payment
  labels:
    name: domain-api-payment

Gateway Deployment

kustomize/base/gateway/deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: gateway
  namespace: domain-api
spec:
  replicas: 1
  selector:
    matchLabels:
      app: gateway
  template:
    metadata:
      labels:
        app: gateway
    spec:
      imagePullSecrets:
        - name: gh-docker-registry-creds
      containers:
        - name: localstack
          image: ghcr.io/your-org/domain-api-gateway:latest
          ports:
            - containerPort: 4566
              name: http
          env:
            - name: SERVICES
              value: "apigateway,lambda,iam"
            - name: DEBUG
              value: "1"
            - name: LAMBDA_EXECUTOR
              value: "local"
            - name: TAXPAYER_API_URL
              value: "http://taxpayer-api.domain-api-taxpayer.svc.cluster.local"
            - name: INCOME_TAX_API_URL
              value: "http://income-tax-api.domain-api-income-tax.svc.cluster.local"
            - name: PAYMENT_API_URL
              value: "http://payment-api.domain-api-payment.svc.cluster.local"
          livenessProbe:
            httpGet:
              path: /_localstack/health
              port: 4566
            initialDelaySeconds: 30
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /_localstack/health
              port: 4566
            initialDelaySeconds: 20
            periodSeconds: 5

kustomize/base/gateway/service.yaml:

apiVersion: v1
kind: Service
metadata:
  name: gateway
  namespace: domain-api
spec:
  selector:
    app: gateway
  ports:
    - name: http
      port: 80
      targetPort: 4566
  type: ClusterIP

API Service Deployments

kustomize/base/taxpayer-api/deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: taxpayer-api
  namespace: domain-api-taxpayer
spec:
  replicas: 1
  selector:
    matchLabels:
      app: taxpayer-api
  template:
    metadata:
      labels:
        app: taxpayer-api
    spec:
      containers:
        - name: prism
          image: stoplight/prism:latest
          args:
            - mock
            - -h
            - "0.0.0.0"
            - -p
            - "80"
            - /specs/taxpayer-api.yaml
          ports:
            - containerPort: 80
              name: http
          volumeMounts:
            - name: specs
              mountPath: /specs
              readOnly: true
          livenessProbe:
            httpGet:
              path: /
              port: 80
            initialDelaySeconds: 10
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /
              port: 80
            initialDelaySeconds: 5
            periodSeconds: 5
      volumes:
        - name: specs
          configMap:
            name: taxpayer-api-spec

kustomize/base/taxpayer-api/service.yaml:

apiVersion: v1
kind: Service
metadata:
  name: taxpayer-api
  namespace: domain-api-taxpayer
spec:
  selector:
    app: taxpayer-api
  ports:
    - name: http
      port: 80
      targetPort: 80
  type: ClusterIP

Similar deployments and services for income-tax-api (in domain-api-income-tax namespace) and payment-api (in domain-api-payment namespace).

Documentation Service

Instead of using ConfigMaps (which have size limits), we build a custom image with the generated documentation and OpenAPI specs baked in.

docs/Dockerfile:

FROM halverneus/static-file-server:latest
 
# Copy generated documentation to /web (root)
COPY docs /web
 
# Copy OpenAPI specs to /web/specs
COPY specs /web/specs
 
# Set environment variables
ENV FOLDER=/web
ENV PORT=8080
ENV SHOW_LISTING=true
 
EXPOSE 8080

kustomize/base/docs/deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: docs
  namespace: domain-api
spec:
  replicas: 1
  selector:
    matchLabels:
      app: docs
  template:
    metadata:
      labels:
        app: docs
    spec:
      imagePullSecrets:
        - name: gh-docker-registry-creds
      containers:
        - name: static-server
          image: ghcr.io/your-org/domain-api-docs:latest
          ports:
            - containerPort: 8080
              name: http
          livenessProbe:
            httpGet:
              path: /
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /
              port: 8080
            initialDelaySeconds: 3
            periodSeconds: 5

kustomize/base/docs/service.yaml:

apiVersion: v1
kind: Service
metadata:
  name: docs
  namespace: domain-api
spec:
  selector:
    app: docs
  ports:
    - name: http
      port: 80
      targetPort: 8080
  type: ClusterIP

Standard Ingress Configuration

The domain-apis deployment uses the standardized traefik-ingress Helm chart pattern established in k8s-lab. This provides consistent ingress configuration across all platform services and eliminates the need for deprecated IngressRoute CRDs.

kustomize/base/ingress/kustomization.yaml:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
 
helmGlobals:
  chartHome: ../../libs/workspace-shared/helm
 
helmCharts:
  # Domain API ingress configuration
  - name: traefik-ingress
    releaseName: domain-api-services
    valuesInline:
      # Global configuration for lab environment
      domains:
        localDomainSuffix: lab.local.ctoaas.co
      tls:
        enabled: true
        issuer: letsencrypt-prod
      
      # Array of ingress configurations
      ingresses:
        # Gateway service - internal access
        - service:
            name: gateway
            namespace: domain-api
            port:
              number: 80
          ingress:
            name: domain-api-gateway
            accessPattern: internal
          domains:
            name: domain-api
          tls:
            secretName: domain-api-gateway-tls
 
        # Documentation service - internal access
        - service:
            name: docs
            namespace: domain-api
            port:
              number: 80
          ingress:
            name: domain-api-docs
            accessPattern: internal
          domains:
            name: domain-api-docs
          tls:
            secretName: domain-api-docs-tls
 
generatorOptions:
  disableNameSuffixHash: true

Key Benefits:

  • Consistency: Follows the same pattern as other k8s-lab services (Kargo, Headlamp, n8n)
  • Maintainability: Uses shared Helm chart from workspace-shared
  • Automatic TLS: cert-manager integration for automatic certificate provisioning
  • Internal Access: Both services use accessPattern: internal for lab-only access
  • No CRD Issues: Uses standard Kubernetes Ingress resources instead of deprecated IngressRoute CRDs

Generated Ingress Resources:

The Helm chart generates standard Kubernetes Ingress resources:

# Gateway ingress (generated by Helm)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: domain-api-gateway
  namespace: domain-api
  annotations:
    traefik.ingress.kubernetes.io/router.entrypoints: websecure
    traefik.ingress.kubernetes.io/router.tls: "true"
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: traefik
  tls:
  - hosts:
    - domain-api.lab.local.ctoaas.co
    secretName: domain-api-gateway-tls
  rules:
  - host: domain-api.lab.local.ctoaas.co
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: gateway
            port:
              number: 80
 
---
# Docs ingress (generated by Helm)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: domain-api-docs
  namespace: domain-api
  annotations:
    traefik.ingress.kubernetes.io/router.entrypoints: websecure
    traefik.ingress.kubernetes.io/router.tls: "true"
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: traefik
  tls:
  - hosts:
    - domain-api-docs.lab.local.ctoaas.co
    secretName: domain-api-docs-tls
  rules:
  - host: domain-api-docs.lab.local.ctoaas.co
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: docs
            port:
              number: 80

Kustomization

kustomize/base/kustomization.yaml:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
 
resources:
  - gateway/namespace.yaml
  - gateway/deployment.yaml
  - gateway/service.yaml
  - taxpayer-api/namespace.yaml
  - taxpayer-api/deployment.yaml
  - taxpayer-api/service.yaml
  - income-tax-api/namespace.yaml
  - income-tax-api/deployment.yaml
  - income-tax-api/service.yaml
  - payment-api/namespace.yaml
  - payment-api/deployment.yaml
  - payment-api/service.yaml
  - docs/deployment.yaml
  - docs/service.yaml
  - ingress/  # Ingress component with Helm chart
 
configMapGenerator:
  - name: taxpayer-api-spec
    namespace: domain-api-taxpayer
    files:
      - taxpayer-api.yaml=../../specs/taxpayer/taxpayer-api.yaml
  - name: income-tax-api-spec
    namespace: domain-api-income-tax
    files:
      - income-tax-api.yaml=../../specs/income-tax/income-tax-api.yaml
  - name: payment-api-spec
    namespace: domain-api-payment
    files:
      - payment-api.yaml=../../specs/payment/payment-api.yaml
 
images:
  - name: ghcr.io/your-org/domain-api-gateway
    newTag: latest
  - name: ghcr.io/your-org/domain-api-docs
    newTag: latest

ArgoCD Application

The ArgoCD Application manifest is added to the k8s-lab repository’s other-seeds/ directory, not in the domain-apis repo itself. This follows the pattern where k8s-lab manages ArgoCD Applications for external repositories.

k8s-lab/other-seeds/domain-api.yaml:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: domain-api
  namespace: argocd
  labels:
    repo: domain-apis
    component: api
spec:
  project: default
  source:
    repoURL: https://github.com/your-org/domain-apis
    targetRevision: main
    path: kustomize/base
  destination:
    server: https://kubernetes.default.svc
    namespace: domain-api
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true

Environment-Agnostic Acceptance Testing

Acceptance tests are designed to work against any deployment by accepting a base URL:

tests/acceptance/playwright.config.ts:

import { defineConfig } from '@playwright/test';
 
export default defineConfig({
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:4566/restapis/domain-api/dev/_user_request_',
  },
  // ... other config
});

Running tests against different environments:

# Against docker-compose
BASE_URL=http://localhost:4566/restapis/domain-api/dev/_user_request_ npm run test:acceptance
 
# Against k8s (via port-forward)
kubectl port-forward -n domain-api svc/gateway 4566:4566
BASE_URL=http://localhost:4566/restapis/domain-api/dev/_user_request_ npm run test:acceptance
 
# Against k8s (via ingress)
BASE_URL=https://domain-api.lab.local.ctoaas.co npm run test:acceptance

Taskfile Integration

Taskfile.yaml (k8s tasks):

version: '3'
 
tasks:
  k8s:build:gateway:
    desc: Build custom LocalStack image with Lambda
    dir: gateway
    cmds:
      - docker build -t ghcr.io/your-org/domain-api-gateway:latest .
 
  k8s:build:docs:
    desc: Build custom docs image with generated documentation
    cmds:
      - npm run docs  # Generate documentation first
      - docker build -f docs/Dockerfile -t ghcr.io/your-org/domain-api-docs:latest .
 
  k8s:build:
    desc: Build all custom images
    deps: [k8s:build:gateway, k8s:build:docs]
 
  k8s:push:gateway:
    desc: Push gateway image to registry
    deps: [k8s:build:gateway]
    cmds:
      - docker push ghcr.io/your-org/domain-api-gateway:latest
 
  k8s:push:docs:
    desc: Push docs image to registry
    deps: [k8s:build:docs]
    cmds:
      - docker push ghcr.io/your-org/domain-api-docs:latest
 
  k8s:push:
    desc: Push all custom images to registry
    deps: [k8s:push:gateway, k8s:push:docs]
 
  k8s:apply:
    desc: Apply Kubernetes manifests
    cmds:
      - kubectl apply -k kustomize/base
 
  k8s:delete:
    desc: Delete Kubernetes resources
    cmds:
      - kubectl delete -k kustomize/base
 
  k8s:status:
    desc: Check domain-api deployment status
    cmds:
      - kubectl get all -n domain-api
      - kubectl get ingress -n domain-api
 
  k8s:logs:gateway:
    desc: View gateway logs
    cmds:
      - kubectl logs -n domain-api -l app=gateway -f
 
  k8s:logs:taxpayer:
    desc: View taxpayer API logs
    cmds:
      - kubectl logs -n domain-api -l app=taxpayer-api -f
 
  k8s:port-forward:gateway:
    desc: Port-forward to gateway service
    cmds:
      - kubectl port-forward -n domain-api svc/gateway 4566:80
 
  k8s:port-forward:docs:
    desc: Port-forward to docs service
    cmds:
      - kubectl port-forward -n domain-api svc/docs 8080:80
 
  k8s:test:acceptance:
    desc: Run acceptance tests against k8s deployment
    cmds:
      - kubectl port-forward -n domain-api svc/gateway 4566:80 &
      - sleep 3
      - BASE_URL=http://localhost:4566/restapis/domain-api/dev/_user_request_ npm run test:acceptance
      - pkill -f "kubectl port-forward"
 
  k8s:restart:gateway:
    desc: Restart gateway deployment
    cmds:
      - kubectl rollout restart -n domain-api deployment/gateway
 
  k8s:describe:gateway:
    desc: Describe gateway deployment
    cmds:
      - kubectl describe -n domain-api deployment/gateway
      - kubectl describe -n domain-api pod -l app=gateway

Deployment Workflow

Local Development (docker-compose):

  1. docker-compose up - Start all services
  2. task gateway:init - Initialize LocalStack
  3. npm run test:acceptance - Run tests

Kubernetes Deployment:

  1. task k8s:build - Build gateway and docs images
  2. task k8s:push - Push to GHCR
  3. task k8s:apply - Deploy to k8s
  4. task k8s:status - Verify deployment
  5. task k8s:test:acceptance - Run acceptance tests

ArgoCD Deployment:

  1. Push changes to git
  2. ArgoCD automatically syncs
  3. Monitor via ArgoCD UI or kubectl

Key Design Decisions

Why custom LocalStack image?

  • Eliminates runtime Lambda deployment complexity
  • Faster pod startup (Lambda already loaded)
  • Immutable infrastructure (Lambda version tied to image)
  • Easier rollback (just change image tag)

Why build custom docs image instead of ConfigMaps?

  • ConfigMaps have 1MB size limit (docs can easily exceed this)
  • Baking docs into image is more efficient (no volume mounts)
  • Immutable deployments (docs version tied to image tag)
  • Faster pod startup (no ConfigMap sync delays)
  • Better for CI/CD (build docs once, deploy anywhere)

Why use LAMBDA_EXECUTOR=local instead of docker?

  • Eliminates need for Docker socket access in pods
  • Simpler security model (no privileged containers)
  • Faster Lambda execution (no container startup overhead)
  • Sufficient for development/testing workloads
  • Reduces resource requirements

Why port 80 for all services?

  • Kubernetes convention for HTTP services
  • Simplifies service-to-service communication
  • Standard port for ingress controllers
  • Cleaner URLs (no port numbers needed)
  • Easier to remember and configure

Why separate namespaces for each API?

  • Better resource isolation and security boundaries
  • Independent RBAC policies per API
  • Clearer ownership and responsibility
  • Easier to replace mock servers with real implementations
  • Follows microservices best practices
  • Prevents accidental cross-API dependencies

Why keep LocalStack instead of native k8s services?

  • Maintains parity with docker-compose development environment
  • Preserves AWS API Gateway + Lambda architecture
  • No code changes to Lambda function
  • Same aggregation logic in all environments

Why separate API pods?

  • Independent scaling of each API
  • Clearer resource boundaries
  • Easier to replace mock servers with real implementations
  • Follows microservices patterns

Why standard Helm-based ingress?

  • Consistent with k8s-lab platform standards
  • Uses shared traefik-ingress Helm chart from workspace-shared
  • Automatic TLS via cert-manager integration
  • Avoids deprecated IngressRoute CRDs
  • Easier to maintain and update across all services
  • Standard Kubernetes Ingress resources instead of custom CRDs

Health Checks and Monitoring

All services include:

  • Liveness probes: Restart unhealthy containers
  • Readiness probes: Remove from service endpoints when not ready
  • Resource limits: Prevent resource exhaustion
  • Labels: Enable monitoring and log aggregation

Security Considerations

  • Image pull secrets: Use central secret store for GHCR access
  • Network policies: Restrict traffic between pods (future enhancement)
  • TLS termination: At Traefik ingress layer
  • No privileged containers: Except LocalStack (needs Docker socket)

Future Enhancements

  • Replace mock servers: Deploy real API implementations
  • Add persistence: For LocalStack state across restarts
  • Horizontal scaling: Scale API pods based on load
  • Service mesh: Add Istio/Linkerd for advanced traffic management
  • Observability: Add Prometheus metrics and distributed tracing