Services
Dependency injection without the framework
Dependency injection without the framework. Define a service, use it anywhere, swap it out in tests.
The problem
Most apps have shared dependencies — a database connection, a logger, an HTTP client. The standard approaches all have drawbacks:
Global imports are the simplest, but you can't swap them in tests:
import { db } from "./database"
async function getUsers() {
return db.query("SELECT * FROM users") // hardcoded dependency
}
// In tests, there's no clean way to replace `db` without
// jest.mock() or similar approaches that break type safety.Constructor injection is testable, but you end up passing dependencies through every function call:
async function getUsers(db: Database, logger: Logger) {
logger.info("fetching users")
return db.query("SELECT * FROM users")
}
// Every caller needs to know about every dependency:
async function handleRequest(db: Database, logger: Logger, cache: Cache) {
const users = await getUsers(db, logger) // threading deps manually
// ...
}This gets worse as your app grows. Adding a new dependency to a low-level function means updating every function in the call chain.
DI frameworks (like NestJS) solve the threading problem but introduce their own complexity — decorators, containers, reflection, and runtime wiring.
The solution
Define.Service gives you the simplicity of globals with the testability of injection. It uses Node's AsyncLocalStorage under the hood, so dependencies flow through your code invisibly — no manual threading, no framework magic.
Define a service:
import { Define } from "within-ts"
class Database extends Define.Service("Database")<{
query: (sql: string) => Promise<Row[]>
}>() {
static default() {
return new PgDatabase()
}
}Use it anywhere — just new it:
async function getUsers() {
const db = new Database()
return db.query("SELECT * FROM users")
}No imports of the concrete implementation. No dependency threading. Just new Database() wherever you need it.
How it works
The class returned by Define.Service does not construct anything in the traditional sense. new Database() reads from AsyncLocalStorage and returns the current service instance — effectively a typed read from ambient context.
On first access, if nothing has been explicitly set, the static default() method is called to create the initial instance. This happens lazily — the factory only runs when you first new the service.
Testing
Override any service for the duration of a test:
const mockDb = { query: async () => [{ id: "1", name: "Test User" }] }
Database.run(mockDb, async () => {
const users = await getUsers()
// users came from the mock, not the real database
expect(users).toEqual([{ id: "1", name: "Test User" }])
})
// outside .run(), back to the real implementationNo jest.mock, no dependency containers, no function signature changes. The override is scoped to the callback and propagates through any depth of async calls.
Nested overrides and async isolation
Overrides nest cleanly. An inner .run() takes precedence, and the outer one resumes when it exits:
Database.run(prodDb, async () => {
// prodDb active here
Database.run(stagingDb, async () => {
// stagingDb active here
})
// prodDb active again
})Concurrent async contexts are fully isolated. Two parallel requests each get their own service instances, even though they're running in the same process:
await Promise.all([
Database.run(tenantA, () => handleRequest()),
Database.run(tenantB, () => handleRequest()),
])
// tenantA and tenantB never interfere with each otherExplicit initialization
Most services should have a static default(). But sometimes you need manual control — maybe the service requires async setup, or you want to configure it at startup:
// No static default — must be initialized explicitly
class Database extends Define.Service("Database")<{
query: (sql: string) => Promise<Row[]>
}>() {}
// At app startup
const db = await connectToDatabase(config.dbUrl)
Database.enterWith(db)
// Now new Database() works everywhereenterWith sets the value for the current async context. Call it at the top level and it applies everywhere. Call it inside a request handler and it only applies to that request.