After building both GraphQL and REST APIs for the Catalyst PSA platform, I've learned that the "GraphQL vs REST" debate misses the point. They're tools for different jobs. The question isn't which is better—it's which fits your specific use case.
Here's what 27 years of API development and hands-on experience with both approaches taught me about when to use each.
REST for Project Management, GraphQL for Reporting
In Catalyst PSA, we used both in the same application:
- REST APIs: CRUD operations on projects, tasks, time entries
- GraphQL API: Complex reporting dashboard fetching data across multiple entities
This hybrid approach gave us the simplicity of REST where it made sense and the flexibility of GraphQL where we needed it.
Understanding the Fundamental Difference
REST: Resource-Oriented
REST organizes endpoints around resources:
GET /api/projects
GET /api/projects/123
POST /api/projects
PUT /api/projects/123
DELETE /api/projects/123
GET /api/projects/123/tasks
GET /api/projects/123/time-entries
Each endpoint returns a fixed structure. Want different fields? Create a new endpoint.
GraphQL: Query-Oriented
GraphQL has one endpoint and flexible queries:
query {
project(id: "123") {
id
name
client {
name
industry
}
tasks {
id
name
status
}
}
}Clients specify exactly what they need. Same endpoint, different data shapes.
When REST Wins
1. Simple CRUD Operations
For basic create/read/update/delete, REST is simpler:
// REST endpoint - straightforward
@Post('projects')
async createProject(@Body() dto: CreateProjectDTO) {
return await this.projectService.create(dto);
}
@Get('projects/:id')
async getProject(@Param('id') id: string) {
return await this.projectService.findById(id);
}Compare to GraphQL:
// GraphQL - more boilerplate
@Resolver(() => Project)
export class ProjectResolver {
@Mutation(() => Project)
async createProject(@Args('input') input: CreateProjectInput) {
return await this.projectService.create(input);
}
@Query(() => Project)
async project(@Args('id') id: string) {
return await this.projectService.findById(id);
}
// Need resolver for every field that requires loading
@ResolveField(() => Client)
async client(@Parent() project: Project) {
return await this.clientService.findById(project.clientId);
}
}For CRUD, GraphQL adds complexity without benefit.
2. Caching
HTTP caching works beautifully with REST:
@Get('projects/:id')
@Header('Cache-Control', 'public, max-age=300')
async getProject(@Param('id') id: string) {
return await this.projectService.findById(id);
}CDNs, browsers, and proxies cache GET requests automatically. GraphQL typically uses POST, bypassing HTTP caches.
3. File Uploads
REST handles file uploads naturally:
@Post('projects/:id/attachments')
@UseInterceptors(FileInterceptor('file'))
async uploadAttachment(
@Param('id') id: string,
@UploadedFile() file: Express.Multer.File
) {
return await this.attachmentService.create(id, file);
}GraphQL requires multipart requests with special handling—doable but awkward.
4. Rate Limiting
REST endpoints are easy to rate limit:
@Get('projects')
@UseGuards(RateLimitGuard)
@RateLimit({ points: 100, duration: 60 })
async getProjects() {
return await this.projectService.findAll();
}With GraphQL, how do you rate limit? By operation complexity? Number of fields? It's more complex.
5. Monitoring and Debugging
REST endpoints show up clearly in logs:
GET /api/projects/123 - 200 - 45ms
POST /api/projects - 201 - 120ms
GraphQL logs all look the same:
POST /graphql - 200 - ???ms
You need to parse the query to know what actually happened.
When GraphQL Wins
1. Complex Data Fetching
Our reporting dashboard needed data from 10+ entities. With REST:
// Client makes 10+ requests
const project = await fetch('/api/projects/123');
const client = await fetch(`/api/clients/${project.clientId}`);
const tasks = await fetch('/api/projects/123/tasks');
const timeEntries = await fetch('/api/projects/123/time-entries');
const team = await fetch('/api/projects/123/team-members');
// ... 6 more requests
// Or create a special endpoint
const dashboard = await fetch('/api/projects/123/dashboard');
// But then you need different endpoints for different dashboardsWith GraphQL:
query ProjectDashboard($id: ID!) {
project(id: $id) {
id
name
budget
client {
name
industry
}
tasks {
id
name
status
assignee {
name
avatar
}
}
timeEntries(last: 10) {
hours
description
date
}
teamMembers {
user {
name
role
}
allocation
}
}
}One request, exactly the data needed. GraphQL shines here.
2. Mobile Apps with Limited Bandwidth
Mobile apps need to minimize requests. GraphQL lets them fetch exactly what the UI needs:
# Mobile list view - just names and status
query ProjectList {
projects {
id
name
status
}
}
# Desktop detail view - full data
query ProjectDetail($id: ID!) {
project(id: $id) {
id
name
description
status
budget
# ... all fields
}
}Same endpoint, different payloads for different clients.
3. Evolving APIs
Adding fields to GraphQL is backward-compatible:
type Project {
id: ID!
name: String!
# Add new field - old queries still work
estimatedHours: Int
}REST versioning is harder:
GET /api/v1/projects - old clients
GET /api/v2/projects - new clients with estimatedHours
4. Nested Data Structures
When data is naturally hierarchical:
query OrgStructure {
organization {
departments {
name
teams {
name
members {
name
role
}
}
}
}
}With REST, you'd need recursive requests or a huge single endpoint.
Performance Considerations
The N+1 Problem in GraphQL
GraphQL's flexibility creates performance pitfalls:
query {
projects {
id
name
client { # N+1! Loads client for each project
name
}
}
}Without DataLoader, this makes N+1 database queries. Solution:
@Resolver(() => Project)
export class ProjectResolver {
constructor(private dataLoader: DataLoaderService) {}
@ResolveField(() => Client)
async client(@Parent() project: Project) {
// DataLoader batches and caches
return await this.dataLoader.load('Client', project.clientId);
}
}
// DataLoader implementation
export class DataLoaderService {
private loaders = new Map();
load(type: string, id: string) {
if (!this.loaders.has(type)) {
this.loaders.set(type, new DataLoader(async (ids) => {
// Batch load all IDs at once
return await this.loadMany(type, ids);
}));
}
return this.loaders.get(type).load(id);
}
}REST doesn't have this problem—each endpoint is optimized explicitly.
Query Complexity
Malicious GraphQL queries can DOS your server:
query {
projects {
tasks {
timeEntries {
user {
projects {
tasks {
# Infinite depth!
}
}
}
}
}
}
}You need query complexity limits:
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver } from '@nestjs/apollo';
@Module({
imports: [
GraphQLModule.forRoot({
driver: ApolloDriver,
validationRules: [
depthLimit(5),
createComplexityLimitRule(1000),
],
}),
],
})
export class AppModule {}REST endpoints are bounded by design.
Developer Experience
REST: Easier to Start
REST is intuitive. Any developer can understand:
GET /users/123
GraphQL has a learning curve. Schema definition language, resolvers, fragments, mutations vs queries—there's more to learn.
GraphQL: Better Tooling
Once you're up to speed, GraphQL tooling is excellent:
- GraphQL Playground: Interactive API explorer
- Schema introspection: Self-documenting
- Type generation: Auto-generate TypeScript types from schema
// Auto-generated from GraphQL schema
export interface ProjectQuery {
project: {
id: string;
name: string;
client: {
name: string;
};
};
}REST requires manual documentation (OpenAPI) and type definitions.
Our Hybrid Approach in Catalyst PSA
We used both:
REST for:
- User authentication (
POST /auth/login) - CRUD operations (
GET/POST/PUT/DELETE /projects) - File uploads (
POST /attachments) - Webhooks (
POST /webhooks/stripe)
GraphQL for:
- Dashboards (complex aggregations)
- Reporting (flexible queries)
- Mobile API (minimize bandwidth)
- Public API (let partners query flexibly)
Implementation:
@Module({
imports: [
// REST controllers
ProjectModule,
TaskModule,
TimeEntryModule,
// GraphQL API
GraphQLModule.forRoot({
driver: ApolloDriver,
autoSchemaFile: true,
playground: true,
}),
],
})
export class AppModule {}Both approaches coexist peacefully.
Migration Strategy
If you're migrating from REST to GraphQL (or vice versa), do it gradually:
Phase 1: Run Both
// Keep existing REST
@Get('projects')
async getProjects() { ... }
// Add GraphQL alongside
@Query(() => [Project])
async projects() { ... }Phase 2: Migrate Clients Incrementally
// Old clients use REST
GET /api/projects
// New clients use GraphQL
POST /graphql
query { projects { id name } }Phase 3: Deprecate (Eventually)
Once all clients migrated, remove the old API. But there's no rush—both can coexist.
Decision Framework
Use REST when:
- Building simple CRUD APIs
- HTTP caching is important
- Simplicity > flexibility
- Uploading files
- Public APIs with unpredictable usage
Use GraphQL when:
- Fetching complex, nested data
- Supporting multiple clients (mobile, web, desktop)
- Bandwidth is limited
- Rapid frontend iteration (UI changes don't require backend changes)
- Schema evolution is important
Use both when:
- You have both simple CRUD and complex queries
- Different clients have different needs
- You're migrating between approaches
Conclusion
After building both REST and GraphQL APIs in production, I've learned that it's not either/or. They solve different problems.
REST excels at simple, cacheable, resource-oriented APIs. GraphQL excels at complex data fetching with flexible clients.
In Catalyst PSA, using both gave us the best of both worlds: simple CRUD with REST, flexible reporting with GraphQL.
After 27 years of API development, I can say confidently: pick the right tool for the job, and don't be afraid to use both.

Jason Cochran
Sofware Engineer | Cloud Consultant | Founder at Strataga
27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.