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:
-
Taxpayer API (High Maturity): Modern JSON-based API that natively provides hypermedia links
- Returns JSON responses with
_linksfield - Implements the target architecture natively
- No gateway transformation needed beyond aggregation
- Returns JSON responses with
-
Income Tax API (Medium Maturity): JSON-based API without native hypermedia support
- Returns JSON responses but without
_linksfield - Gateway injects links based on configuration
- Demonstrates link injection for APIs that can’t be modified
- Returns JSON responses but without
-
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:
- Taxpayer API: Manages taxpayer identity and registration information (High Maturity - JSON with links)
- Income Tax API: Handles income tax returns, assessments, and calculations (Medium Maturity - JSON without links)
- 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
_linksfield - Gateway Role: Minimal - only handles aggregation via
includeparameter - 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
_linksfield - Gateway Role: Injects
_linksbased 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
_linksbased 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
includequery parameter from requests - Makes parallel requests to backend APIs
- Merges responses into unified
_includedstructure - 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
_linksfor traversal - Gateway Access: Clients call through API Gateway, use
includefor 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:
- Explore the API structure interactively
- Test cross-API relationships by following links
- Validate that responses match the OAS specifications
- 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_matchAggregation Lambda Implementation
The Lambda function handles the aggregation logic:
Key Responsibilities:
- Parse incoming API Gateway event
- Extract
includeparameter from query string - Forward request to appropriate backend API
- If
includeis present, fetch related resources - Merge responses into unified structure
- 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:/specsLocalStack 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
Acceptheader, orAccept: application/vnd.domain+json(future:Accept: application/vnd.domain+xmlfor XML aggregation) - Behavior:
- Processes
includeparameter for cross-API aggregation - Adds
_includedsection when includes are requested - Returns
application/vnd.domain+json(or requested format in future extensions)
- Processes
- Use Case: Clients want the enhanced gateway features (includes)
- Media Type:
application/vnd.domain+jsonindicates 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/jsonheader - Behavior:
- Proxies request to backend API
- Ignores
includeparameter (not processed) - No
_includedsection added - Returns
application/json
- Use Case: Clients want standard REST JSON responses without aggregation overhead
- Media Type:
application/jsonindicates standard REST JSON response
3. Pass-Through Mode:
- Trigger:
Accept: application/vnd.rawheader - Behavior:
- Proxies request directly to backend API without modification
- Returns response exactly as received from backend (no transformation)
- Preserves backend’s
Content-Typeheader (typicallyapplication/json) - Ignores
includeparameter (not processed)
- Use Case: Clients want direct access to backend API behavior, testing, or when aggregation is not needed
- Media Type:
application/vnd.rawindicates 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 --followDeployment 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:4566Error 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 dataShared 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
_linkspattern 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
_linksfield contains hypermedia links to related resources_includedfield (when using gateway aggregation) contains embedded related resourcestypefield 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"}
}
}Query Parameter: Include Related Resources
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:
-
Direct API Access (no gateway):
- Client calls backend APIs directly
- Client follows
_linksto traverse relationships - Multiple HTTP requests required for related resources
- Example:
GET http://taxpayer-api:8081/taxpayer/v1/taxpayers/TP123456
-
Gateway Access (with aggregation):
- Client calls through AWS API Gateway
- Gateway Lambda handles
includeparameter - 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:
- Parses the
includeparameter - Fetches the primary resource from the backend API
- Extracts relationship URLs from
_links - Makes parallel requests to related backend APIs
- Merges responses into
_includedfield - 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
_linkscan 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
includeparameter - 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
includeparameter 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
_includedsection - Only present when the
includeparameter 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:
- From
_links: “All assessments are available at this URL” (for pagination, refresh, etc.) - From
_includes: “Assessment AS20220001 is embedded in this response’s_includedsection”
Why both are needed:
- Pagination: If there are 100 assessments but only 10 included,
_linksshows where to get more - Selective inclusion: Client might include only recent assessments,
_linksshows 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
_includesfield with IDs referencing resources in the collection-level_included - All included resources are in a single
_includedobject 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:
- Implicit Parent Inclusion: Nested includes automatically include all parent resources in the path
?include=taxReturns.assessmentsincludes BOTHtaxReturnsANDassessments- 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 justtaxReturns.assessments)
- Depth Limit: Maximum nesting depth is 5 levels (configurable) to prevent infinite loops and excessive API calls
- Relationship Validation: Each level must be a valid relationship in the parent resource’s
_linksfield - Deduplication: Resources are deduplicated by type and ID across all include levels
- Flat Structure: All included resources remain in the flat
_includedobject, grouped by type - Reference Tracking: Each resource with included relationships has an
_includesfield 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:
- Parse Include Tree: Convert
taxReturns.assessments,taxReturns.allocationsinto a tree structure - Fetch Primary Resource: Get the main resource (e.g., taxpayer)
- Process First Level: Fetch
taxReturnsand add to_included - Process Nested Levels: For each tax return, fetch its
assessmentsandallocations - Deduplicate: Merge all resources by type and ID
- Add References: Populate
_includesfields 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 taxpayersGET /taxpayers/{id}- Get taxpayer detailsPOST /taxpayers- Create taxpayerPUT /taxpayers/{id}- Update taxpayerDELETE /taxpayers/{id}- Delete taxpayer
Income Tax API Endpoints:
GET /tax-returns- List tax returnsGET /tax-returns/{id}- Get tax return detailsPOST /tax-returns- Submit tax returnGET /tax-returns/{id}/assessments- Get assessments for a returnGET /assessments/{id}- Get assessment details
Payment API Endpoints:
GET /payments- List paymentsGET /payments/{id}- Get payment detailsPOST /payments- Record paymentGET /payments/{id}/allocations- Get payment allocationsPOST /allocations- Create payment allocation
Relationship Link Format
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
_linksfields - Example:
/taxpayers/TP123456nothttp://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/TP123456becomes/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
Property 7: Relationship Link Presence
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
Property 9: Path-Only Link Format
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
Property 10: Link Resolution
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 responseProperty 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 collectionProperty 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 8083Mock 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.htmlTests 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/localstackLambda 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 GHCRkustomize/base/taxpayer-api/namespace.yaml:
apiVersion: v1
kind: Namespace
metadata:
name: domain-api-taxpayer
labels:
name: domain-api-taxpayerkustomize/base/income-tax-api/namespace.yaml:
apiVersion: v1
kind: Namespace
metadata:
name: domain-api-income-tax
labels:
name: domain-api-income-taxkustomize/base/payment-api/namespace.yaml:
apiVersion: v1
kind: Namespace
metadata:
name: domain-api-payment
labels:
name: domain-api-paymentGateway 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: 5kustomize/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: ClusterIPAPI 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-speckustomize/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: ClusterIPSimilar 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 8080kustomize/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: 5kustomize/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: ClusterIPStandard 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: trueKey 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: internalfor 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: 80Kustomization
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: latestArgoCD 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=trueEnvironment-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:acceptanceTaskfile 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=gatewayDeployment Workflow
Local Development (docker-compose):
docker-compose up- Start all servicestask gateway:init- Initialize LocalStacknpm run test:acceptance- Run tests
Kubernetes Deployment:
task k8s:build- Build gateway and docs imagestask k8s:push- Push to GHCRtask k8s:apply- Deploy to k8stask k8s:status- Verify deploymenttask k8s:test:acceptance- Run acceptance tests
ArgoCD Deployment:
- Push changes to git
- ArgoCD automatically syncs
- 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