Modernizing API Development: Why Manual REST API Creation is an Anti-Pattern

Backend Development

Discover why manually building REST APIs from scratch is an outdated anti-pattern in 2025. This article explores how schema-driven development with frameworks like tRPC, Hono, Fastify, and NestJS offers faster, safer, and self-documenting API solutions by replacing repetitive boilerplate with declarative contracts.

If you've spent any time as a backend or full-stack developer, you're familiar with the ritual: a new feature demands a new API endpoint, triggering a boilerplate ceremony of defining routes, writing controllers, validating input, handling errors, and updating documentation.

This manual process is not just tedious; it's inherently fragile. Each additional definition or type cast introduces a potential point of failure—mismatched types, outdated documentation, or forgotten validation rules. Developers have often accepted this as the unavoidable cost of reliability.

However, in 2025, it's time to challenge this assumption. Building APIs manually has become an anti-pattern. The modern development ecosystem offers a superior approach: a schema-driven paradigm that replaces repetitive setup with declarative contracts.

This article will deconstruct the traditional method, introduce the schema-driven model, and demonstrate why crafting REST APIs from scratch is no longer efficient or necessary.

A “Classic” REST Endpoint Setup

To illustrate the problem, let's build a simple POST /users endpoint using the "classic" Express and yup approach.

First, we define the types and validation schema:

import * as yup from 'yup';

// Definition 1: TypeScript interface
interface CreateUserRequest {
  username: string;
  email: string;
  age: number;
}

// Definition 2: Validation schema
const createUserSchema = yup.object({
  username: yup.string().min(3).required(),
  email: yup.string().email().required(),
  age: yup.number().positive().integer().required(),
});

Here, we've immediately defined the same data structure twice, violating the DRY (Don't Repeat Yourself) principle and creating potential synchronization issues.

Next, the endpoint implementation:

import express, { Request, Response, NextFunction } from 'express';
import * as yup from 'yup';

const app = express();
app.use(express.json());

const validate = (schema: yup.AnyObjectSchema) =>
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      await schema.validate(req.body);
      next();
    } catch (err) {
      res.status(400).json({ type: 'validation_error', message: err.message });
    }
  };

app.post('/users', validate(createUserSchema), (req, res) => {
  const userData = req.body as CreateUserRequest;
  try {
    const newUser = { id: Date.now(), ...userData };
    res.status(201).json(newUser);
  } catch {
    res.status(500).json({ message: 'Internal server error' });
  }
});

This code snippet reveals the repetitive ceremony: duplicate schemas, manual validation middleware, explicit type casting, and try/catch blocks cluttering the logic. Furthermore, we would still need to manually update our OpenAPI documentation, introducing a third source of truth prone to divergence.

The Schema-Driven Solution

The modern alternative is a declarative model: define your API contract once, and let your framework automatically handle routing, validation, and documentation.

Let's reconstruct the same endpoint using tRPC with Zod as our single source of truth.

import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

const createUserSchema = z.object({
  username: z.string().min(3),
  email: z.string().email(),
  age: z.number().positive().int(),
});

export const appRouter = t.router({
  createUser: t.procedure
    .input(createUserSchema)
    .mutation(({ input }) => {
      const newUser = { id: Date.now(), ...input };
      return newUser;
    }),
});

export type AppRouter = typeof appRouter;

Here’s a breakdown of the improvements:

  • One schema, one truth: A single Zod schema defines the input structure.
  • Automatic type inference: Types are automatically inferred from the Zod schema, eliminating manual TypeScript interface definitions.
  • No middleware: Validation is built directly into the procedure definition.
  • No type casting: Inputs and outputs are strongly typed, ensuring correctness at compile time.
  • No try/catch: Errors are handled gracefully by the framework.

The outcome is faster iteration, fewer bugs, and inherently self-documenting code.

Frameworks Embracing Schema-Driven APIs

This shift towards schema-driven development isn't exclusive to tRPC; it represents a broader industry trend. Below are examples of how other popular frameworks implement similar principles.

Hono: Web standards meet type safety

Hono provides a lightweight, performant web framework that integrates Zod for built-in validation middleware.

import { Hono } from 'hono';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';

const app = new Hono();
const createUserSchema = z.object({
  username: z.string().min(3),
  email: z.string().email(),
  age: z.number().positive().int(),
});

app.post('/users', zValidator('json', createUserSchema), (c) => {
  const userData = c.req.valid('json');
  const newUser = { id: Date.now(), ...userData };
  return c.json(newUser, 201);
});

Hono modernizes Express-style syntax with minimal setup while maintaining full type safety.

Fastify: Schema-driven performance

Fastify leverages schemas for both validation and performance optimization, transforming type safety into runtime efficiency.

import Fastify from 'fastify';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';

const fastify = Fastify();
const createUserSchema = z.object({
  username: z.string().min(3),
  email: z.string().email(),
  age: z.number().positive().int(),
});

type CreateUserRequest = z.infer<typeof createUserSchema>;

fastify.post<{ Body: CreateUserRequest }>('/users', {
  schema: { body: zodToJsonSchema(createUserSchema) },
}, async (request, reply) => {
  const newUser = { id: Date.now(), ...request.body };
  reply.code(201).send(newUser);
});

Here, Zod schemas are converted to JSON schemas, allowing Fastify to optimize request parsing and validation.

NestJS: Declarative via decorators

NestJS integrates validation and typing seamlessly through class decorators, eliminating the need for manual wiring.

import { Controller, Post, Body } from '@nestjs/common';
import { IsString, IsEmail, IsInt, Min, MinLength } from 'class-validator';

export class CreateUserDto {
  @IsString()
  @MinLength(3)
  username: string;

  @IsEmail()
  email: string;

  @IsInt()
  @Min(1)
  age: number;
}

@Controller('users')
export class UsersController {
  @Post()
  create(@Body() userData: CreateUserDto) {
    return { id: Date.now(), ...userData };
  }
}

This approach allows developers to define validation rules directly within their DTO (Data Transfer Object) classes.

The Payoff: Faster, Safer, and Self-Documenting

The schema-driven paradigm delivers significant improvements across several key development aspects:

AspectClassic REST (Express + yup)Schema-Driven (tRPC + Zod)
Development velocitySlow and verbose: multiple schemas, middleware, and manual error handling.Rapid and concise: one schema defines the entire contract; plumbing handled by framework.
Safety and reliabilityBrittle: manual type casting and sync issues between layers.End-to-end typesafe: schema shared across server and client with compile-time validation.
DocumentationManual and stale: separate OpenAPI spec that drifts over time.Automatic and current: tools like trpc-openapi generate live documentation from code.

Conclusion

Building APIs manually has become an outdated practice. The schema-driven approach replaces repetitive "glue" code with declarative contracts, empowering frameworks to handle the boilerplate.

It's not merely about writing less code; it's about writing better code. A single, central schema serves as your validation layer, type system, and documentation. The result is APIs that are faster to build, safer to evolve, and simpler to maintain.

The message is clear: stop creating REST APIs from scratch. The advanced frameworks available today are designed to do it for you, more efficiently and reliably.