·7 min read

From “Why Bother?” to “I'll Never Write It the Other Way”: DI, Repository Pattern & Adapter Layers in TypeScript

TypeScriptPatternsBackend

When I first joined a production TypeScript backend that used dependency injection, repository pattern, and a strict adapter layer, my honest reaction was: why is there so much ceremony for what's basically just fetching data?

Six months later I'm the one pushing for these patterns on new projects. Here's what changed my mind.


The “Normal TypeScript” Way

Most TypeScript tutorials and early-stage codebases write services like this:

import pool from '../db'
import { sendWelcomeEmail } from '../email'

export async function createUser(email: string, name: string) {
    const result = await pool.query(
        'INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *',
        [email, name]
    )
    const user = result.rows[0]
    await sendWelcomeEmail(user.email, user.name)
    return user
}

This works. It's simple. You can read it top to bottom and understand it immediately. So what's the problem?

The problems show up later:

  • You want to test createUser without hitting a real database
  • You want to swap Postgres for something else in staging
  • You want to reuse the email-sending logic from a different service
  • A new engineer adds another direct pool.query call three files away, slightly differently

None of these are fatal on a small project. On a codebase with 30+ entities and 5 engineers, they compound into a mess where the same concept is implemented four different ways and testing requires spinning up a real DB.


Pattern 1: Repository Pattern

The repository pattern is just one rule: all database access for an entity lives in one place.

// src/models/UserRepository.ts

interface IUserBuilder {
    email: string
    name: string
}

interface IUser {
    id: number
    email: string
    name: string
    createdAt: Date
}

class UserRepository {
    constructor(private readonly pool: Pool) {}

    async create(builder: IUserBuilder): Promise<IUser> {
        const result = await this.pool.query<IUser>(
            `INSERT INTO users (email, name)
             VALUES ($1, $2)
             RETURNING id, email, name, created_at AS "createdAt"`,
            [builder.email, builder.name]
        )
        return result.rows[0]
    }

    async findByEmail(email: string): Promise<IUser | null> {
        const result = await this.pool.query<IUser>(
            'SELECT id, email, name, created_at AS "createdAt" FROM users WHERE email = $1',
            [email]
        )
        return result.rows[0] ?? null
    }
}

Now UserRepository is the single source of truth for how users are read and written. Anyone touching user data goes through here.

Why this matters: when your users table gets a deleted_at column and you need soft-delete semantics everywhere, you change one file, not grep through 15 service files hoping you caught them all.


Pattern 2: Dependency Injection

This is the one that looked most like ceremony to me. Why not just import the repository directly?

import { userRepository } from '../models/UserRepository'

export async function createUser(email: string) {
    return userRepository.create({ email })
}

The problem is hidden in that import. Your service is now hardwired to one specific instance. You can't test it without the real database. You can't swap it. You can't even reason about what it depends on without reading the function body.

The DI version:

class UserService {
    constructor(
        private readonly userRepository: UserRepository,
        private readonly emailService: EmailService
    ) {}

    async createUser(email: string, name: string): Promise<IUser> {
        const user = await this.userRepository.create({ email, name })
        await this.emailService.sendWelcome(user.email, user.name)
        return user
    }
}

Now the dependencies are in the constructor signature. You can see exactly what this service needs without reading its implementation. And in tests:

const mockRepo = {
    create: jest.fn().mockResolvedValue({
        id: 1, email: 'a@b.com', name: 'Alice', createdAt: new Date()
    }),
    findByEmail: jest.fn(),
}
const mockEmail = { sendWelcome: jest.fn() }

const service = new UserService(mockRepo, mockEmail)
await service.createUser('a@b.com', 'Alice')

expect(mockEmail.sendWelcome).toHaveBeenCalledWith('a@b.com', 'Alice')

No database. No network. Instant test. You're testing logic, not infrastructure.

My initial objection was: “but I'd have to pass this thing through every layer.” That's true. But it forces you to be honest about what your code actually depends on. The hidden import was lying — pretending the function had no dependencies when it absolutely did.


Pattern 3: Adapter Layer (The One Nobody Talks About)

This one took me longest to appreciate. The idea: your database model and your API response are two different things, and you should never let them be the same.

Here's what I mean. Your DB returns:

// What the DB gives you
interface IUserRow {
    id: number           // internal integer
    email: string
    name: string
    created_at: Date
    deleted_at: Date | null
}

Your API should return:

// What the API exposes
interface ApiUser {
    id: string           // encoded external ID, not the raw integer
    email: string
    name: string
    joined_at: string    // ISO 8601 string, not a Date object
}

Without an adapter layer, you'd either expose the raw DB row (leaking internal IDs, wrong field names, wrong types) or scatter the transformation logic across handlers.

With an adapter:

// src/apiModels/adapters.ts

function encodeId(id: number): string {
    return Buffer.from(id.toString()).toString('base64')
}

export function toApiUser(user: IUserRow): ApiUser {
    return {
        id: encodeId(user.id),
        email: user.email,
        name: user.name,
        joined_at: user.created_at.toISOString(),
    }
}

Now your handler is clean:

app.get('/users/:id', async (req, res) => {
    const user = await userRepository.findById(id)
    if (!user) return res.status(404).json({ error: 'not_found' })
    return res.json(toApiUser(user))
})

Why this matters:

  • You can rename DB columns without touching API contracts
  • You can change how IDs are encoded without touching handlers
  • TypeScript enforces the shape at compile time — if you add a required field to ApiUser, every adapter immediately breaks until you handle it
  • The transformation logic is testable in isolation with just plain objects

The adapter is where you handle all the messy impedance mismatch between what the DB stores and what clients need. Doing it in one place means you can't accidentally expose a raw integer ID from one endpoint while another endpoint properly encodes it.


What Changed My Mind

The honest answer: I got burned by the alternative.

I've seen a codebase where there was no repository pattern — just pool.query calls scattered everywhere. When we needed to add soft-delete to one entity, it took two days of grepping and prayer. I've seen services with implicit database dependencies that were impossible to unit test, so nobody wrote tests, so regressions slid through.

These patterns feel like overhead until the moment they save you from a multi-day refactor or a subtle bug that only shows up in production.

The other thing I noticed: the patterns make code reviews much faster. When every service follows the same structure — constructor takes dependencies, methods return typed results, DB access goes through a repository — you can read an unfamiliar file and understand it in 60 seconds. You're not decoding unique snowflake architecture, you're just reading the business logic.


Starting Small

You don't need to adopt all three at once. The order I'd recommend:

  1. 1.Repository first. It's the lowest friction. Just move all your DB queries for an entity into one class. Immediate benefit: one place to look, one place to change.
  2. 2.Adapters second. Add them when you find yourself writing transformation logic in handlers. Extract it. Name it toApiSomething.
  3. 3.DI last. Once you have repositories and adapters, you'll naturally want to inject them rather than import them — especially once you write your first test and feel the pain of the alternative.

None of these require a framework. No inversify, no decorators, no magic. Constructor injection in plain TypeScript is enough to get 90% of the benefit.


The patterns aren't about being clever. They're about making the codebase boring to work in — in the best way.