CROP
ProjectsParts Services

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=10000

Default URLs

When environment variables are not set, services default to:

Docker Compose

In Docker Compose, services use container names:

environment:
  CATALOG_SERVICE_URL: http://catalog:3000
  MEDIA_SERVICE_URL: http://media:3000

Request 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 limit
  • NETWORK_ERROR: Network connectivity issue
  • INVALID_RESPONSE: Service returned non-JSON response
  • HTTP_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:

  1. Global timeout (all services):

    SERVICE_TIMEOUT=10000
  2. 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/parts

Docker Compose

Set container names in docker-compose.yml:

services:
  search:
    environment:
      CATALOG_SERVICE_URL: http://catalog:3000
      MEDIA_SERVICE_URL: http://media:3000

Production (Cloud Run)

Set fully qualified URLs:

CATALOG_SERVICE_URL=https://catalog-service-abc123.run.app
MEDIA_SERVICE_URL=https://media-service-xyz789.run.app

Or use Cloud Run service names if on the same VPC:

CATALOG_SERVICE_URL=http://catalog-service
MEDIA_SERVICE_URL=http://media-service

Testing

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:

  1. Check service is running: docker ps or curl http://localhost:3003/health
  2. Verify environment variable: echo $CATALOG_SERVICE_URL
  3. Check Docker network: docker network inspect crop-network

Timeout Errors

// { success: false, error: { code: 'TIMEOUT', message: 'Request timed out...' } }

Solutions:

  1. Increase timeout: SERVICE_TIMEOUT=10000
  2. Optimize slow endpoint in target service
  3. 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');

On this page