Understanding the REST to GraphQL Migration
Migrating from REST to GraphQL represents a fundamental shift in how clients communicate with your backend services. REST (Representational State Transfer) has been the dominant API architecture for over a decade, relying on multiple endpoints that return fixed data structures. GraphQL, developed by Facebook in 2012 and open-sourced in 2015, introduces a query language that allows clients to request precisely the data they need from a single endpoint.
This tutorial walks you through a complete, practical migration path—from analyzing your existing REST API to deploying a fully functional GraphQL layer. You'll learn how to approach the migration incrementally, avoid common pitfalls, and ultimately deliver a more flexible, performant API to your consumers.
What Is GraphQL and How It Differs from REST
In REST, you typically hit multiple endpoints to assemble a view. For example, to render a user profile with their recent orders, you might call /users/123 and then /users/123/orders. Each endpoint returns a fixed payload defined by the server. This leads to over-fetching (receiving fields you don't need) or under-fetching (requiring additional requests to get related data).
GraphQL replaces this model with a single endpoint (usually /graphql) that accepts a query string describing exactly which fields and relationships the client wants. The server then resolves that query against a strongly-typed schema, returning only the requested shape. The schema acts as a contract, enabling powerful developer tooling, introspection, and type safety across the stack.
Key conceptual differences:
- Single endpoint vs. many resource-specific endpoints
- Client-driven data shaping vs. server-defined response structures
- Strongly-typed schema vs. implicit JSON contracts
- Mutations, subscriptions, and queries as first-class operations vs. HTTP verbs alone
- Introspection and auto-generated documentation vs. hand-maintained OpenAPI specs
Why Migrating to GraphQL Matters
Teams adopt GraphQL for concrete reasons that go beyond hype. The most impactful benefits include:
- Eliminating over-fetching and under-fetching: Mobile clients on slow networks save significant bandwidth when they request only needed fields. A dashboard widget might need just a user's avatar URL and display name—not the entire user object with 30 fields.
- Reducing round-trips: A single GraphQL query can traverse relationships (user → orders → items → product details) in one request. In REST, this might require 4+ sequential HTTP calls.
- Simplifying frontend development: Frontend teams can iterate independently, composing queries without backend changes. The schema serves as a living contract.
- Unifying data sources: A GraphQL gateway can stitch together microservices, databases, and third-party APIs behind a single schema, reducing client complexity.
- Type safety and validation: GraphQL's type system catches query errors at development time, long before runtime failures in production.
Step-by-Step Migration Guide
We'll follow a proven, incremental approach: build a GraphQL wrapper around your existing REST services first, then gradually replace or refactor the underlying implementations. This avoids a risky "big bang" rewrite and keeps both APIs operational during the transition.
Step 1: Audit Your Existing REST API
Begin by cataloging every REST endpoint, its request parameters, response shape, and authentication requirements. Document which frontend views consume each endpoint and what data they actually use. This audit reveals the "real" data requirements versus the theoretical ones.
Create a simple mapping document. For each endpoint, list:
- HTTP method and path (e.g.,
GET /users/:id) - Request parameters (query, path, headers)
- Response JSON structure with all possible fields
- Client-side usage: which fields does each screen actually render?
- Related endpoints called in sequence (e.g., after
/users/:id, the client calls/users/:id/orders)
This audit becomes the foundation for designing your GraphQL schema. The fields that clients actually use should map directly to GraphQL type fields; fields that are never consumed can be left out of the initial schema.
Step 2: Design Your GraphQL Schema
With the audit complete, design your schema using the GraphQL Schema Definition Language (SDL). Start with the core types that mirror your REST resources, then add relationships that replace chained REST calls.
Here's an example schema based on a typical e-commerce REST API:
# schema.graphql
type Query {
user(id: ID!): User
users(limit: Int, offset: Int): [User!]!
product(id: ID!): Product
products(category: String, limit: Int): [Product!]!
order(id: ID!): Order
}
type User {
id: ID!
email: String!
displayName: String
avatarUrl: String
createdAt: String
# Relationship replaces GET /users/:id/orders
orders(status: String, limit: Int): [Order!]!
# Relationship replaces GET /users/:id/reviews
reviews(limit: Int): [Review!]!
}
type Order {
id: ID!
status: String!
total: Float!
createdAt: String!
items: [OrderItem!]!
# Nested relationship
shippingAddress: Address
}
type OrderItem {
id: ID!
quantity: Int!
unitPrice: Float!
product: Product!
}
type Product {
id: ID!
name: String!
description: String
price: Float!
imageUrl: String
inStock: Boolean!
category: String
reviews(limit: Int): [Review!]!
}
type Review {
id: ID!
rating: Int!
comment: String
author: User!
createdAt: String!
}
type Address {
street: String
city: String
state: String
zipCode: String
country: String
}
type Mutation {
createOrder(input: CreateOrderInput!): CreateOrderPayload!
updateUserProfile(input: UpdateProfileInput!): UpdateProfilePayload!
addReview(input: AddReviewInput!): AddReviewPayload!
}
input CreateOrderInput {
userId: ID!
items: [OrderItemInput!]!
shippingAddressId: ID
}
input OrderItemInput {
productId: ID!
quantity: Int!
}
type CreateOrderPayload {
order: Order
errors: [String!]
}
input UpdateProfileInput {
userId: ID!
displayName: String
email: String
}
type UpdateProfilePayload {
user: User
errors: [String!]
}
input AddReviewInput {
productId: ID!
rating: Int!
comment: String
}
type AddReviewPayload {
review: Review
errors: [String!]
}
Notice how relationships like User.orders and OrderItem.product flatten the nested REST calls into a single queryable graph. The client can now traverse from user to orders to items to product details in one request.
Step 3: Set Up a GraphQL Server as a Wrapper
Rather than rewriting your entire backend, stand up a GraphQL server that acts as a proxy. This server accepts GraphQL queries, calls your existing REST endpoints under the hood, and reshapes the responses to match the GraphQL schema. This approach is often called the "GraphQL gateway" or "wrapper" pattern.
We'll use Node.js with Apollo Server and the node-fetch library (or built-in fetch in Node 18+). Here's a complete, runnable example:
// server.js - GraphQL wrapper over REST endpoints
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import gql from 'graphql-tag';
import fetch from 'node-fetch';
// ---------- Type Definitions ----------
const typeDefs = gql`
type Query {
user(id: ID!): User
users(limit: Int, offset: Int): [User!]!
product(id: ID!): Product
}
type User {
id: ID!
email: String!
displayName: String
avatarUrl: String
orders(status: String): [Order!]!
}
type Order {
id: ID!
status: String!
total: Float!
items: [OrderItem!]!
}
type OrderItem {
id: ID!
quantity: Int!
unitPrice: Float!
product: Product!
}
type Product {
id: ID!
name: String!
price: Float!
inStock: Boolean!
}
`;
// ---------- REST API Base URL ----------
const REST_API_BASE = 'https://api.example.com/v1';
// ---------- Resolvers wrapping REST calls ----------
const resolvers = {
Query: {
user: async (_parent, args, _context, _info) => {
const response = await fetch(`${REST_API_BASE}/users/${args.id}`);
if (!response.ok) {
throw new Error(`REST user fetch failed with status ${response.status}`);
}
const userData = await response.json();
return userData;
},
users: async (_parent, args, _context, _info) => {
const params = new URLSearchParams();
if (args.limit) params.append('limit', args.limit.toString());
if (args.offset) params.append('offset', args.offset.toString());
const response = await fetch(`${REST_API_BASE}/users?${params.toString()}`);
const usersData = await response.json();
return usersData;
},
product: async (_parent, args, _context, _info) => {
const response = await fetch(`${REST_API_BASE}/products/${args.id}`);
const productData = await response.json();
return productData;
},
},
User: {
orders: async (parent, args, _context, _info) => {
// parent is the resolved User object; parent.id comes from the REST response
const params = new URLSearchParams();
if (args.status) params.append('status', args.status);
const response = await fetch(
`${REST_API_BASE}/users/${parent.id}/orders?${params.toString()}`
);
const ordersData = await response.json();
return ordersData;
},
},
Order: {
items: async (parent, _args, _context, _info) => {
const response = await fetch(`${REST_API_BASE}/orders/${parent.id}/items`);
const itemsData = await response.json();
return itemsData;
},
},
OrderItem: {
product: async (parent, _args, _context, _info) => {
// parent is the OrderItem; parent.productId comes from the REST response
const response = await fetch(`${REST_API_BASE}/products/${parent.productId}`);
const productData = await response.json();
return productData;
},
},
};
// ---------- Server Initialization ----------
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: true, // Enable in non-production for schema discovery
});
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
});
console.log(`GraphQL gateway ready at ${url}`);
This wrapper approach gives you immediate GraphQL capabilities without touching the existing REST codebase. The REST endpoints remain operational, so clients can migrate at their own pace. The resolver functions are thin adapters that translate between GraphQL arguments and REST parameters, then pass the REST JSON response through as the resolved value. GraphQL automatically applies field selection on top, so even though the REST endpoint might return 20 fields, only the requested fields reach the client.
Step 4: Handle Mutations with REST POST/PUT/PATCH Calls
Mutations in GraphQL map naturally to HTTP verbs beyond GET. Your resolver functions for mutations will call the corresponding REST endpoints using POST, PUT, or PATCH methods, passing the GraphQL input arguments as the request body.
Here's how to implement mutations in the wrapper pattern:
// Add to your typeDefs
const mutationTypeDefs = gql`
type Mutation {
createOrder(input: CreateOrderInput!): CreateOrderPayload!
updateUserProfile(input: UpdateProfileInput!): UpdateProfilePayload!
}
input CreateOrderInput {
userId: ID!
items: [OrderItemInput!]!
shippingAddressId: ID
}
input OrderItemInput {
productId: ID!
quantity: Int!
}
type CreateOrderPayload {
order: Order
errors: [String!]
}
input UpdateProfileInput {
userId: ID!
displayName: String
email: String
}
type UpdateProfilePayload {
user: User
errors: [String!]
}
`;
// Add to your resolvers map
const mutationResolvers = {
Mutation: {
createOrder: async (_parent, args, _context, _info) => {
try {
const response = await fetch(`${REST_API_BASE}/orders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: args.input.userId,
items: args.input.items,
shippingAddressId: args.input.shippingAddressId,
}),
});
if (!response.ok) {
const errorBody = await response.json();
return {
order: null,
errors: errorBody.messages || [`HTTP ${response.status}`],
};
}
const orderData = await response.json();
return { order: orderData, errors: [] };
} catch (error) {
return {
order: null,
errors: [error.message],
};
}
},
updateUserProfile: async (_parent, args, _context, _info) => {
try {
const response = await fetch(`${REST_API_BASE}/users/${args.input.userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
displayName: args.input.displayName,
email: args.input.email,
}),
});
if (!response.ok) {
const errorBody = await response.json();
return {
user: null,
errors: errorBody.messages || [`HTTP ${response.status}`],
};
}
const userData = await response.json();
return { user: userData, errors: [] };
} catch (error) {
return {
user: null,
errors: [error.message],
};
}
},
},
};
Notice the payload pattern: mutations return a dedicated payload type that wraps both the success result and potential errors. This allows partial success handling and richer error information than HTTP status codes alone. The REST error responses are translated into the errors array, preserving error details for the client.
Step 5: Add Authentication and Context
Most REST APIs use token-based authentication (Bearer tokens in the Authorization header). In your GraphQL wrapper, you extract the token from the incoming GraphQL request and forward it to the REST calls. Apollo Server's context function is the ideal place for this.
// Enhanced server setup with authentication context
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
const server = new ApolloServer({
typeDefs,
resolvers,
});
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => {
// Extract the Authorization header from the incoming GraphQL request
const authHeader = req.headers.authorization || '';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
return {
token,
restHeaders: {
'Authorization': authHeader,
'Content-Type': 'application/json',
'X-Forwarded-For': req.headers['x-forwarded-for'] || req.socket.remoteAddress,
},
};
},
listen: { port: 4000 },
});
Then update your resolver functions to use the context:
// Updated resolver using context for authentication
const resolvers = {
Query: {
user: async (_parent, args, context, _info) => {
const response = await fetch(`${REST_API_BASE}/users/${args.id}`, {
headers: context.restHeaders,
});
if (response.status === 401) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
if (!response.ok) {
throw new Error(`REST user fetch failed with status ${response.status}`);
}
return response.json();
},
// ... other query resolvers updated similarly
},
User: {
orders: async (parent, args, context, _info) => {
const response = await fetch(
`${REST_API_BASE}/users/${parent.id}/orders?status=${args.status || ''}`,
{ headers: context.restHeaders }
);
return response.json();
},
},
// ... remaining resolvers updated similarly
};
This pattern ensures that authentication flows transparently from the GraphQL client through the gateway to the underlying REST services. The same token validates at both layers, and 401 responses are surfaced as proper GraphQL errors with extension codes that clients can handle programmatically.
Step 6: Optimize with DataLoader to Avoid N+1 Queries
A critical problem emerges when you naively wrap REST endpoints: the N+1 query problem. If a GraphQL query requests 20 users and each user's orders, your resolver will make 1 call for the users list plus 20 individual calls for each user's orders—that's 21 REST requests. DataLoader batches and caches these calls, collapsing them into far fewer requests.
Here's how to integrate DataLoader into your wrapper:
// dataloaders.js - Create batched loaders for REST endpoints
import DataLoader from 'dataloader';
import fetch from 'node-fetch';
const REST_API_BASE = 'https://api.example.com/v1';
// Shared headers object — populated from context
function createLoaders(restHeaders) {
// Batch orders by user ID: single GET with ?userIds=1,2,3,... if your REST API supports it
// If not, DataLoader still deduplicates identical keys within a single tick
const ordersByUserLoader = new DataLoader(async (userIds) => {
// Attempt a bulk fetch if the REST API supports filtering by multiple user IDs
const params = new URLSearchParams();
userIds.forEach(id => params.append('userIds', id));
try {
const response = await fetch(`${REST_API_BASE}/orders?${params.toString()}`, {
headers: restHeaders,
});
const allOrders = await response.json();
// Group orders by userId to match DataLoader's key ordering
const ordersMap = new Map();
for (const order of allOrders) {
if (!ordersMap.has(order.userId)) {
ordersMap.set(order.userId, []);
}
ordersMap.get(order.userId).push(order);
}
return userIds.map(id => ordersMap.get(id) || []);
} catch (error) {
// Fallback: make individual requests if bulk endpoint unavailable
// DataLoader will still deduplicate concurrent requests for the same userId
const results = await Promise.all(
userIds.map(async (id) => {
const res = await fetch(`${REST_API_BASE}/users/${id}/orders`, {
headers: restHeaders,
});
return res.json();
})
);
return results;
}
});
// Batch products by ID
const productLoader = new DataLoader(async (productIds) => {
// If REST supports ?ids=1,2,3 parameter
const params = new URLSearchParams();
productIds.forEach(id => params.append('ids', id));
const response = await fetch(`${REST_API_BASE}/products?${params.toString()}`, {
headers: restHeaders,
});
const products = await response.json();
// Maintain key ordering
const productMap = new Map(products.map(p => [p.id, p]));
return productIds.map(id => productMap.get(id) || null);
});
// Batch order items by order ID
const itemsByOrderLoader = new DataLoader(async (orderIds) => {
const params = new URLSearchParams();
orderIds.forEach(id => params.append('orderIds', id));
const response = await fetch(`${REST_API_BASE}/order-items?${params.toString()}`, {
headers: restHeaders,
});
const allItems = await response.json();
const itemsMap = new Map();
for (const item of allItems) {
if (!itemsMap.has(item.orderId)) {
itemsMap.set(item.orderId, []);
}
itemsMap.get(item.orderId).push(item);
}
return orderIds.map(id => itemsMap.get(id) || []);
});
return {
ordersByUserLoader,
productLoader,
itemsByOrderLoader,
};
}
export { createLoaders };
Now update your server context to create fresh loaders per request (important for cache isolation):
// Updated context with DataLoader instances
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => {
const authHeader = req.headers.authorization || '';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
const restHeaders = {
'Authorization': authHeader,
'Content-Type': 'application/json',
};
// Create fresh loaders per request to avoid cross-request caching
const loaders = createLoaders(restHeaders);
return {
token,
restHeaders,
loaders,
};
},
listen: { port: 4000 },
});
Finally, update your resolvers to use the loaders:
// Resolvers using DataLoader — eliminates N+1 REST calls
const resolvers = {
Query: {
user: async (_parent, args, context, _info) => {
const response = await fetch(`${REST_API_BASE}/users/${args.id}`, {
headers: context.restHeaders,
});
return response.json();
},
users: async (_parent, args, context, _info) => {
const params = new URLSearchParams();
if (args.limit) params.append('limit', args.limit.toString());
if (args.offset) params.append('offset', args.offset.toString());
const response = await fetch(`${REST_API_BASE}/users?${params.toString()}`, {
headers: context.restHeaders,
});
return response.json();
},
},
User: {
// Uses DataLoader instead of individual fetch per user
orders: async (parent, args, context, _info) => {
const allOrders = await context.loaders.ordersByUserLoader.load(parent.id);
// Optional: filter by status in-memory if REST bulk endpoint doesn't support it
if (args.status) {
return allOrders.filter(order => order.status === args.status);
}
return allOrders;
},
},
Order: {
items: async (parent, _args, context, _info) => {
return context.loaders.itemsByOrderLoader.load(parent.id);
},
},
OrderItem: {
product: async (parent, _args, context, _info) => {
return context.loaders.productLoader.load(parent.productId);
},
},
};
With DataLoader, requesting 20 users and their orders now makes at most 2 REST calls (one for users, one batched call for orders across all user IDs) instead of 21. This dramatically reduces latency and load on your REST backend.
Step 7: Gradual Backend Replacement
Once the GraphQL wrapper is stable and clients are migrated, you can begin replacing REST endpoints with direct database queries or dedicated GraphQL microservices. The beauty of the wrapper approach is that you can do this one resolver at a time.
For example, to replace the User.orders resolver with a direct database query:
// Before: wrapper resolver calling REST
User: {
orders: async (parent, args, context, _info) => {
const response = await fetch(`${REST_API_BASE}/users/${parent.id}/orders`, {
headers: context.restHeaders,
});
return response.json();
},
}
// After: direct database resolver (no REST dependency)
import { db } from './database.js';
User: {
orders: async (parent, args, context, _info) => {
const rows = await db.query(
'SELECT * FROM orders WHERE user_id = $1 AND status = COALESCE($2, status)',
[parent.id, args.status || null]
);
return rows;
},
}
The GraphQL schema remains unchanged. Clients see no difference. You can ship this change in a routine deployment, monitor for regressions, and gradually phase out the REST endpoint when its traffic drops to zero.
Step 8: Client-Side Migration
On the client side, introduce GraphQL queries alongside existing REST calls. A common strategy is to create a feature flag that toggles between REST and GraphQL data fetching, allowing A/B testing and gradual rollout.
Here's a simplified React example showing the transition:
// Before: REST-based data fetching
async function fetchUserProfile(userId) {
const userRes = await fetch(`/api/users/${userId}`);
const user = await userRes.json();
const ordersRes = await fetch(`/api/users/${userId}/orders`);
const orders = await ordersRes.json();
// Fetch product details for each order item — N+1 nightmare
const enrichedOrders = await Promise.all(
orders.map(async (order) => {
const itemsRes = await fetch(`/api/orders/${order.id}/items`);
const items = await itemsRes.json();
const enrichedItems = await Promise.all(
items.map(async (item) => {
const productRes = await fetch(`/api/products/${item.productId}`);
const product = await productRes.json();
return { ...item, product };
})
);
return { ...order, items: enrichedItems };
})
);
return { user, orders: enrichedOrders };
}
// After: Single GraphQL query — all relationships resolved in one request
const USER_PROFILE_QUERY = `
query UserProfile($userId: ID!) {
user(id: $userId) {
id
email
displayName
avatarUrl
orders {
id
status
total
createdAt
items {
id
quantity
unitPrice
product {
id
name
price
imageUrl
}
}
}
}
}
`;
async function fetchUserProfileGraphQL(userId) {
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: USER_PROFILE_QUERY,
variables: { userId },
}),
});
const { data, errors } = await response.json();
if (errors?.length) {
throw new Error(errors[0].message);
}
return data.user;
}
The GraphQL version replaces multiple round-trips with a single request. The client code becomes simpler—no manual orchestration of dependent calls, no error handling for each intermediate step. The query document serves as self-documenting intent.
Best Practices for REST-to-GraphQL Migration
- Keep REST endpoints running during migration. Never decommission a REST endpoint until you've verified zero traffic to it. Use monitoring and logging to track usage decline over weeks or months.
- Design your GraphQL schema around product needs, not REST shapes. Your schema should reflect how clients think about data, not how your REST resources happen to be organized. Use the audit from Step 1 to guide this.
- Use nullable fields liberally in the transition phase. Mark fields as nullable (
Stringinstead ofString!) when the underlying REST endpoint might not always return them. You can tighten nullability later as you gain confidence. - Implement query depth and complexity limits. GraphQL's flexibility can be abused. Set a maximum query depth (e.g., 5 levels) and a complexity budget per request to prevent denial-of-service queries that traverse deeply nested relationships.
- Version your schema with a deprecation workflow. Use the
@deprecateddirective on fields and provide migration notes via thereasonargument. Clients see these warnings in their IDE. Avoid URL-based versioning—GraphQL's strength is a single evolving schema. - Monitor REST endpoint latency through the GraphQL layer. The wrapper adds its own overhead. Instrument your resolvers with tracing (Apollo provides built-in instrumentation) to detect when underlying REST calls are slowing down queries.
- Cache aggressively at multiple layers. Use HTTP caching headers on REST responses where applicable, in-memory cache in your GraphQL gateway (e.g., Redis), and client-side cache normalization (Apollo Client, URQL) to avoid redundant fetches.
- Handle errors gracefully. A single GraphQL query may partially succeed—some fields resolve while others fail. Return partial data with error paths rather than failing the entire query. This matches how real-world UIs handle degraded experiences.
- Publish your schema to a registry. Use tools like Apollo Studio or GraphQL Hive to publish your schema. This enables schema diffing on pull requests, breaking change detection, and centralized documentation for all consuming teams.
- Educate your team on the mental model shift. REST encourages resource-thinking; GraphQL encourages graph-thinking. Invest in workshops and pair programming sessions to help backend engineers internalize resolver patterns, DataLoader batching, and schema design principles.
Common Pitfalls to Avoid
- Mirroring REST endpoints 1:1 as Query fields. Avoid creating
getUserById,getOrdersByUseras top-level queries. Instead, nest relationships under types. The top-level Query should represent entry points into the graph, not a catalog of every REST path. - Ignoring the N+1 problem until production. The wrapper pattern amplifies N+1 issues because each resolver independently calls REST. Always implement DataLoader or similar batching from day one.
- Exposing internal REST error formats directly. REST error bodies often contain stack traces, internal codes, or database IDs. Sanitize these in your GraphQL error responses to avoid information leakage.
- Over-fetching within resolvers. A resolver that calls a REST endpoint receives the full REST response, but GraphQL only returns requested fields to the client. This is fine for the transition, but eventually, optimize resolvers to fetch only what's needed from the data source.
Conclusion
Migrating from REST to GraphQL is a strategic evolution, not a disruptive replacement. By wrapping your existing REST API with a GraphQL gateway, you unlock client-driven data fetching, eliminate wasteful round-trips, and provide a self-documenting, strongly-typed API contract—all without rewriting your backend. The incremental approach outlined here lets you deliver value immediately, migrate clients at their own pace, and gradually replace underlying implementations as you see fit. Start with the audit, design a thoughtful schema, implement the wrapper with DataLoader from day one, and let your REST endpoints fade away naturally as traffic moves to the GraphQL layer. The result is a more flexible, performant, and developer-friendly API that serves both your current needs and your future architecture.