GraphQL & tRPC
Expose a federated GraphQL subgraph for UI reads and tRPC procedures for service-to-service calls.
For auth headers see API Authentication; for how the gateway composes subgraphs see Backend & API Gateway.
Federated subgraph conventions
startPlugin builds your typeDefs/resolvers into a subgraph (the plugin's part of the composed schema) with buildSubgraphSchema and serves it at /graphql. The gateway polls each service's subgraph endpoint and composes the supergraph with rover supergraph compose.
- Compose
typeDefsfromapolloCommonTypes(scalarsDate/JSON,CURSOR_DIRECTION,CURSOR_MODE,Attachment,Coordinate,PageInfo) plus per-module schema strings underextend type Query/extend type Mutation. - Operation names are unique repo-wide and prefixed with the plugin or module:
salesPipelines,frontlineInbox,cmsPageList. Client-facing client-portal operations use thecpprefix (cpSalesPipelines). - Keep schema and resolvers inside their module's
graphql/directory.
Pagination
Two parameter styles exist and are not interchangeable:
| Style | Schema fragment | Resolver helper |
|---|---|---|
| Cursor | GQL_CURSOR_PARAM_DEFS (limit, cursor, cursorMode, direction, orderBy, sortMode, aggregationPipeline) | cursorPaginate / cursorPaginateAggregation |
| Offset | GQL_OFFSET_PARAM_DEFS (page, perPage, sortField, sortDirection) | defaultPaginate |
cursorPaginate({ model, params, query, formatter }) (mongoose-utils.ts) enforces limit 1–100 (default 20), decodes the base64 cursor, applies orderBy with an _id tiebreaker, and returns { list, totalCount, pageInfo }. Declare formatter for typed sort fields (createdAt: 'date'). A sales example:
# modules/sales/graphql/schemas/pipeline.ts
salesPipelines(
boardId: String,
isAll: Boolean
limit: Int
cursor: String
cursorMode: CURSOR_MODE
direction: CURSOR_DIRECTION
orderBy: JSON
sortMode: String
aggregationPipeline: [JSON]
): SalesPipelinesListResponse
const { list, totalCount, pageInfo } =
await cursorPaginateAggregation<IPipelineDocument>({
model: models.Pipelines,
pipeline: [{ $match: query }, PIPELINE_STATUS_RANK_STAGE],
params: { ...params, orderBy: PIPELINES_ORDER_BY, limit: params.limit || 20 },
formatter: { createdAt: 'date' },
});
The resolver pipeline and wrapperConfig
wrapApolloResolvers wraps every Query/Mutation resolver: beforeResolvers hooks run first, then wrapPermission calls checkLogin(user), mutations go through withLogging, and everything ends in withSentryCapture. Per-resolver overrides live on resolver.wrapperConfig:
// opt out of the login check for client-portal traffic (sales wishlist)
wishlistQueries.cpWishlist.wrapperConfig = { forClientPortal: true };
// or mark a whole resolver map (sales productReview mutations)
markResolvers(productReviewMutations, {
wrapperConfig: { skipPermission: true },
});
forClientPortalrequirescontext.clientPortal; addcpUserRequired: trueto also requirecontext.cpUser.skipPermissionmakes the resolver public; use it sparingly.- Inside resolvers,
context.checkPermission('<action>')enforces a registered action and OAuth scope;contextalso carriesuser,subdomain,models,eventHandlers,__(doc), andprocessId.
tRPC routers
The generated src/trpc/init-trpc.ts creates t = initTRPC.context<ITRPCContext>().create() and an appRouter. Plugins type the context with their models and merge module routers; sales:
export type SalesTRPCContext = ITRPCContext<{ models: IModels }>;
const t = initTRPC.context<SalesTRPCContext>().create();
export const appRouter = t.mergeRouters(
dealTrpcRouter,
posTrpcRouter,
documentTrpcRouter,
// …
);
Procedures are addressed as <module>.<action>. From sales' dealTrpcRouter:
export const dealTrpcRouter = t.router({
deal: {
findOne: t.procedure
.input(z.any())
.query(async ({ ctx, input }) => {
return await ctx.models.Deals.findOne(input).lean();
}),
find: t.procedure
.input(z.any())
.query(async ({ ctx, input }) => {
const { query, skip, limit, sort = {} } = input;
if (!query) return ctx.models.Deals.find(input).lean();
return ctx.models.Deals.find(query).skip(skip || 0).limit(limit || 0).sort(sort).lean();
}),
},
});
Calling other services
Use sendTRPCMessage (trpc/index.ts): it looks up the target's address from Redis service discovery, skips disabled plugins, encodes the tenant and caller context into the x-trpc-context header, and returns defaultValue on failure unless throwOnError is set:
const fields = await sendTRPCMessage({
subdomain,
pluginName: 'core',
method: 'query',
module: 'fields',
action: 'find',
input: { query: { contentType: 'sales:deal' } },
defaultValue: [],
});
The callee sees ctx.subdomain plus optional userId/processId from the header; the /trpc mount rejects mutation calls that carry no context. For a raw client, the generated src/trpc/trpcClients.ts builds createTRPCUntypedClient with httpBatchLink({ url: (await getPlugin('core')).address + '/trpc' }) after isEnabled('core'). On SaaS, sendTRPCMessage instead routes through https://<subdomain>.next.erxes.io/gateway/pl:<plugin>/trpc.
Subscriptions over Redis
Pub/sub runs on graphqlPubsub, a RedisPubSub instance from erxes-api-shared/utils. A plugin declares subscriptions in a dedicated file (src/apollo/subscription.ts) exporting { name, typeDefs, generateResolvers(graphqlPubsub) }:
export default {
name: 'sales',
typeDefs: `salesDealChanged(_id: String!): DealSubscription`,
generateResolvers: (graphqlPubsub) => ({
salesDealChanged: {
resolve: (payload) => payload.salesDealChanged,
subscribe: (_, { _id }) =>
graphqlPubsub.asyncIterator(`salesDealChanged:${_id}`),
},
}),
};
Set hasSubscriptions: true and subscriptionPluginPath in startPlugin. The file is served at /subscriptionPlugin.js; the gateway's subscription service downloads each plugin's bundle (downloadPlugins.ts) to build the composite subscription resolver. Publish with graphqlPubsub.publish('<channel>', payload) from resolvers or afterProcess handlers.