Part 8.3 — Designing REST APIs That Feel Like GraphQL
Most teams reach for GraphQL because their REST APIs feel rigid.
But rigidity is not a REST requirement—it’s a design failure.
In this post, you’ll design REST APIs that give clients flexibility without:
dynamic query languages
runtime query planners
field-level auth complexity
You’ll use clear contracts, explicit parameters, and disciplined DTOs.
1. The Goal: Client Control Without Chaos
GraphQL gives clients control by letting them specify fields.
In REST, you achieve the same goal by:
defining safe degrees of freedom
keeping response shapes predictable
making flexibility explicit
The key is constraint.
2. Start with Narrow, Purpose-Built DTOs
Avoid this anti-pattern:
return prisma.user.findUnique({ where: { id } });This leaks:
database fields
relations
internal structure
Instead, define DTOs intentionally:
export class UserSummaryDto {
id: number;
name: string;
}export class UserDetailDto extends UserSummaryDto {
email: string;
}Different DTOs = different contracts.
3. Field Selection via Query Parameters
Allow clients to request subsets, not arbitrary fields.
Example:
GET /users/1?fields=summary
GET /users/1?fields=detailIn controller:
@Get(':id')
getUser(
@Param('id') id: number,
@Query('fields') fields: 'summary' | 'detail' = 'summary',
) {
return this.usersService.findById(id, fields);
}This is safer than free-form field lists and easier to cache.
4. Implementing Field Variants in Services
async findById(id: number, fields: 'summary' | 'detail') {
if (fields === 'summary') {
return this.prisma.user.findUnique({
where: { id },
select: { id: true, name: true },
});
}
return this.prisma.user.findUnique({
where: { id },
select: { id: true, name: true, email: true },
});
}Benefits:
explicit contracts
predictable performance
no runtime query building
5. Includes for Nested Data (Explicit Only)
Avoid implicit nesting.
Bad:
GET /users/1
→ returns posts, comments, likes, tags…Good:
GET /users/1?include=posts
GET /users/1?include=posts.commentsController:
@Get(':id')
getUser(
@Param('id') id: number,
@Query('include') include?: 'posts' | 'posts.comments',
) {
return this.usersService.findById(id, include);
}Explicit includes keep APIs understandable.
6. Prisma Makes This Safe and Fast
Prisma’s select and include map perfectly to this pattern.
include === 'posts'
? { posts: true }
: include === 'posts.comments'
? { posts: { include: { comments: true } } }
: undefined;You stay in control of:
query shape
joins
performance
No N+1 surprises.
7. Pagination Is Not Optional
GraphQL encourages pagination everywhere.
REST should too.
GET /posts?page=1&limit=20Service:
findMany(page: number, limit: number) {
return this.prisma.post.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
});
}Never return unbounded lists.
8. Sorting and Filtering as First-Class Concepts
Instead of new endpoints:
GET /posts?sort=createdAt&order=desc
GET /posts?authorId=1This mirrors GraphQL arguments—cleanly.
Validate inputs strictly.
Never pass raw query params to Prisma.
9. Versioning by Evolution, Not Explosion
GraphQL avoids versioning by evolving schemas.
REST can do the same by:
adding fields, not changing them
adding new DTO variants
keeping old contracts intact
Avoid /v2 unless absolutely necessary.
10. Caching Is Still a Superpower
REST keeps one major advantage over GraphQL:
HTTP caching.
Your APIs:
are cacheable
debuggable
observable
By keeping response shapes stable per query, you preserve this advantage.
11. Error Shapes Must Be Predictable
GraphQL always returns structured errors.
REST should too.
Define a standard error response and stick to it.
Never leak stack traces or raw DB errors.
12. The Design Discipline That Matters
GraphQL doesn’t magically fix APIs.
It forces discipline.
You can apply that same discipline by:
designing DTOs intentionally
limiting flexibility
being explicit about includes
respecting performance boundaries
Well-designed REST APIs feel boring—and that’s a compliment.
13. What Comes Next
Now that you know how to:
shape REST responses intentionally
avoid over-fetching
give clients controlled flexibility
Related
Part 8.6 — When (and When Not) to Use GraphQL in Real Systems
GraphQL is powerful—but expensive. This post helps you decide, based on real constraints, whether GraphQL is worth adopting or whether REST will serve you better.
Part 8.5 — Backend Architecture: Controllers vs “Resolvers” in NestJS
If you squint a little, a NestJS controller method already is a resolver. This post shows how to embrace that idea without introducing GraphQL complexity.
Part 8.4 — Client-Driven Data with REST: Avoiding Over-Fetching
Over-fetching is a client problem as much as a server problem. This post shows how React apps can drive data needs intentionally—without turning REST APIs into guesswork.
Comments