Backend Development 21 min read

Best Practices and Lessons Learned from Implementing GraphQL BFF Services at Ctrip

This article shares Ctrip's experience building GraphQL‑based Backend‑for‑Frontend services, covering the technology stack, data‑graph modeling, error handling with union types, non‑null field usage, performance optimizations, engineering practices such as data‑loader batching, virtual paths, monitoring, and testing.

Ctrip Technology
Ctrip Technology
Ctrip Technology
Best Practices and Lessons Learned from Implementing GraphQL BFF Services at Ctrip

In recent years, Ctrip's vacation front‑end team has been practicing a GraphQL/Node.js based BFF (Backend for Frontend) solution across multiple product lines, which not only supports multi‑platform development but also brings significant performance benefits.

Many front‑end teams that tried GraphQL BFF reverted to traditional RESTful BFF due to the perceived complexity of GraphQL. This article presents the best practices and common anti‑patterns we have identified.

Technology Stack

graphql – JavaScript implementation

koa v2 – Node.js web framework

apollo-server-koa – Apollo Server for Koa

data-loader – request batching for resolvers

graphql-scalars – common scalar types

faker – type‑based mock data generation

@graphql-codegen/typescript – generate TypeScript types from schema

graphql-depth-limit – limit query depth

jest – unit testing framework

Other non‑core or company‑specific modules are omitted.

GraphQL Best Practices

We focus on IO‑intensive BFF services. All external‑facing GraphQL services are restricted to calling other back‑end APIs, while internal services may access databases or caches directly.

Because GraphQL query cost varies with query complexity, we keep services IO‑bound to maintain predictability.

Data‑Graph vs. Data‑Interface

Instead of exposing a flat list of RESTful endpoints under the root Query, we model relationships as a convergent data graph, allowing nested fields to inherit context and reduce the need for redundant arguments.

Examples illustrate how user favorites and recommended products become hierarchical fields rather than independent root fields.

Using Union Types for Error Handling

Throwing errors to the top level mixes errors from all fields into a single errors array, making it hard to pinpoint the failing node. Representing error and success results as a union type (e.g., AddTodoError | AddTodoSuccess ) provides a clear, mutually exclusive outcome.

Generated TypeScript example:

export type AddTodoResult =
  | { __typename: 'AddTodoError'; message: string; }
  | { __typename: 'AddTodoSuccess'; newTodo: Todo; };

declare const result: AddTodoResult;
if (result.__typename === 'AddTodoError') {
  console.log(result.message);
} else if (result.__typename === 'AddTodoSuccess') {
  console.log(result.newTodo);
}

This approach eliminates ambiguous {code, data, message} objects and simplifies client‑side handling.

Non‑Null Types

Forgetting to mark fields as non‑null ( ! ) forces clients to handle optional values everywhere, increasing complexity and reducing the advantages of GraphQL's flexible querying.

Proper use of non‑null propagates errors upward, aligning with the GraphQL specification (section 6.4.4).

Practical Deployment

When introducing a new BFF layer, we aim to minimize front‑end code changes by keeping existing response contracts stable, providing field‑level trimming, and adding new fields that are also trim‑able.

We use a json wildcard field to return the full original object while still allowing selective trimming:

type ProductStruct {
  json: JSON
  ProductId: Int
  ...
}

type ProductInfo { ProductData: ProductStruct }

extend type Query { productInfo(params: ProductArgs!): ProductInfo }

Clients can query {json} for the whole payload or request specific fields for a trimmed response.

Application Scenarios

Parallel queries: multiple product details can be fetched in a single GraphQL request, with each resolver executing in parallel.

Serial (transactional) queries: mutations enforce ordering, e.g., adding a ticket to a cart before fetching price details.

Data‑loader batching: shared loaders at the parent level deduplicate requests from child fields, reducing redundant API calls.

Engineering Practices

Exception handling logs clearly indicate which parent field caused a child failure.

Virtual paths (e.g., /basename/graphql/productInfo ) improve observability by distinguishing different GraphQL entry points.

Monitoring embeds tracing in each resolver to identify unused nodes.

Unit testing uses Jest and @apollo/client to spin up a test server and validate query responses.

Conclusion

Implementing a GraphQL BFF layer brings flexibility and performance gains but requires careful design around data modeling, error handling, non‑null constraints, and engineering tooling to ensure maintainability and developer productivity.

performanceBFFerror handlingnodejsKoaGraphQLApollodata-loader
Ctrip Technology
Written by

Ctrip Technology

Official Ctrip Technology account, sharing and discussing growth.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.