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.2 — GraphQL Mental Model: Schemas, Types & Resolvers (Conceptual)
You don’t need GraphQL to benefit from GraphQL thinking. This post explains schemas, types, and resolvers—and how they map directly to clean REST architecture.
Part 8.1 — Why GraphQL Exists: Problems It Tries to Solve
GraphQL didn’t appear because REST failed. It appeared because many REST APIs were designed without client needs in mind.
Part 4.6 — Building a Maintainable Testing Architecture
Writing tests is easy. Maintaining hundreds of them is hard. This post shows how to structure your NestJS backend tests so they scale without pain.
Comments