Simple DI: Framework-less Inversion of Control in TypeScript
I’ve seen the same thing happen at every startup I’ve worked at. The service starts small. A few files, clean imports, everyone knows where everything lives. Then the team grows, velocity picks up, and six months later you’re staring at a test file that imports half the codebase just to test one function.
This is what happens when a team moves fast and the codebase grows faster than the architecture. Functions get pulled into wherever they’re needed. Files end up in directories that made sense three months ago. And testing gets painful, because every function reaches deep into the dependency tree.
I always wait to fix this, but once the time to fix is shorter than reviews and cleanup, I introduce the inversion of control pattern. That’s where DI comes in.
This isn’t NestJS-style DI with decorators and reflection and a framework you have to buy into wholesale. Just factory functions returning plain objects. I’ve been calling it Simple DI.
The Spaghetti
Here’s how services usually start. You’ve got a user service that directly imports what it needs:
import { db } from "./db"
import { logger } from "./logger"
import { sendEmail } from "./email"
export const getUser = async (id: string) => {
logger.info("fetching user", { id })
return db.users.findOne({ id })
}
export const createUser = async (data: NewUser) => {
const user = await db.users.insert(data)
logger.info("user created", { id: user.id })
await sendEmail(user.email, "Welcome!")
return user
} This is fine when the service is small. The problem shows up when you try to test it:
// Now we need to mock the database, the logger, AND the email service
// just to test that createUser returns the right shape
jest.mock("./db")
jest.mock("./logger")
jest.mock("./email")
test("createUser returns a user", async () => {
// ...setup all three mocks...
const user = await createUser({ name: "Alice", email: "[email protected]" })
expect(user.name).toBe("Alice")
}) Jest’s jest.mock overrides the import at the module level. It works, but it’s a code smell. You’re not testing your function in isolation, you’re testing it welded to its dependencies. And because the mocks are module-level, every test in the file shares them. Want different mock behavior per test? Now you’re fighting Jest instead of writing tests.
Worse, engineers start testing the dependencies themselves inside these test files. Does the email get sent? That’s a concern of the email service, not the user service. But when everything is wired together through direct imports, the lines blur. You end up with tests that are slow, brittle, and testing the wrong things.
Factory Functions
There’s a common pattern to fix this: instead of importing dependencies directly, take them as arguments.
type UserServiceDeps = {
db: Database
logger: Logger
emailService: EmailService
}
export const createUserService = (deps: UserServiceDeps) => ({
getUser: async (id: string) => {
deps.logger.info("fetching user", { id })
return deps.db.users.findOne({ id })
},
createUser: async (data: NewUser) => {
const user = await deps.db.users.insert(data)
deps.logger.info("user created", { id: user.id })
await deps.emailService.sendWelcome(user.email)
return user
},
}) test("createUser returns a user", async () => {
const fakeDb = {
users: {
insert: async (data: NewUser) => ({ id: "123", ...data }),
},
}
const fakeLogger = { info: () => {} }
const fakeEmail = { sendWelcome: async () => {} }
const userService = createUserService({
db: fakeDb as Database,
logger: fakeLogger as Logger,
emailService: fakeEmail as EmailService,
})
const user = await userService.createUser({ name: "Alice", email: "[email protected]" })
expect(user.name).toBe("Alice")
}) No jest.mock. No module-level overrides. You pass in exactly what you want, and the test only exercises the logic inside createUserService. If you want to test that emails get sent correctly, that’s a test for the email service, not this one.
This is just functions and objects. No decorators, no reflect-metadata, no framework. It’s the same pattern that Fastify, Hono, and Drizzle use.
One of the best parts of this is you can adopt it incrementally. When working in a fast-moving codebase this is critical. My constant goal is to not slow anyone down, so these refactors have to be gradual. Wrapping this in a factory gives us a few easy wins:
- Free IoC and dependency injection
- Clear namespacing and organization of functions
- Much easier testing
Wiring It Up
Once your services are factory functions, you need a way to wire them together. I wrote a factory function that handles this: it takes a lazy initializer, returns a Proxy that stands in for the real service, and registers everything in a global registry for instantiation later.
const registry: Array<{ init: () => void; instance: () => object | null }> = []
const hasOnInit = (o: object | null): o is { onInit: () => void } =>
typeof (o as Record<string, unknown>)?.onInit === "function"
const create = <T extends object>(init: () => T): T => {
let instance: T | null = null
const proxy = new Proxy({} as T, {
get: (_, prop) => {
if (!instance) throw new Error(`Accessed before instantiation: ${String(prop)}`)
const value = instance[prop as keyof T]
return typeof value === "function" ? value.bind(instance) : value
},
})
registry.push({ init: () => { instance = init() }, instance: () => instance })
return proxy as T
}
export const factory = Object.assign(create, {
instantiateAll: () => registry.forEach((e) => e.init()),
initializeAll: () => registry.forEach((e) => {
const inst = e.instance()
if (hasOnInit(inst)) inst.onInit()
}),
}) Each service file uses factory() to wrap its creation and declare dependencies through normal imports:
import { factory } from "./factory"
import { emailService } from "./emailService"
const createUserService = ...
export const userService = factory(() =>
createUserService({ db, logger, emailService })
) Then at your app’s entry point, two calls boot everything up:
import { factory } from "./factory"
factory.instantiateAll()
factory.initializeAll() This is where Inversion of Control actually happens. The services declare what they need through imports, and factory() ensures everything exists before anything runs. You can swap implementations by changing what gets imported.
You lose the hand-written dependency graph that a manual composition root gives you, but factory can reconstruct it. I added a printDependencies() method that logs the full graph at startup, which turned out to be more useful than a static file because it’s always accurate.
When I introduced this at a startup, it immediately surfaced problems we didn’t know we had. Files that imported each other in circles. Services that depended on things they shouldn’t have. Logic scattered across three directories that should have been one service. The dependency graph made all of this visible because you literally can’t wire something up without knowing what it needs.
The Circular Problem
Eventually, everyone hits circular dependencies. The user service needs to send notifications, and the notification service needs to look up users. In a direct-import codebase, circular deps hide. Node resolves them silently (usually, but not always which is extra painful), and you don’t notice until something breaks at runtime or your build times balloon.
If you’re able to, solve this by restructuring the cycle. Often this is a sign that the logic wants to be organized in a different way. But sometimes the domain really does have bidirectional relationships. Users send notifications. Notifications reference users.
With factory(), circular deps just work. Both files export a Proxy at module load time, so Node’s import resolution succeeds even with cycles:
import { factory } from "./factory"
import { notificationService } from "./notificationService"
export const userService = factory(() =>
createUserService({ db, logger, notificationService })
) import { factory } from "./factory"
import { userService } from "./userService"
export const notificationService = factory(() =>
createNotificationService({ logger, userService })
) No special handling needed. Each file imports the other’s proxy, and instantiateAll() fills them all in. The cycle is explicit in the imports, visible in code review, and resolved cleanly at startup.
The one restriction: you can’t use dependencies during construction. If your initializer tries to call notificationService.send(...) while building the object, it’ll hit an uninstantiated proxy. For setup logic that needs deps, services can implement an onInit method that initializeAll() calls after every service is instantiated:
export const createUserService = (deps: UserServiceDeps) => ({
getUser: async (id: string) => { ... },
createUser: async (data: NewUser) => { ... },
onInit: () => {
// Safe to use deps here - everything is instantiated
},
})
This restriction turned out to be a positive constraint. It pushed us toward more functional code with fewer side effects at construction time. Services became pure data and functions, with initialization as a separate, explicit step when needed.
Selling It to Your Team
The hardest part of introducing this wasn’t the pattern itself. It was convincing people it was worth changing how they write services. What worked: converting one well-known, painful-to-test service as a proof of concept. Once engineers saw the test file go from 200 lines of mock setup to 40 lines of actual tests, they started converting their own services without being asked.
Simple DI is just functions, objects, and one Proxy. No framework, magic, decorators, or build steps. Just wrapping well-organized code.
This post includes an agent skill — a structured prompt that teaches coding agents to implement this pattern.