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 typeDefs from apolloCommonTypes (scalars Date/JSON, CURSOR_DIRECTION, CURSOR_MODE, Attachment, Coordinate, PageInfo) plus per-module schema strings under extend 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 the cp prefix (cpSalesPipelines).
  • Keep schema and resolvers inside their module's graphql/ directory.

Pagination

Two parameter styles exist and are not interchangeable:

StyleSchema fragmentResolver helper
CursorGQL_CURSOR_PARAM_DEFS (limit, cursor, cursorMode, direction, orderBy, sortMode, aggregationPipeline)cursorPaginate / cursorPaginateAggregation
OffsetGQL_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 },
});
  • forClientPortal requires context.clientPortal; add cpUserRequired: true to also require context.cpUser.
  • skipPermission makes the resolver public; use it sparingly.
  • Inside resolvers, context.checkPermission('<action>') enforces a registered action and OAuth scope; context also carries user, subdomain, models, eventHandlers, __(doc), and processId.

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.

Was this helpful?