RPC Worker Style Guide
This guide describes our approach to creating microservices using Cloudflare Workers’ RPC capabilities via service bindings.
Core Concepts
Section titled “Core Concepts”- Workers can expose methods to other workers through RPC (Remote Procedure Call)
- Service bindings allow direct worker-to-worker communication without public URLs
- Methods are exposed by extending the
WorkerEntrypointclass - Each RPC service should focus on a specific domain/capability
Implementation Pattern
Section titled “Implementation Pattern”1. Create Service Class
Section titled “1. Create Service Class”import { WorkerEntrypoint } from 'cloudflare:workers'
export class MyService extends WorkerEntrypoint<Env> { // Required fetch handler async fetch() { return new Response('Service running') }
// Public methods that can be called via RPC async doSomething(param1: string, param2: number) { // Implementation return result }}2. Define Environment Interface
Section titled “2. Define Environment Interface”The newly created service needs to be added to our Env interface. This can be done by running pnpm cf-typegen in the apps/api directory. This will update the generated Env interface in apps/api/worker-configuration.d.ts. In order to have working code completion from Typescript, we need to import and reference the service in the type definition like so:
MY_SERVICE: Fetcher<import('./src/rpc-services/MyService').MyService>Additionally add any other bindings such as environment variables or secrets to the Env interface.
interface Env { // Define required bindings SOME_SECRET: string // ... other bindings}3. Configure Service Binding
Section titled “3. Configure Service Binding”In the calling worker’s wrangler.json:
services = [ { "binding": "MY_SERVICE", "service": "honeygrid", "entrypoint": "MyService" }]4. Call Service Methods
Section titled “4. Call Service Methods”// In another workerexport default { async fetch(request: Request, env: Env) { const result = await env.MY_SERVICE.doSomething('test', 123) return new Response(JSON.stringify(result)) },}Best Practices
Section titled “Best Practices”Service Design
Section titled “Service Design”-
Single Responsibility
- Each service should handle one domain/capability
- Example:
CallRailServicefocuses only on CallRail API interactions
-
Method Naming
- Use clear, action-based names (e.g.,
getCall,updateCallDetails) - Be consistent with CRUD operations
- Use clear, action-based names (e.g.,
-
Type Safety
- Define strict interfaces for parameters and returns
- Use Zod or similar for runtime validation
const result = MyValidator.parse(data)
Error Handling
Section titled “Error Handling”-
Throw Meaningful Errors
if (!response.ok) {throw new Error('Failed to fetch data: ' + (await response.text()))} -
Validate Inputs/Outputs
const validated = InputSchema.parse(input)
Testing
Section titled “Testing”-
Mock External Services
fetchMock.get('https://api.example.com').intercept({ path: '/resource' }).reply(200, mockData) -
Test Happy & Error Paths
it('handles API errors', async () => {fetchMock.get('https://api.example.com').reply(500)await expect(service.method()).rejects.toThrow()})
Caching
Section titled “Caching”-
Use KV for Response Caching
const cached = await this.env.CACHE.get(key)if (cached) return JSON.parse(cached) -
Cache Invalidation
- Set appropriate TTLs
- Implement cache busting when data changes
Example Service
Section titled “Example Service”See CallRailService for a complete example implementing these patterns:
export class CallRailService extends WorkerEntrypoint<Env> { async getCall(callId: string) { // Input validation if (!callId) throw new Error('Call ID required')
// External API call with error handling const response = await fetch(`${API_URL}/calls/${callId}`, { headers: this.getHeaders(), }) if (!response.ok) throw new Error('Failed to fetch call')
// Response validation return EnrichedCall.parse(await response.json()) }}