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.
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.
Ctrip Technology
Official Ctrip Technology account, sharing and discussing growth.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.