DEV Community

Teguh Coding
Teguh Coding

Posted on

Building REST APIs That Don't Suck: Patterns I Wish I Knew Sooner

Building REST APIs That Don't Suck: Patterns I Wish I Knew Sooner

After building dozens of APIs in production, I've learned that the difference between an API that developers love and one they secretly hate often comes down to a few key patterns. Today, I'm sharing five approaches that have saved me countless debugging sessions and made my codebases actually maintainable.

1. The Repository Pattern: Abstraction That Actually Helps

Here's something I see too often: business logic tangled directly with database calls scattered across route handlers. It's a mess to test and even worse to debug at 2 AM.

The repository pattern changed this for me completely. Instead of mixing concerns, I abstract all data access:

// repositories/UserRepository.js
class UserRepository {
  constructor(database) {
    this.db = database;
  }

  async findById(id) {
    return this.db.users.findUnique({ where: { id } });
  }

  async findByEmail(email) {
    return this.db.users.findUnique({ where: { email } });
  }

  async create(data) {
    return this.db.users.create({ data });
  }

  async update(id, data) {
    return this.db.users.update({ where: { id }, data });
  }
}

module.exports = UserRepository;
Enter fullscreen mode Exit fullscreen mode

Now my route handlers stay clean, and swapping databases later doesn't require rewriting half my application. The key insight? Your routes should never know whether you're using PostgreSQL, MongoDB, or an in-memory array for testing.

2. Consistent Error Responses: Save Your Future Self

I used to return different error formats across endpoints. Sometimes it was { error: "Message" }, other times { message: "Message" }, and occasionally just a raw string. My frontend colleagues rightfully complained.

Now everything follows a consistent structure:

// utils/apiResponse.js
class ApiResponse {
  static success(data, meta = {}) {
    return {
      success: true,
      data,
      meta,
      timestamp: new Date().toISOString()
    };
  }

  static error(message, code = 'INTERNAL_ERROR', statusCode = 500) {
    return {
      success: false,
      error: {
        code,
        message,
        timestamp: new Date().toISOString()
      }
    };
  }

  static validationError(errors) {
    return {
      success: false,
      error: {
        code: 'VALIDATION_ERROR',
        message: 'Invalid input data',
        details: errors,
        timestamp: new Date().toISOString()
      }
    };
  }
}

// Usage in Express
app.get('/users/:id', async (req, res) => {
  try {
    const user = await userService.findById(req.params.id);
    if (!user) {
      return res.status(404).json(
        ApiResponse.error('User not found', 'NOT_FOUND', 404)
      );
    }
    res.json(ApiResponse.success(user));
  } catch (error) {
    res.status(500).json(ApiResponse.error('Something went wrong'));
  }
});
Enter fullscreen mode Exit fullscreen mode

This consistency means my error handling middleware can be centralized, and my frontend team can build predictable error UI.

3. The Service Layer: Where Business Logic Lives

Routes should be thin. I mean really thin. Your route files should only handle HTTP concerns: parsing requests, calling services, and formatting responses. Everything else goes into services:

// services/UserService.js
class UserService {
  constructor(userRepository, emailService) {
    this.userRepository = userRepository;
    this.emailService = emailService;
  }

  async registerUser(userData) {
    // Business validation
    const existing = await this.userRepository.findByEmail(userData.email);
    if (existing) {
      throw new ConflictError('Email already registered');
    }

    // Hash password (never in routes!)
    const hashedPassword = await bcrypt.hash(userData.password, 12);

    // Create user
    const user = await this.userRepository.create({
      ...userData,
      password: hashedPassword
    });

    // Send welcome email
    await this.emailService.sendWelcome(user.email);

    // Return without sensitive data
    const { password, ...safeUser } = user;
    return safeUser;
  }
}
Enter fullscreen mode Exit fullscreen mode

The magic here is testability. I can test my business logic without spinning up an HTTP server. I can mock dependencies easily. My tests read like documentation.

4. Request Validation: Fail Fast, Fail Clear

Validation at the database level is too late. You want to reject invalid requests before they touch your business logic. I use Zod for schema validation:

// validators/userValidator.js
const createUserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8).regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, 
    'Password must contain uppercase, lowercase, and number'),
  name: z.string().min(2).max(100),
  role: z.enum(['user', 'admin', 'moderator']).default('user')
});

const updateUserSchema = createUserSchema.partial();

function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json(
        ApiResponse.validationError(result.error.issues)
      );
    }
    req.validatedData = result.data;
    next();
  };
}

// Usage
app.post('/users', validate(createUserSchema), userController.create);
app.patch('/users/:id', validate(updateUserSchema), userController.update);
Enter fullscreen mode Exit fullscreen mode

The schema documents your API contract. New developers can read it and immediately understand what the endpoint expects. Plus, Zod's error messages are actually readable.

5. Pagination: Don't Return Everything

This one seems obvious, but I've seen production APIs returning thousands of records because "the client can handle it." They can't, and they shouldn't have to.

// utils/pagination.js
async function paginate(query, { page = 1, limit = 20 }) {
  const skip = (page - 1) * limit;

  const [data, total] = await Promise.all([
    query.skip(skip).limit(limit),
    query.clone().count()
  ]);

  return {
    data,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
      hasMore: page * limit < total
    }
  };
}

// Usage in route
app.get('/posts', async (req, res) => {
  const { page, limit } = req.query;
  const result = await paginate(
    db.posts.findMany({ where: { published: true } }),
    { page: parseInt(page) || 1, limit: parseInt(limit) || 20 }
  );
  res.json(ApiResponse.success(result.data, result.pagination));
});
Enter fullscreen mode Exit fullscreen mode

Always include pagination metadata: total records, total pages, and whether more data exists. Your frontend will thank you.

The Bigger Picture

These patterns aren't about being overly architectural or adding unnecessary complexity. They're about creating predictable, maintainable systems that developers actually enjoy working with.

Start with what hurts most in your current project. Is it inconsistent errors? Start there. Is it hard-to-test routes? Introduce the service layer. Is it validation chaos? Add Zod schemas.

You don't need to refactor everything at once. Small, consistent improvements compound into systems you're proud of.

What patterns have made your API development easier? I'd love to hear what's working for you.

Top comments (0)