Inter-Service Communication
This guide covers patterns for communication between microservices in the CROP monorepo.
Inter-Service Communication
This guide covers patterns for communication between microservices in the CROP monorepo.
Overview
Services communicate via HTTP using the ServiceClient from @crop/shared-hono. This provides:
- Type-safe requests
- Automatic request ID forwarding
- Timeout handling
- Standardized error responses
- Service discovery via environment variables
Quick Start
Using Pre-configured Clients
import { serviceClients } from '@crop/shared-hono';
// In a route handler
app.get('/api/enriched-parts', async (c) => {
// Call catalog service
const response = await serviceClients.catalog.get<Part[]>('/api/parts', {
query: { category: 'filters' },
context: c, // Forwards request ID
});
if (!response.success) {
return c.json(
{
success: false,
error: response.error,
},
500
);
}
// Use the data
const parts = response.data;
return c.json({ success: true, data: parts });
});Creating Custom Clients
import { ServiceClient } from '@crop/shared-hono';
const customClient = new ServiceClient({
baseUrl: process.env.CUSTOM_SERVICE_URL || 'http://localhost:3010',
timeout: 10000, // 10 seconds
headers: {
'X-API-Key': process.env.CUSTOM_API_KEY || '',
},
serviceName: 'custom-service',
});
const response = await customClient.get('/api/data');Service Discovery
Services are discovered via environment variables with fallback to local development URLs.
Environment Variables
Set these in your .env file or deployment configuration:
# Service URLs
SEARCH_SERVICE_URL=http://search:3000
CATALOG_SERVICE_URL=http://catalog:3000
MEDIA_SERVICE_URL=http://media:3000
USER_SERVICE_URL=http://user:3000
# Optional: Global service timeout (default: 5000ms)
SERVICE_TIMEOUT=10000Default URLs
When environment variables are not set, services default to:
search: http://localhost:3001catalog: http://localhost:3003media: http://localhost:3004user: http://localhost:3005
Docker Compose
In Docker Compose, services use container names:
environment:
CATALOG_SERVICE_URL: http://catalog:3000
MEDIA_SERVICE_URL: http://media:3000Request Methods
GET
const response = await serviceClients.catalog.get<Part[]>('/api/parts', {
query: {
category: 'filters',
limit: 10,
},
headers: {
'X-Custom-Header': 'value',
},
context: c, // Hono context for request ID forwarding
});POST
const response = await serviceClients.catalog.post<Part, CreatePartInput>('/api/parts', {
body: {
partNumber: 'ABC123',
manufacturer: 'ACME',
description: 'Widget',
},
context: c,
});PUT
const response = await serviceClients.catalog.put<Part, UpdatePartInput>(
'/api/parts/123',
{
body: {
description: 'Updated description',
},
context: c,
}
);DELETE
const response = await serviceClients.catalog.delete<void>('/api/parts/123', {
context: c,
});Error Handling
All service responses follow the standard ApiResponse format:
interface ServiceResponse<T> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: unknown;
};
meta?: {
timestamp: string;
requestId: string;
};
}Handling Errors
const response = await serviceClients.catalog.get<Part[]>('/api/parts');
if (!response.success) {
// Log the error
console.error('Catalog service error:', response.error);
// Return error to client
return c.json(
{
success: false,
error: {
code: 'UPSTREAM_ERROR',
message: `Catalog service failed: ${response.error?.message}`,
details: response.error,
},
},
500
);
}
// Success - use the data
const parts = response.data;Common Error Codes
TIMEOUT: Request exceeded timeout limitNETWORK_ERROR: Network connectivity issueINVALID_RESPONSE: Service returned non-JSON responseHTTP_ERROR: HTTP status code error (4xx/5xx)
Request ID Forwarding
Request IDs are automatically forwarded when you pass the Hono context:
app.get('/api/enriched-parts', async (c) => {
// Request ID from incoming request is forwarded to catalog service
const response = await serviceClients.catalog.get('/api/parts', {
context: c, // ← This forwards the request ID
});
// The catalog service will see the same X-Request-ID header
});This enables distributed tracing across services.
Health Checks
Check if a service is available:
try {
const health = await serviceClients.catalog.health();
console.log('Catalog service status:', health.status);
} catch (error) {
console.error('Catalog service is unavailable');
}Timeouts
Default timeout is 5000ms (5 seconds). Configure via:
-
Global timeout (all services):
SERVICE_TIMEOUT=10000 -
Per-client timeout:
const client = new ServiceClient({ baseUrl: 'http://slow-service:3000', timeout: 30000, // 30 seconds });
Best Practices
1. Always Handle Errors
// ✅ Good
const response = await serviceClients.catalog.get('/api/parts');
if (!response.success) {
// Handle error
return c.json({ success: false, error: response.error }, 500);
}
// ❌ Bad
const response = await serviceClients.catalog.get('/api/parts');
const parts = response.data; // Might be undefined!2. Forward Request Context
// ✅ Good - Request ID is forwarded
await serviceClients.catalog.get('/api/parts', { context: c });
// ⚠️ OK - No request ID forwarding
await serviceClients.catalog.get('/api/parts');3. Use Type Parameters
// ✅ Good - Type-safe
const response = await serviceClients.catalog.get<Part[]>('/api/parts');
if (response.success) {
const parts: Part[] = response.data; // Type-safe
}
// ❌ Bad - No type safety
const response = await serviceClients.catalog.get('/api/parts');4. Set Appropriate Timeouts
// ✅ Good - Long timeout for bulk operations
const client = new ServiceClient({
baseUrl: process.env.CATALOG_SERVICE_URL,
timeout: 60000, // 1 minute for bulk import
});
// ❌ Bad - Default timeout might be too short
await serviceClients.catalog.post('/api/parts/bulk-import', {
body: largeDataset, // Might timeout with 5s default
});5. Implement Circuit Breakers (Future)
For production, consider adding circuit breaker logic:
let catalogFailures = 0;
const MAX_FAILURES = 3;
async function callCatalog() {
if (catalogFailures >= MAX_FAILURES) {
throw new Error('Catalog service circuit breaker open');
}
const response = await serviceClients.catalog.get('/api/parts');
if (!response.success) {
catalogFailures++;
} else {
catalogFailures = 0; // Reset on success
}
return response;
}Example: Multi-Service Aggregation
import { serviceClients } from '@crop/shared-hono';
app.get('/api/parts/:id/enriched', async (c) => {
const partId = c.req.param('id');
// Fetch part details, media, and pricing in parallel
const [partResponse, mediaResponse, pricingResponse] = await Promise.all([
serviceClients.catalog.get<Part>(`/api/parts/${partId}`, { context: c }),
serviceClients.media.get<Media[]>(`/api/media?partId=${partId}`, { context: c }),
serviceClients.catalog.get<Pricing>(`/api/parts/${partId}/pricing`, { context: c }),
]);
// Check all responses
if (!partResponse.success) {
return c.json({ success: false, error: partResponse.error }, 404);
}
if (!mediaResponse.success) {
return c.json({ success: false, error: mediaResponse.error }, 500);
}
if (!pricingResponse.success) {
return c.json({ success: false, error: pricingResponse.error }, 500);
}
// Aggregate data
const enrichedPart = {
...partResponse.data,
media: mediaResponse.data,
pricing: pricingResponse.data,
};
return c.json({
success: true,
data: enrichedPart,
meta: {
timestamp: new Date().toISOString(),
requestId: c.get('requestId'),
},
});
});Development vs Production
Local Development
Services use localhost URLs by default:
// No environment variables needed
const response = await serviceClients.catalog.get('/api/parts');
// → http://localhost:3003/api/partsDocker Compose
Set container names in docker-compose.yml:
services:
search:
environment:
CATALOG_SERVICE_URL: http://catalog:3000
MEDIA_SERVICE_URL: http://media:3000Production (Cloud Run)
Set fully qualified URLs:
CATALOG_SERVICE_URL=https://catalog-service-abc123.run.app
MEDIA_SERVICE_URL=https://media-service-xyz789.run.appOr use Cloud Run service names if on the same VPC:
CATALOG_SERVICE_URL=http://catalog-service
MEDIA_SERVICE_URL=http://media-serviceTesting
Mock service clients in tests:
import { describe, test, expect, mock } from 'bun:test';
describe('Enriched Parts API', () => {
test('aggregates data from multiple services', async () => {
// Mock service client
const mockCatalogClient = {
get: mock(async () => ({
success: true,
data: { id: '1', partNumber: 'ABC123' },
})),
};
// Use mock in test
const app = createApp({ catalogClient: mockCatalogClient });
const res = await app.request('/api/parts/1/enriched');
const data = await res.json();
expect(data.success).toBe(true);
expect(mockCatalogClient.get).toHaveBeenCalledTimes(1);
});
});Troubleshooting
Service Not Reachable
const response = await serviceClients.catalog.get('/api/parts');
// { success: false, error: { code: 'NETWORK_ERROR', message: '...' } }Solutions:
- Check service is running:
docker psorcurl http://localhost:3003/health - Verify environment variable:
echo $CATALOG_SERVICE_URL - Check Docker network:
docker network inspect crop-network
Timeout Errors
// { success: false, error: { code: 'TIMEOUT', message: 'Request timed out...' } }Solutions:
- Increase timeout:
SERVICE_TIMEOUT=10000 - Optimize slow endpoint in target service
- Use background jobs for long-running operations
Request ID Not Forwarded
Check you're passing the context:
// ✅ Correct
await serviceClients.catalog.get('/api/parts', { context: c });
// ❌ Missing context
await serviceClients.catalog.get('/api/parts');Related Documentation
- Service Standards - Service structure and conventions
- API Standards - API response format and error codes
- Shared Packages - Available shared packages