Migrating to the Service Catalog
Applies to 1.0.0.
1.0.0 replaces the ad-hoc service-registration and cross-service-call wiring with a service catalog: services are declared with defineService, remote routing is configured once with a remoteResolver, and handlers make typed cross-service calls through ctx.call. Two old shapes are removed in the same release:
- the
ServiceRouteregistration callback ((router) => void), and - the per-call
fallbacktransport onserver.client(Desc, { fallback }).
This is a compiling breaking change: the removed types no longer exist, so a project that uses them will fail to type-check until migrated. Migration is currently manual — automated codemods are tracked as a separate, future change.
The examples below use .ts imports for a project running TypeScript directly, with import_extension=.ts on both code-generation plugins. A compiled project may use .js; keep the extension consistent with its buf.gen.yaml. The catalog plugin's default output is catalog.gen.ts.
1. ServiceRoute → defineService
A service is now a { descriptor, register } pair produced by defineService (descriptor + handler map), instead of an opaque router callback. Keeping the proto descriptor alongside the handlers lets the framework build the catalog, drive local vs remote activation, and validate the transport.
Before
import { createServer } from '@connectum/core';
import { GreeterService } from './gen/greeter_pb.ts';
const routes = (router) => {
router.service(GreeterService, {
async sayHello(req, ctx) {
return { message: `Hello, ${req.name}!` };
},
});
};
const server = createServer({ services: [routes] });After
import { createServer, defineService } from '@connectum/core';
import { GreeterService } from './gen/greeter_pb.ts';
const greeter = defineService(GreeterService, {
async sayHello(req, ctx) {
return { message: `Hello, ${req.name}!` };
},
});
const server = createServer({ services: [greeter] });Handlers now receive a Connectum Context (a superset of the raw ConnectRPC HandlerContext that adds ctx.call and ctx.stream). Existing handlers that read ctx.signal, ctx.timeoutMs(), ctx.requestHeader, ctx.values, etc. keep working unchanged — every HandlerContext field is forwarded.
For DI-heavy services whose handlers are expensive to construct, use defineLazyService(descriptor, factory). The factory runs only when the service is actually mounted locally (i.e. listed in enabledServices, covered below), so a service routed to a remote process never instantiates its local dependencies.
import { defineLazyService } from '@connectum/core';
const orders = defineLazyService(OrdersService, () => createOrdersHandlers(deps));Per-service options (interceptors, jsonOptions)
The third argument of router.service(Descriptor, impl, options) — a per-service option bag applied to every method of that service — is preserved as the optional third argument of defineService (and defineLazyService), typed as ServiceOptions. So a service-scoped interceptor chain or jsonOptions migrates one-to-one.
Before
const routes = (router) => {
router.service(GreeterService, handlers, { interceptors: [requireAuth] });
};After
import { defineService } from '@connectum/core';
const greeter = defineService(GreeterService, handlers, {
interceptors: [requireAuth],
});ServiceOptions is exactly @connectrpc/connect's router.service option bag, so jsonOptions and the other per-service handler options carry over unchanged.
A pure local monolith needs nothing else — no catalog, no resolver.
2. server.client(Desc, { fallback }) → remoteResolver
The per-call fallback transport is removed. ServerClientOptions no longer has a fallback field (it now carries only an optional endpoint hint for polymorphic deployments). Instead, configure routing once on the server with a remoteResolver, and server.client(Desc) auto-routes: in-process for locally mounted services, resolver-supplied transport for everything else.
Before
import { createGrpcTransport } from '@connectrpc/connect-node';
const remoteTransport = createGrpcTransport({ baseUrl: 'https://inventory.internal' });
// A manual fallback transport, repeated at every call site:
const client = server.client(InventoryService, { fallback: remoteTransport });
await client.checkStock({ sku });After
import { createServer, singleTransportResolver } from '@connectum/core';
import { createGrpcTransport } from '@connectrpc/connect-node';
import { serviceCatalog } from './gen/catalog.gen.ts';
const server = createServer({
services: [orders], // OrdersService is local
catalog: serviceCatalog, // generated: typeName → DescService
enabledServices: ['shop.v1.OrdersService'],
remoteResolver: singleTransportResolver(
createGrpcTransport({ baseUrl: 'https://inventory.internal' }),
),
});
// Same call site whether InventoryService is co-located or remote:
const client = server.client(InventoryService);
await client.checkStock({ sku });Pick the resolver that matches your topology — all four are exported from @connectum/core and must be synchronous (no network I/O; the framework caches the result per (typeName, endpoint)):
singleTransportResolver(transport)— route every remote service to one upstream (a sidecar or gateway).mapResolver({ [typeName]: transport })— an explicit allow-list; unknowntypeNames resolve tonull(→Code.Unavailable).dnsResolver({ template })— derive a base URL per service from a DNS-style template, e.g.'http://{shortName}.prod.svc.cluster.local:50051'.perServiceEnvResolver({ [typeName]: 'ENV_VAR' })— read each service's base URL from its own environment variable.
The catalog is generated by protoc-gen-catalog (a { typeName → DescService } map). It drives startup validation and remote routing; it is also what makes ctx.call typed (see below).
3. Manual createClient in handlers → ctx.call
Cross-service calls from inside a handler no longer require hand-wiring a transport and a generated client. Call ctx.call("${typeName}/${Method}", req) instead — it is typed by the generated catalog and automatically cascades the inbound abort signal and deadline (a caller may shorten the deadline but never extend it).
An explicit CallOptions.signal replaces the inherited signal; omit it to preserve inbound cancellation. Header propagation is separately opt-in through propagateHeaders. See Service Catalog for the complete cascade rules.
Before
import { createClient } from '@connectrpc/connect';
import { createGrpcTransport } from '@connectrpc/connect-node';
import { InventoryService } from './gen/inventory_pb.ts';
const inventoryTransport = createGrpcTransport({ baseUrl: process.env.INVENTORY_URL });
const orders = defineService(OrdersService, {
async placeOrder(req, ctx) {
const inventory = createClient(InventoryService, inventoryTransport);
// Manual signal/deadline plumbing per call:
const stock = await inventory.checkStock({ sku: req.sku }, { signal: ctx.signal });
// ...
},
});After
const orders = defineService(OrdersService, {
async placeOrder(req, ctx) {
// Typed by the generated catalog; signal + deadline cascade automatically.
const stock = await ctx.call('shop.v1.InventoryService/CheckStock', { sku: req.sku });
// ...
},
});The method key is "${typeName}/${Method}" where Method is the PascalCase proto method name (e.g. "shop.v1.InventoryService/CheckStock"), matching the ConnectRPC URL convention. For streaming methods, use ctx.stream(...) instead of ctx.call(...).
The request initializer typing below is planned for the upcoming @connectum/protoc-gen-catalog 1.3.0 release; the published 1.2.x generator does not emit it. With a catalog generated by the updated plugin, the request parameter is MessageInitShape<typeof InputSchema>. It accepts a protobuf-valid initializer object as well as a full generated message; nested messages, oneofs, maps, and field names follow the input schema. Responses remain full generated messages. Existing catalog.gen.ts files keep their previous full-message request type, so rerun Buf generation after upgrading the plugin to use initializer objects. This is a generated typing change; it does not change runtime dispatch.
ctx.call requires a catalog on the server — without one it throws ConnectError(Code.FailedPrecondition). Without the generated catalog augmentation the key type is never, so ctx.call is statically uncallable — the correct default for a service that makes no cross-service calls.
4. Manual env→endpoint registry → parseServicesEnv + a resolver
Two distinct env-driven concerns, previously hand-rolled in boot code, are now first class. Do not conflate them:
- Which services this process hosts locally —
parseServicesEnvturns a comma-separated env value into theenabledServiceslist (full prototypeNames). - Where to reach remote services —
perServiceEnvResolvermaps each remotetypeNameto the env var holding its base URL.
Before
// Hand-rolled: parse env, build a registry, look up per call.
const REGISTRY = {
'shop.v1.InventoryService': process.env.INVENTORY_URL,
'shop.v1.PaymentService': process.env.PAYMENT_URL,
};After
import { createServer, parseServicesEnv, perServiceEnvResolver } from '@connectum/core';
import { serviceCatalog } from './gen/catalog.gen.ts';
const server = createServer({
services: [orders, inventory],
catalog: serviceCatalog,
// CONNECTUM_SERVICES="shop.v1.OrdersService,shop.v1.InventoryService"
enabledServices: parseServicesEnv(process.env.CONNECTUM_SERVICES),
remoteResolver: perServiceEnvResolver({
'shop.v1.PaymentService': 'PAYMENT_URL',
}),
});A service listed in enabledServices is mounted locally; anything else in the catalog is treated as remote and resolved by the remoteResolver. A perServiceEnvResolver mapping with no entry, or whose env var is unset, resolves to null (→ Code.Unavailable).
5. Error handling: configuration vs operational failures
1.0.0 splits errors by cause, so a programmer mistake fails loud rather than being mapped to an RPC status:
- Configuration mistake →
CatalogConfigError(a plainErrorwith a stack), thrown eagerly.server.client(Desc)for a service that is not mounted locally and has noremoteResolverconfigured throwsCatalogConfigErrorat theserver.client(...)call — not later, at dispatch.enabledServicesentries absent from the catalog throwCatalogConfigErroratserver.start(). - Operational failure →
ConnectErrorwith a meaningfulCode. A resolver that returnsnull(no route) surfaces asConnectError(Code.Unavailable).
// Not mounted locally AND no remoteResolver → configuration error, thrown here:
const client = server.client(PaymentService); // throws CatalogConfigError
// With a remoteResolver that returns null for this service → operational error:
const client = server.client(PaymentService); // throws ConnectError(Code.Unavailable)Catch CatalogConfigError only in tooling/tests; in normal operation it should crash the process so the misconfiguration is fixed, not swallowed.
6. 1.3.0 — outgoingInterceptors run on every route
Applies to 1.3.0.
createServer({ outgoingInterceptors }) is documented as the client-side chain of every catalog call. Before 1.3.0 it ran only on ctx.call / ctx.stream to a service mounted on the same Server. From 1.3.0 it runs exactly once per call on every route, and the standalone client takes its own chain.
| Surface and route | 1.0 - 1.2 | 1.3.0 |
|---|---|---|
ctx.call / ctx.stream, service mounted on this server | chain runs | chain runs |
ctx.call / ctx.stream, service behind the remoteResolver | chain skipped | chain runs, outside the transport's own interceptors |
ctx.call / ctx.stream on createMockContext mock routes | chain skipped | chain runs |
server.client(Desc), service mounted on this server | plain localClient() (no chain) | in-process client with the chain |
server.client(Desc), service behind the remoteResolver | resolver transport only | chain runs, outside the transport's own interceptors |
server.localClient(), createLocalTransport() | plain | plain (unchanged) |
createCatalogClient(...) | no chain option | optional outgoingInterceptors, default empty |
Action — only if you worked around the gap. If you mounted the same signer, OpenTelemetry client interceptor or retry on the resolver's transports so that remote calls were signed or traced, remove that copy and keep the policy in outgoingInterceptors. Left in place it runs twice: two client spans, two token-factory calls, retry amplification. The identity the receiver sees does not change (the inner bearer interceptor overwrites the header), so nothing fails loudly.
Action — credentials now reach every target of the resolver. Before 1.3.0 a signer in outgoingInterceptors never ran on a remoteResolver route, so its token never left the process on those calls. Now the chain runs for every target the resolver returns, including one in another trust domain (a partner API, a third-party service). A bearer signer there sends your internal token to that host. Check each resolver target: restrict the signer by req.service.typeName (return next(req) untouched for services that must not receive the credential), or keep that credential on the transport of the one target that needs it and leave it out of outgoingInterceptors.
Before
const server = createServer({
services: [orders],
catalog,
remoteResolver: singleTransportResolver(
createGrpcTransport({ baseUrl, interceptors: [bearer, otelClient, gatewayHeader] }),
),
});After
const server = createServer({
services: [orders],
catalog,
outgoingInterceptors: [bearer, otelClient], // application policy, once, every route
remoteResolver: singleTransportResolver(
createGrpcTransport({ baseUrl, interceptors: [gatewayHeader] }), // transport-specific only
),
});Two behaviors to know. On a resolver route an interceptor in the chain reads req.url as https://catalog/<typeName>/<Method> and sees no protocol headers; keep wire-level policy (request signing over headers, an audience derived from the host) on the resolver's transport. And a server.client() call to a local service now runs the chain; call server.localClient() for a plain in-process client.
Migration checklist
- [ ] Replace every
(router) => { router.service(...) }callback withdefineService(Descriptor, handlers). - [ ] Switch DI-heavy services to
defineLazyService(Descriptor, factory)where the factory should run only when the service is mounted locally. - [ ] Remove
fallbackfrom allserver.client(Desc, { ... })call sites. - [ ] Configure a single
remoteResolveroncreateServer({ ... })(singleTransportResolver/mapResolver/dnsResolver/perServiceEnvResolver). - [ ] Pass the generated
catalogtocreateServerif any process makes cross-service calls or routes to remote services. - [ ] Replace in-handler
createClient(Svc, transport)withctx.call("${typeName}/${Method}", req)(andctx.stream(...)for streaming). - [ ] Replace hand-rolled env registries with
parseServicesEnv(local activation) and a resolver (remote endpoints). - [ ] Handle the split error model: expect
CatalogConfigErrorfor misconfiguration andConnectError(Code.Unavailable)for an unresolvable remote service. - [ ] During rolling upgrades, add methods compatibly; do not remove or rename a method while callers still use it. An older service answers an unknown method with
Code.Unimplemented. - [ ] On 1.3.0, remove any signer, OpenTelemetry client or retry interceptor you copied onto the resolver's transports; keep that policy only in
outgoingInterceptors(see section 6).