nto one paradigm, teams can route requests based on operation type. Read-heavy, cacheable endpoints stay on REST. Dynamic, multi-client interfaces migrate to GraphQL. This hybrid approach reduces infrastructure costs, improves client performance, and decouples frontend iteration from backend deployment cycles.
Core Solution
Building a protocol-agnostic backend requires separating business logic from transport layers. The architecture routes incoming requests through a unified gateway, delegates to a shared service layer, and returns responses formatted according to the selected protocol.
Step 1: Define a Shared Service Layer
Both REST and GraphQL should consume the same data access functions. This prevents duplication and ensures consistent business rules.
// src/services/tenant.service.ts
import { prisma } from '../infra/database';
export interface TenantProfile {
id: string;
displayName: string;
avatarUrl: string;
workspaceCount: number;
}
export async function fetchTenantProfile(tenantId: string): Promise<TenantProfile> {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: {
id: true,
displayName: true,
avatarUrl: true,
workspaces: { select: { id: true } }
}
});
if (!tenant) throw new Error('TENANT_NOT_FOUND');
return {
id: tenant.id,
displayName: tenant.displayName,
avatarUrl: tenant.avatarUrl,
workspaceCount: tenant.workspaces.length
};
}
Step 2: Implement Protocol-Specific Resolvers
GraphQL resolvers map directly to the service layer. REST controllers handle routing and HTTP semantics.
// src/graphql/resolvers/tenant.resolver.ts
import { fetchTenantProfile } from '../../services/tenant.service';
export const tenantResolvers = {
Query: {
tenant: async (_: unknown, args: { id: string }) => {
return fetchTenantProfile(args.id);
}
}
};
// src/rest/controllers/tenant.controller.ts
import { Request, Response } from 'express';
import { fetchTenantProfile } from '../../services/tenant.service';
export async function getTenantProfile(req: Request, res: Response) {
try {
const profile = await fetchTenantProfile(req.params.id);
res.status(200).json(profile);
} catch (err) {
res.status(err.message === 'TENANT_NOT_FOUND' ? 404 : 500)
.json({ error: err.message });
}
}
A lightweight Express router directs traffic. GraphQL queries hit /graphql, while traditional resources use /api/v1/*.
// src/app.ts
import express from 'express';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { tenantResolvers } from './graphql/resolvers/tenant.resolver';
import { getTenantProfile } from './rest/controllers/tenant.controller';
const app = express();
// REST route
app.get('/api/v1/tenants/:id', getTenantProfile);
// GraphQL setup
const apolloServer = new ApolloServer({
typeDefs: `
type Tenant {
id: ID!
displayName: String!
avatarUrl: String
workspaceCount: Int!
}
type Query {
tenant(id: ID!): Tenant
}
`,
resolvers: tenantResolvers
});
await apolloServer.start();
app.use('/graphql', express.json(), expressMiddleware(apolloServer));
export { app };
Architecture Rationale
- Service Layer Isolation: Business logic lives outside transport concerns. Adding a gRPC or WebSocket interface later requires zero service modifications.
- Explicit Routing: Clients choose the protocol that matches their workload. Mobile apps query GraphQL for precise payloads. Webhooks and health checks use REST for HTTP-native semantics.
- Resolver Orchestration: GraphQL's resolver chain introduces ~20ms overhead for simple fetches due to query parsing and validation. This is acceptable because the protocol is reserved for complex traversals where network round-trip elimination yields net gains.
- Type Safety: TypeScript interfaces enforce contract consistency across protocols. GraphQL SDL and REST OpenAPI specs can be auto-generated from the same service signatures.
Pitfall Guide
1. The N+1 Query Trap
Explanation: GraphQL resolvers execute sequentially. Fetching a list of tenants and then resolving each tenant's workspace count triggers one database query per item. With 100 tenants, this becomes 101 queries.
Fix: Implement batching via DataLoader. Group resolver calls within the same tick and execute a single WHERE id IN (...) query. Cache results per request lifecycle.
2. Unbounded Query Depth
Explanation: Clients can construct deeply nested queries that exhaust server memory or trigger recursive joins. GraphQL's flexibility becomes a denial-of-service vector without constraints.
Fix: Apply query complexity scoring and depth limits. Reject queries exceeding a predefined complexity threshold (e.g., 1000 units) before execution. Use validation plugins like graphql-query-complexity.
3. Caching Misalignment
Explanation: GraphQL typically uses POST requests, which bypass HTTP caches. Teams assume GraphQL cannot be cached, leading to redundant database hits and increased latency.
Fix: Implement persisted queries or automatic persisted queries (APQ). Map query hashes to static URLs that CDNs can cache. For highly dynamic data, use application-level caching (Redis) with TTLs aligned to data mutation patterns.
4. Schema Versioning Confusion
Explanation: Teams attempt to version GraphQL schemas like REST (/v1, /v2). This defeats GraphQL's backward-compatible design and fragments client integrations.
Fix: Treat GraphQL schemas as additive. Deprecate fields using @deprecated(reason: "...") and remove them after a grace period. Use schema stitching or federation if domain boundaries require separation.
5. File I/O Over GraphQL
Explanation: Uploading binaries through GraphQL requires multipart request parsing, base64 encoding workarounds, or custom scalar implementations. This adds complexity and breaks standard HTTP streaming.
Fix: Keep file uploads and downloads on REST endpoints. Return signed URLs or download tokens in GraphQL responses, then let clients fetch binaries via optimized HTTP routes.
6. Over-Engineering Simple CRUD
Explanation: Wrapping webhook handlers, health checks, or administrative panels in GraphQL adds resolver overhead without delivering client flexibility.
Fix: Default to REST for single-purpose, predictable endpoints. Reserve GraphQL for interfaces requiring dynamic field selection, real-time subscriptions, or multi-client data composition.
7. Cross-Protocol Auth Drift
Explanation: REST and GraphQL authentication middleware are often implemented separately, leading to inconsistent token validation, session handling, and rate limiting.
Fix: Centralize authentication in a shared middleware layer. Validate JWTs, check scopes, and enforce rate limits before routing to either protocol. Ensure error responses follow a unified format.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Mobile app with limited bandwidth | GraphQL | Reduces payload size by 50%+ through precise field selection | Lower egress costs, improved UX |
| Webhook integration or health check | REST | Leverages native HTTP semantics and status codes | Minimal infrastructure overhead |
| Public API for third-party developers | REST | Universal client compatibility and mature tooling | Lower support burden, faster adoption |
| Real-time dashboard with nested relationships | GraphQL | Single query replaces multiple round trips | Reduced latency, fewer server connections |
| Static product catalog or documentation | REST | HTTP caching at CDN edge eliminates origin hits | Drastically lower compute costs |
| File upload/download service | REST | Native multipart handling and streaming support | Simpler implementation, better throughput |
Configuration Template
// src/infra/router.ts
import express from 'express';
import { rateLimit } from 'express-rate-limit';
import { authenticate } from '../middleware/auth';
import { getTenantProfile } from '../rest/controllers/tenant.controller';
import { createApolloServer } from '../graphql/server';
const router = express.Router();
// Global rate limiting
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100 });
router.use(limiter);
// Auth middleware applies to all routes
router.use(authenticate);
// REST endpoints
router.get('/api/v1/tenants/:id', getTenantProfile);
router.post('/api/v1/webhooks/stripe', async (req, res) => {
// Process webhook, return 200 immediately
res.status(200).send('OK');
});
// GraphQL endpoint
const apolloServer = createApolloServer();
await apolloServer.start();
router.use('/graphql', express.json(), apolloServer.getMiddleware());
export { router };
Quick Start Guide
- Initialize the project: Run
npm init -y && npm i express @apollo/server graphql prisma @prisma/client. Install TypeScript types: npm i -D typescript @types/express @types/node.
- Generate the service layer: Create
src/services/ with pure async functions that interact with your database. Avoid framework-specific imports inside these files.
- Wire the router: Set up Express routes for REST and mount Apollo Server at
/graphql. Apply shared middleware (auth, rate limiting, logging) before protocol branching.
- Test with realistic payloads: Use a load testing tool to simulate simple fetches and complex nested queries. Compare p50/p95 latencies and database query counts.
- Deploy and monitor: Route traffic through a CDN. Enable GraphQL query logging and REST access logs. Track cache hit ratios and resolver execution times to validate architectural decisions.