⚙️ Dev & Engineering

Build Fast Structured APIs with Dependency Injection

Chloe Chen
Chloe Chen
Dev & Engineering Lead

Full-stack engineer obsessed with developer experience. Thinks code should be written for the humans who maintain it, not just the machines that run it.

dependency injectiontest doublesAPI performancedeveloper experienceunit testing

We've all stared at our server logs while some external script or brittle E2E testing suite relentlessly scrapes our UI, right? 🚀 It drives up compute costs, triggers unnecessary re-renders, and inevitably breaks the moment a designer decides to change a CSS class name from .btn-primary to .button-main.

Recent engineering benchmarks have revealed a staggering reality: driving operations through headless browser UI scraping is up to 45x more expensive in compute and latency than interacting with clean, structured APIs. Yet, many teams still default to UI-driven automation because building and maintaining structured APIs feels like a heavy, untestable chore.

But what if I told you that building structured APIs doesn't have to be a headache? The secret to creating blazing-fast, highly maintainable endpoints lies in a concept that often sounds more intimidating than it actually is: Dependency Injection (DI).

Shall we solve this beautifully together? Let's dive into how we can decouple our logic, create perfect testability, and drastically improve our Developer Experience (DX).

The Mental Model: The Restaurant and The Seam

💡 Imagine your application as a high-end restaurant.

When a client relies on UI scraping, it's like a customer walking right past the host, pushing into the kitchen, opening the industrial fridge, and trying to cook their own meal using whatever ingredients they can find. It's chaotic, dangerous, and incredibly inefficient.

Structured APIs are your waiters. They take a specific, formatted order, hand it to the kitchen, and bring back exactly what was requested.

But here is where Dependency Injection comes in. If your waiter (the API route) is tightly coupled to a specific chef (your production database), what happens when you want to test the waiter's ability to take orders without actually cooking a $50 steak? You can't.

Dependency Injection creates a "seam." Instead of the API route importing and calling the database directly, we hand the API route a menu interface. During production, we inject the real database. During testing, we inject a lightweight test double.

Here is what that looks like visually:

Tightly Coupled (Painful) API Route Handler Real Production DB Dependency Injection (Beautiful) API Route Handler Interface (The Seam) Real DB Test Mock

Notice how the blue path gives us options. We are no longer trapped by our infrastructure. Let's build this step-by-step.

Prerequisites

Before we start coding, ensure you have the following ready: - Node.js (v18+ recommended) - A TypeScript project initialized (tsc --init) - A modern testing framework installed (I highly recommend Vitest for its speed and DX: npm install -D vitest)

Step 1: Define the Seam (The Interface)

The core problem with legacy code and testability is tight coupling. If your method directly imports import { db } from './database', there is no seam.

We fix this by defining a contract. Our API doesn't need to know how to talk to PostgreSQL or MongoDB; it just needs to know that an object exists with a getUser method.

// types/repository.ts
export interface User {
  id: string;
  name: string;
  email: string;
  status: 'active' | 'pending';
}

export interface UserRepository {
  getUserById(id: string): Promise<User | null>;
  updateUserStatus(id: string, status: string): Promise<User>;
}

Why this matters: We are designing for DX. By exporting this interface, any developer on your team knows exactly what data shape is required. We've created a boundary between our business logic and our data layer.

Step 2: Implement the Real and Mock Repositories

Now we create two implementations of this interface. One for production, and one for our test suite.

Before we write the code, let's clarify our test double taxonomy. As developers, we often use the word "mock" for everything, but precision matters:
- Dummy: Objects passed around but never actually used.
- Stub: Provides canned answers to calls made during the test.
- Mock: Objects pre-programmed with expectations which form a specification of the calls they are expected to receive.

We are going to build a simple Stub/Mock hybrid for our tests.

// repositories/postgresUserRepository.ts
import { UserRepository, User } from '../types/repository';
import { sql } from '../db'; // Assume a standard SQL driver

export class PostgresUserRepository implements UserRepository {
  async getUserById(id: string): Promise<User | null> {
    const rows = await sqlSELECT * FROM users WHERE id = ${id};
    return rows[0] || null;
  }

  async updateUserStatus(id: string, status: string): Promise<User> {
    const rows = await sqlUPDATE users SET status = ${status} WHERE id = ${id} RETURNING *;
    return rows[0];
  }
}

Now, let's create our test double:

// repositories/mockUserRepository.ts
import { UserRepository, User } from '../types/repository';

export class MockUserRepository implements UserRepository {
  private users: Map<string, User> = new Map([
    ['123', { id: '123', name: 'Chloe', email: '[email protected]', status: 'active' }]
  ]);

  async getUserById(id: string): Promise<User | null> {
    return this.users.get(id) || null;
  }

  async updateUserStatus(id: string, status: 'active' | 'pending'): Promise<User> {
    const user = this.users.get(id);
    if (!user) throw new Error('User not found');
    
    const updatedUser = { ...user, status };
    this.users.set(id, updatedUser);
    return updatedUser;
  }
}

Step 3: Build the API Route with Dependency Injection

Instead of instantiating the database inside our route handler, we will pass the UserRepository into our controller. This is called Constructor Injection.

// controllers/userController.ts
import { UserRepository } from '../types/repository';

export class UserController {
  // The dependency is injected via the constructor!
  constructor(private userRepository: UserRepository) {}

  async handleGetStatus(req: any, res: any) {
    try {
      const userId = req.params.id;
      const user = await this.userRepository.getUserById(userId);
      
      if (!user) {
        return res.status(404).json({ error: 'User not found' });
      }
      
      // Structured API response
      return res.status(200).json({ 
        data: { id: user.id, status: user.status } 
      });
    } catch (error) {
      return res.status(500).json({ error: 'Internal Server Error' });
    }
  }
}

Why this code is better: Notice how clean this is? The controller has zero knowledge of SQL, connection strings, or database pooling. It only knows about the UserRepository interface. This is the hallmark of professional, scalable code.

Step 4: Wire it up and Write the Unit Test

In your production application (e.g., your Express setup or Next.js API route), you wire the real dependencies together:

// server.ts (Production Wiring)
import { PostgresUserRepository } from './repositories/postgresUserRepository';
import { UserController } from './controllers/userController';

const userRepository = new PostgresUserRepository();
const userController = new UserController(userRepository);

// Express route example
app.get('/api/users/:id/status', (req, res) => userController.handleGetStatus(req, res));

But here is where the magic happens. Look at how beautifully we can test this logic without spinning up a Docker container for PostgreSQL:

// controllers/userController.test.ts
import { describe, it, expect } from 'vitest';
import { UserController } from './userController';
import { MockUserRepository } from '../repositories/mockUserRepository';

describe('UserController', () => {
  it('should return user status for a valid user', async () => {
    // 1. Arrange: Inject the mock
    const mockRepo = new MockUserRepository();
    const controller = new UserController(mockRepo);
    
    const req = { params: { id: '123' } };
    const res = {
      status: function(code: number) { 
        this.statusCode = code; 
        return this; 
      },
      json: function(data: any) {
        this.body = data;
        return this;
      }
    };

    // 2. Act
    await controller.handleGetStatus(req, res as any);

    // 3. Assert
    expect(res.statusCode).toBe(200);
    expect(res.body).toEqual({
      data: { id: '123', status: 'active' }
    });
  });
});

Verification

To confirm this works, run your test suite using npx vitest. You should see your tests execute in roughly 2-5 milliseconds.

Compare that to an E2E UI scraping test that has to boot a browser, navigate to a page, wait for the DOM to render, and parse a

to check a user's status. The structured API approach isn't just slightly faster—it's an entirely different universe of performance.

Troubleshooting

Issue: TypeScript complains that my Mock doesn't implement the interface.
Fix: Ensure your mock method signatures exactly match the interface. If the interface returns Promise, your mock cannot return just User | null. It must be wrapped in a Promise (or use the async keyword).

Issue: My tests are failing with "Cannot read properties of undefined (reading 'status')".
Fix: Check your mock response objects. If you are mocking Express res.status().json(), remember that status() must return this to allow chaining.

Performance vs DX

Let's evaluate what we've accomplished from both critical angles:

The Performance Win

By exposing a structured API instead of forcing clients to scrape a UI, we eliminate the rendering pipeline entirely. There is no HTML parsing, no CSSOM construction, and no JavaScript execution overhead on the client side. As benchmarks show, structured data retrieval is up to 45x cheaper in compute. You are saving server bandwidth, reducing database connection times (as API calls are resolved faster), and drastically lowering your cloud bill.

The DX (Developer Experience) Win

This is where we really get to go home early. Before Dependency Injection, testing this endpoint meant setting up a test database, running migrations, seeding data, running the test, and tearing it down. It was slow and flaky. Now? Your test suite runs entirely in memory. It executes in milliseconds. You get instant feedback in your terminal while you type. Your components and controllers are lean, focused, and completely decoupled from infrastructure.

What You Built

You successfully architected a structured API route using Dependency Injection. You defined a strict interface seam, implemented both production and mock repositories, and wrote a blazing-fast unit test that proves your business logic works without touching a real database.

Your controllers are way leaner now, your tests are completely bulletproof, and you've saved your consumers from the nightmare of UI scraping. Happy Coding! ✨


FAQ

Why shouldn't I just use Jest/Vitest mocking features like vi.mock() instead of Dependency Injection? While module mocking (vi.mock()) works, it tightly couples your tests to the file structure of your application. If you move or rename the database file, your tests break. Dependency Injection relies on interfaces, meaning your tests are completely decoupled from file paths and implementation details, resulting in much more resilient test suites.
Is Constructor Injection the only way to do DI in Node? No! You can also use Parameter Injection (passing the dependency directly into the handler method) or Setter Injection. However, Constructor Injection is generally considered the cleanest approach because it guarantees the object is fully initialized with its dependencies before it can be used.
How does this pattern scale when a controller needs 10 different repositories? If a controller needs 10 dependencies, that is usually a "code smell" indicating the controller is doing too much (violating the Single Responsibility Principle). You should break the controller down into smaller, domain-specific services. Alternatively, for large applications, you can use DI Container libraries like Awilix or InversifyJS to automate the injection process.
Does Dependency Injection impact runtime performance? The performance impact of passing an object reference via a constructor is microscopically negligible. The architectural benefits, maintainability, and the ability to easily swap out heavy dependencies for lighter ones far outweigh any theoretical nanosecond cost of instantiation.

📚 Sources

Related Posts

⚙️ Dev & Engineering
Building a Resilient Customer Support Architecture ✨
Apr 29, 2026
⚙️ Dev & Engineering
React Render Optimization: DX-First Guide to Speed
Apr 25, 2026
⚙️ Dev & Engineering
Modern App Security: File Uploads & GCP Zero-Trust
Apr 24, 2026