Backend Service-to-Service Communication
Overview
Section titled “Overview”Backend plugins communicate with each other using typed RPC clients. This provides type safety, automatic authentication, and a consistent developer experience across the platform.
Quick Start
Section titled “Quick Start”For service-to-service calls between backend plugins, use the rpcClient core service:
import { coreServices } from "@checkstack/backend-api";import { AuthApi } from "@checkstack/auth-common";
env.registerInit({ deps: { rpcClient: coreServices.rpcClient, }, init: async ({ rpcClient }) => { // Get typed client for target plugin using its Api definition const authClient = rpcClient.forPlugin(AuthApi);
// Make type-safe call const { allowRegistration } = await authClient.getRegistrationStatus(); },});Core Services for Communication
Section titled “Core Services for Communication”1. rpcClient - Typed RPC Communication (Recommended)
Section titled “1. rpcClient - Typed RPC Communication (Recommended)”Use for: All oRPC procedure calls between backend plugins
import { TargetApi } from "@checkstack/target-common";
const client = rpcClient.forPlugin(TargetApi);const result = await client.someProcedure({ input: "data" });Benefits:
- ✅ Full TypeScript type safety with automatic type inference
- ✅ Automatic service token authentication
- ✅ Contract-driven development
- ✅ IDE autocomplete and error checking
2. fetch - Raw HTTP Requests (Rarely Needed)
Section titled “2. fetch - Raw HTTP Requests (Rarely Needed)”Use for: External REST APIs or non-oRPC endpoints only
const response = await fetch.fetch("https://external-api.com/data");const data = await response.json();When you might need fetch:
- Calling external third-party REST APIs
- Integrating with legacy HTTP endpoints
- Custom protocol requirements
Important: Almost all backend-to-backend communication should use
rpcClient. Thefetchservice is provided for edge cases and external integrations.
Complete Example: LDAP → Auth Backend
Section titled “Complete Example: LDAP → Auth Backend”This demonstrates best practices for inter-plugin communication.
Step 1: Define Contract (Common Package)
Section titled “Step 1: Define Contract (Common Package)”import { oc } from "@orpc/contract";import { createClientDefinition } from "@checkstack/common";import { z } from "zod";import { pluginMetadata } from "./plugin-metadata";
export const authContract = { getRegistrationStatus: oc .meta({ access: [] }) // Public endpoint .output(z.object({ allowRegistration: z.boolean().describe( "When enabled, new users can create accounts. When disabled, only existing users can sign in." ) })),
setRegistrationStatus: oc .meta({ access: [access.registrationManage] }) .input(z.object({ allowRegistration: z.boolean() })) .output(z.object({ success: z.boolean() })),};
// Create typed Api definition for type-safe forPlugin usage// Pass pluginMetadata directly - enforces using the centralized metadataexport const AuthApi = createClientDefinition(authContract, pluginMetadata);Step 2: Implement Backend (Backend Plugin)
Section titled “Step 2: Implement Backend (Backend Plugin)”import { implement } from "@orpc/server";import { authContract } from "@checkstack/auth-common";
const os = implement(authContract).$context<RpcContext>();
export const createAuthRouter = (configService: ConfigService) => { const getRegistrationStatus = os.getRegistrationStatus.handler(async () => { const config = await configService.get( "platform.registration", platformRegistrationConfigV1, 1 ); return { allowRegistration: config?.allowRegistration ?? true }; });
const setRegistrationStatus = os.setRegistrationStatus.handler( async ({ input }) => { await configService.set( "platform.registration", platformRegistrationConfigV1, 1, { allowRegistration: input.allowRegistration } ); return { success: true }; } );
return os.router({ getRegistrationStatus, setRegistrationStatus, });};Step 3: Call from Consumer (LDAP Plugin)
Section titled “Step 3: Call from Consumer (LDAP Plugin)”import { createBackendPlugin, coreServices } from "@checkstack/backend-api";import { AuthApi } from "@checkstack/auth-common";import { pluginMetadata } from "./plugin-metadata";
export default createBackendPlugin({ metadata: pluginMetadata, register: (env) => { env.registerInit({ deps: { rpcClient: coreServices.rpcClient, logger: coreServices.logger, // ... other deps }, init: async ({ rpcClient, logger }) => { // Get typed client using Api definition const authClient = rpcClient.forPlugin(AuthApi);
// Somewhere in your logic... try { const { allowRegistration } = await authClient.getRegistrationStatus();
if (!allowRegistration) { throw new Error("Registration is disabled"); }
// Continue with user creation... } catch (error) { logger.error("Failed to check registration status:", error); // Handle error appropriately } }, }); },});Best Practices
Section titled “Best Practices”-
Always use typed clients via Api definitions
import { MyApi } from "@checkstack/my-common";const client = rpcClient.forPlugin(MyApi);const result = await client.myProcedure({ id: "123" }); -
Export Api definitions from common packages
my-plugin-common/src/rpc-contract.ts import { createClientDefinition } from "@checkstack/common";import { pluginMetadata } from "./plugin-metadata";// Pass pluginMetadata directly - enforces centralized metadataexport const MyApi = createClientDefinition(myContract, pluginMetadata); -
Handle errors gracefully
try {const result = await client.doSomething();} catch (error) {logger.error("RPC call failed:", error);// Provide fallback or propagate} -
Document cross-plugin dependencies
/*** Calls auth-backend to verify registration status.* @requires auth-backend plugin*/const checkRegistration = async () => { ... }
❌ DON’T
Section titled “❌ DON’T”-
Don’t use fetch for oRPC procedures
// ❌ BAD: Raw fetch for oRPCconst response = await fetch.forPlugin("auth-backend").post("getRegistrationStatus",{});const data = await response.json(); // No type safety!// ✅ GOOD: Typed RPC client with Api definitionimport { AuthApi } from "@checkstack/auth-common";const authClient = rpcClient.forPlugin(AuthApi);const { allowRegistration } = await authClient.getRegistrationStatus(); -
Don’t use string-based forPlugin (legacy pattern)
// ❌ LEGACY: Type-only import with string plugin IDconst client = rpcClient.forPlugin<AuthClient>("auth-backend");// ✅ CURRENT: Api definition with automatic type inferenceconst client = rpcClient.forPlugin(AuthApi); -
Don’t make blocking calls without error handling
// ❌ BAD: No error handlingconst result = await client.criticalOperation();// ✅ GOOD: Graceful error handlingtry {const result = await client.criticalOperation();} catch (error) {logger.error("Operation failed:", error);return defaultValue;} -
Don’t depend on frontend packages
// ❌ BAD: Backend depending on frontendimport { something } from "@my-plugin/frontend";// ✅ GOOD: Use common packageimport { something } from "@my-plugin/common";
Authentication
Section titled “Authentication”Service-to-service calls are automatically authenticated with service tokens:
- Each plugin receives a scoped
rpcClientvia dependency injection - The client automatically includes service tokens in all requests
- Service tokens grant full access (
*) to bypass authorization - Target plugin sees the request as coming from a trusted service
You don’t need to handle authentication manually - it’s automatic!
Error Handling
Section titled “Error Handling”RPC calls throw standard oRPC errors. Always wrap calls in try/catch:
import { ORPCError } from "@orpc/server";
try { const result = await client.myProcedure({ id: "123" });} catch (error) { if (error instanceof ORPCError) { // Handle known oRPC errors logger.error(`RPC error [${error.code}]:`, error.message); } else { // Handle unexpected errors logger.error("Unexpected error:", error); }
// Decide: throw, return default, or retry throw error;}Testing
Section titled “Testing”Use mock RPC clients in tests:
import { describe, it, expect, mock } from "bun:test";import { AuthApi } from "@checkstack/auth-common";import type { InferClient } from "@checkstack/common";
describe("My Service", () => { it("checks registration status", async () => { // Create mock client that matches the Api's inferred type const mockAuthClient: InferClient<typeof AuthApi> = { getRegistrationStatus: mock(() => Promise.resolve({ allowRegistration: false }) ), // ... other methods } as InferClient<typeof AuthApi>;
// Create mock rpcClient const mockRpcClient = { forPlugin: mock(() => mockAuthClient), };
// Test your code with the mock // ... });});Migration Guide
Section titled “Migration Guide”If you have existing code using fetch.forPlugin() for RPC calls:
Before (Not Recommended)
Section titled “Before (Not Recommended)”const fetchService = await deps.fetch;const response = await fetchService.forPlugin("auth-backend").post( "getRegistrationStatus", {});
if (response.ok) { const data = await response.json(); // No type safety on data if (!data.allowRegistration) { throw new Error("Registration disabled"); }}After (Recommended)
Section titled “After (Recommended)”import { AuthApi } from "@checkstack/auth-common";
const authClient = rpcClient.forPlugin(AuthApi);const { allowRegistration } = await authClient.getRegistrationStatus();
if (!allowRegistration) { throw new Error("Registration disabled");}Benefits of migration:
- Full type safety on input and output
- Autocomplete in IDE
- Compile-time error checking
- Less boilerplate code
- Better error messages
When to Use Fetch Service
Section titled “When to Use Fetch Service”The fetch service is still available for specific use cases:
External REST APIs
Section titled “External REST APIs”const response = await fetch.fetch("https://api.github.com/repos/owner/repo");const repoData = await response.json();Legacy HTTP Endpoints
Section titled “Legacy HTTP Endpoints”const response = await fetch.forPlugin("legacy-service").get("/old-endpoint");Custom Protocols or Binary Data
Section titled “Custom Protocols or Binary Data”const response = await fetch.fetch("https://cdn.example.com/file.pdf");const blob = await response.blob();Architecture Details
Section titled “Architecture Details”How RPC Client Works
Section titled “How RPC Client Works”- Factory Registration: The
rpcClientservice is registered inPluginManager - Scoped Instances: Each plugin gets its own authenticated client
- Fetch Reuse: Uses existing
fetchservice (no auth duplication) - oRPC Link: Creates
RPCLinkpointing to/apiwith authenticated fetch - Type Casting: Returns typed client via
forPlugin<T>()method
Code Reference
Section titled “Code Reference”this.registry.registerFactory(coreServices.rpcClient, async (pluginId) => { const fetchService = await this.registry.get(coreServices.fetch, pluginId); const apiBaseUrl = process.env.API_BASE_URL || "http://localhost:3000";
const link = new RPCLink({ url: `${apiBaseUrl}/api`, fetch: fetchService.fetch, // Reuses authenticated fetch });
const client = createORPCClient(link);
return { forPlugin<T>(targetPluginId: string): T { return (client as Record<string, T>)[targetPluginId]; }, };});Summary
Section titled “Summary”- Use
rpcClientfor all backend-to-backend oRPC calls (99% of cases) - Use
fetchonly for external REST APIs or legacy HTTP endpoints - Always use Api definitions with
forPlugin(*Api)for automatic type inference - Handle errors with try/catch blocks
- Export Api definitions from common packages using
createClientDefinition - Service authentication is automatic
For questions or issues, refer to the oRPC documentation or check existing plugin implementations.