street ships with a lightweight IoC (Inversion of Control) container that resolves constructor dependencies automatically using TypeScript’s emitted decorator metadata. No configuration files, no token symbols, no factory functions for the common case.
Without IoC, a controller directly creates its dependencies:
1
2
3
4
// Tightly coupled — hard to test, hard to swap
class UserController {
private service = new UserService(new UserRepository(new PgPool(...)));
}
With IoC, dependencies are declared and injected:
1
2
3
4
5
// Loosely coupled — UserController doesn't know how UserService is built
@Injectable()
class UserController {
constructor(private readonly service: UserService) {}
}
The container handles the construction graph. This means:
container.register(UserService, mockService)street only supports constructor injection (not property injection or method injection). This is a deliberate constraint:
new UserController(mockService)@Injectable() decoratorMark any class as injectable to enable automatic resolution:
1
2
3
4
5
6
7
8
9
import { Injectable } from '../core/container.js';
@Injectable()
export class PaymentService {
charge(amount: number): boolean {
// ...
return true;
}
}
@Injectable() calls Reflect.defineMetadata('street:injectable', true, target). While the container does not strictly require this mark for resolution (TypeScript metadata is emitted regardless), it serves as documentation and allows future tools to detect injectable classes.
The global container is a singleton accessible via container:
1
import { container } from '../core/container.js';
container.resolve<T>(ctor)Resolves a class, constructing it and all its transitive dependencies:
1
const service = container.resolve(UserService);
Resolution steps:
CircularDependencyErrordesign:paramtypes metadata from TypeScriptcontainer.register<T>(ctor, instance)Register a pre-built instance. Used for:
AppConfig, PgPool)1
2
3
4
5
6
7
// Register configured instances
container.register(AppConfig, config);
container.register(PgPool, pool);
// Register a mock for testing
const mockPool = { query: async () => ({ rows: [], command: 'SELECT', rowCount: 0 }) };
container.register(PgPool, mockPool as unknown as PgPool);
container.has(ctor)Check if a class has been resolved or registered:
1
2
3
if (!container.has(PgPool)) {
throw new Error('PgPool must be registered before services');
}
container.reset()Clear all registrations. Use in tests to isolate each suite:
1
2
3
beforeEach(() => {
container.reset();
});
Given this dependency graph:
1
2
3
4
5
UserController
└── UserService
└── UserRepository
└── PgPool ← registered externally
└── AppConfig ← registered externally
Resolution of UserController proceeds as follows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 1. main.ts registers root-level singletons
container.register(AppConfig, config);
container.register(PgPool, pool);
// 2. Framework resolves UserController
app.registerController(UserController);
// → container.resolve(UserController)
// → reads design:paramtypes: [UserService]
// → container.resolve(UserService)
// → reads design:paramtypes: [UserRepository, AppConfig]
// → container.resolve(UserRepository)
// → reads design:paramtypes: [PgPool]
// → container.resolve(PgPool) → returns registered instance
// → new UserRepository(pool)
// → stores singleton
// → container.resolve(AppConfig) → returns registered instance
// → new UserService(repo, config)
// → stores singleton
// → new UserController(userService)
// → stores singleton
Every class is instantiated exactly once.
The container tracks the resolution chain. If a class appears twice in the chain, it throws immediately:
1
2
3
4
5
6
7
8
9
10
11
12
13
@Injectable()
class A {
constructor(private b: B) {}
}
@Injectable()
class B {
constructor(private a: A) {}
}
container.resolve(A);
// Error: Circular dependency detected while resolving: A.
// Resolution chain: A -> B -> A
This surfaces the problem at startup rather than causing a stack overflow at request time.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// src/database/pool.ts (registered manually in main.ts)
export class PgPool { /* ... */ }
// src/services/product.repository.ts
import { Injectable } from '../core/container.js';
import { PgPool } from '../database/pool.js';
import { StreetPostgresRepository } from '../database/repository.js';
@Injectable()
export class ProductRepository extends StreetPostgresRepository<Product> {
protected readonly tableName = 'products';
constructor(pool: PgPool) {
super(pool);
}
protected mapRow(row: Record<string, string | null>): Product {
return {
id: row['id'] ?? '',
name: row['name'] ?? '',
price: parseFloat(row['price'] ?? '0'),
createdAt: row['created_at'] ?? '',
};
}
}
// src/services/product.service.ts
import { Injectable } from '../core/container.js';
import { ProductRepository } from './product.repository.js';
import { AppConfig } from '../config/index.js';
@Injectable()
export class ProductService {
constructor(
private readonly repo: ProductRepository,
private readonly config: AppConfig,
) {}
async findAll(): Promise<Product[]> {
return this.repo.findAll(100, 0);
}
}
// src/controllers/product.controller.ts
import { Injectable } from '../core/container.js';
import { Controller, Get } from '../core/decorators.js';
import type { StreetContext } from '../core/context.js';
import { ProductService } from '../services/product.service.js';
@Injectable()
@Controller('/api/products')
export class ProductController {
constructor(private readonly products: ProductService) {}
@Get('/')
async list(ctx: StreetContext): Promise<void> {
const items = await this.products.findAll();
ctx.json({ items });
}
}
Register in main.ts:
1
2
3
4
5
6
// PgPool and AppConfig registered first (they have no resolvable deps)
container.register(AppConfig, config);
container.register(PgPool, pool);
// ProductController resolved automatically — pulls in ProductService → ProductRepository → PgPool
app.registerController(ProductController);
For cases where you need to resolve a service outside a controller:
1
2
3
4
5
6
// In a CLI command, webhook handler, or background job
import { container } from '../core/container.js';
import { UserService } from '../services/user.service.js';
const userService = container.resolve(UserService);
const user = await userService.findById('abc-123');
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { container } from '../src/core/container.js';
import { UserService } from '../src/services/user.service.js';
describe('UserService', () => {
beforeEach(() => {
container.reset();
// Register mocks
container.register(AppConfig, {
jwtSecret: 'test-secret-at-least-32-chars-here!!',
} as AppConfig);
container.register(PgPool, {
query: async (sql: string) => {
if (sql.includes('SELECT')) return { rows: [], command: 'SELECT', rowCount: 0 };
return { rows: [{ id: 'uuid-1', email: 'a@b.com', name: 'Alice' }], command: 'INSERT', rowCount: 1 };
},
transaction: async (fn: Function) => fn({ query: async () => ({ rows: [], command: '', rowCount: 0 }) }),
} as unknown as PgPool);
});
it('register creates a user', async () => {
const service = container.resolve(UserService);
const user = await service.register({
email: 'test@example.com',
name: 'Test',
password: 'password123',
});
assert.ok(user.id);
});
});