Dependency Injection
Bunty’s service layer is powered by a scoped dependency injection (DI) container, designed for modular monolith architectures. It provides clean service composition, automatic dependency wiring, and full isolation between multiple application instances.
Overview
Every application created through createApp() holds its own DI container. This container manages all registered services and their lifecycles.
import { createApp } from '@bunty/http';
const app1 = createApp();
const app2 = createApp();
// app1 and app2 have independent DI containers
By default, services are singletons within their container scope—each app instance gets its own isolated singletons.
This ensures:
- ✅ No shared state across applications
- ✅ Safe for multi-tenant setups
- ✅ Ideal for testing environments
- ✅ Perfect for multi-service deployments
Defining a Service
Services are declared using the @Injectable() decorator. This tells Bunty to register the class with the current container and manage its lifecycle automatically.
import { Injectable } from '@bunty/common';
@Injectable()
class UserService {
constructor(public db: Database) {} // auto-injected dependency
async getUser(id: number) {
return this.db
.createQueryBuilder(usersTable)
.where('id', '=', id)
.findOne();
}
async createUser(data: CreateUserDto) {
return this.db
.createQueryBuilder(usersTable)
.insert(data);
}
async deleteUser(id: number) {
return this.db
.createQueryBuilder(usersTable)
.where('id', '=', id)
.delete();
}
}
Each injectable service can depend on other services, which the container resolves automatically.
Injecting Dependencies
Bunty supports three injection styles for maximum flexibility:
1. Constructor Injection
Dependencies are resolved when the service is instantiated. This is the recommended approach for most services.
@Injectable()
class OrderService {
constructor(
public db: Database,
public users: UserService,
public email: EmailService,
public logger: LoggerService
) {}
async createOrder(userId: number, items: OrderItem[]) {
// All dependencies are available
const user = await this.users.getUser(userId);
if (!user) {
throw new Error('User not found');
}
const order = await this.db.createQueryBuilder(ordersTable).insert({
userId,
items: JSON.stringify(items),
total: items.reduce((sum, item) => sum + item.price, 0)
});
await this.email.sendOrderConfirmation(user.email, order);
this.logger.info('Order created', { orderId: order.id, userId });
return order;
}
}
2. Property Injection
Use @Inject() to explicitly mark dependencies. Useful for:
- Circular dependency resolution
- Late binding scenarios
- Optional dependencies
import { Injectable, Inject } from '@bunty/common';
@Injectable()
class AuthService {
@Inject(UserService)
public users: UserService;
@Inject(TokenService)
public tokens: TokenService;
@Inject(CacheService)
public cache?: CacheService; // optional dependency
async login(email: string, password: string) {
const user = await this.users.findByEmail(email);
if (!user || !await this.verifyPassword(password, user.password)) {
throw new UnauthorizedError('Invalid credentials');
}
const token = await this.tokens.generate(user.id);
// Use optional cache if available
if (this.cache) {
await this.cache.set(`auth:${user.id}`, token);
}
return { user, token };
}
}
3. Runtime Injection
Use inject() inside any method or function to pull a dependency dynamically.
import { inject } from '@bunty/common';
@Injectable()
class PaymentService {
async processPayment(orderId: number, method: PaymentMethod) {
// Dynamically inject based on payment method
let provider;
if (method === 'stripe') {
provider = inject(StripeProvider);
} else if (method === 'paypal') {
provider = inject(PayPalProvider);
} else {
provider = inject(BankProvider);
}
return await provider.charge(orderId);
}
}
// Use in route handlers
app.post('/api/payments', async (req, res) => {
const auth = inject(AuthService);
const user = await auth.getCurrentUser(req);
const payment = inject(PaymentService);
const result = await payment.processPayment(req.body.orderId, req.body.method);
return res.json(result);
});
Service Lifetimes
By default, services are singletons within their application container, but you can change this behavior using decorator options.
Singleton (Default)
One instance per app container. Best for stateless services.
@Injectable() // same as @Injectable({ scope: 'singleton' })
class ConfigService {
private config: Config;
constructor() {
this.config = loadConfig();
}
get(key: string) {
return this.config[key];
}
}
Transient
New instance on each injection. Best for stateful operations.
@Injectable({ scope: 'transient' })
class RequestLogger {
private startTime = Date.now();
private logs: string[] = [];
log(message: string) {
this.logs.push(`[${Date.now() - this.startTime}ms] ${message}`);
}
flush() {
console.log(this.logs.join('\n'));
this.logs = [];
}
}
Scoped
New instance per request or execution context. Best for per-request state.
@Injectable({ scope: 'scoped' })
class RequestContext {
public userId?: number;
public requestId: string;
public startTime: number;
constructor() {
this.requestId = generateUUID();
this.startTime = Date.now();
}
setUser(userId: number) {
this.userId = userId;
}
getElapsedTime() {
return Date.now() - this.startTime;
}
}
Container Hierarchy
Each createApp() call creates a root container for that application. Nested containers (child scopes) can be created for request or session isolation.
Root Container
const app = createApp();
// Root container manages app-level singletons
app.register(DatabaseService);
app.register(ConfigService);
app.register(CacheService);
Request Scopes
Create child scopes for request isolation:
app.use(async (req, res, next) => {
// Create request scope
const requestScope = app.container.createScope();
// Register request-specific services
requestScope.register(RequestContext, {
value: new RequestContext(req)
});
// All services resolved in this scope will share the RequestContext
req.container = requestScope;
await next();
// Cleanup scope after request
requestScope.dispose();
});
Nested Scopes Example
const app = createApp();
// App-level singleton
@Injectable()
class DatabaseService { }
// Request-scoped service
@Injectable({ scope: 'scoped' })
class RequestLogger { }
app.use(async (req, res, next) => {
const scope = app.container.createScope();
const db = scope.resolve(DatabaseService); // Same instance for entire app
const logger1 = scope.resolve(RequestLogger); // New instance for this request
const logger2 = scope.resolve(RequestLogger); // Same as logger1 (scoped to request)
console.log(logger1 === logger2); // true (same scope)
await next();
});
Service Registration
Automatic Registration
Services with @Injectable() are automatically registered when imported:
// services/UserService.ts
@Injectable()
export class UserService { }
// app.ts
import { UserService } from './services/UserService';
// UserService is now registered automatically
Manual Registration
Register services explicitly for more control:
const app = createApp();
// Register class
app.container.register(UserService);
// Register with options
app.container.register(CacheService, {
scope: 'singleton'
});
// Register value
app.container.register('API_KEY', {
value: process.env.API_KEY
});
// Register factory
app.container.register(DatabaseService, {
factory: () => new DatabaseService(config.db.url)
});
Interface-based Registration
Register implementations for interfaces:
interface IEmailService {
send(to: string, subject: string, body: string): Promise<void>;
}
@Injectable()
class SendGridEmailService implements IEmailService {
async send(to: string, subject: string, body: string) {
// SendGrid implementation
}
}
@Injectable()
class SESEmailService implements IEmailService {
async send(to: string, subject: string, body: string) {
// AWS SES implementation
}
}
// Register based on environment
if (env('EMAIL_PROVIDER') === 'sendgrid') {
app.container.register('IEmailService', SendGridEmailService);
} else {
app.container.register('IEmailService', SESEmailService);
}
// Inject by interface
@Injectable()
class NotificationService {
constructor(
@Inject('IEmailService') private email: IEmailService
) {}
}
Testing with DI
Dependency injection makes testing trivial:
import { describe, test, expect } from 'bun:test';
import { Container } from '@bunty/common';
describe('UserService', () => {
test('should create user', async () => {
// Create test container
const container = new Container();
// Mock database
const mockDb = {
createQueryBuilder: () => ({
insert: async (data) => ({ id: 1, ...data })
})
};
// Register mocks
container.register(Database, { value: mockDb });
container.register(UserService);
// Resolve service with mocked dependencies
const userService = container.resolve(UserService);
// Test
const user = await userService.createUser({
email: 'test@example.com',
username: 'testuser'
});
expect(user.id).toBe(1);
expect(user.email).toBe('test@example.com');
});
});
Why Scoped DI?
Isolation
No accidental cross-app leaks of state or configuration.
const app1 = createApp();
const app2 = createApp();
// Each app has its own DatabaseService instance
const db1 = app1.resolve(DatabaseService); // Connected to DB1
const db2 = app2.resolve(DatabaseService); // Connected to DB2
console.log(db1 === db2); // false - completely isolated
Predictability
Each service’s lifecycle is deterministic within its container.
@Injectable() // Singleton by default
class ConfigService {
private config = loadConfig();
}
const config1 = app.resolve(ConfigService);
const config2 = app.resolve(ConfigService);
console.log(config1 === config2); // true - same instance
Flexibility
Services can use decorators for static wiring or inject() for dynamic resolution.
// Static wiring with decorators
@Injectable()
class StaticService {
constructor(public db: Database) {}
}
// Dynamic resolution with inject()
function dynamicFunction() {
const db = inject(Database);
return db.query('SELECT 1');
}
Maintainability
Encourages clean architecture boundaries and easy testing.
// Clear dependency contracts
@Injectable()
class OrderService {
constructor(
public users: UserService, // User domain
public products: ProductService, // Product domain
public payments: PaymentService, // Payment domain
public email: EmailService // Notification domain
) {
// Dependencies are explicit and visible
}
}
Performance
Singleton caching per app container avoids redundant instantiation.
@Injectable() // Instantiated once per app
class DatabaseService {
private pool = createConnectionPool();
constructor() {
console.log('Database service created');
}
}
// Only logs once
const db1 = app.resolve(DatabaseService); // "Database service created"
const db2 = app.resolve(DatabaseService); // (no log - reused)
Advanced Patterns
Conditional Injection
@Injectable()
class NotificationService {
private provider: EmailProvider;
constructor() {
if (env('EMAIL_PROVIDER') === 'sendgrid') {
this.provider = inject(SendGridProvider);
} else {
this.provider = inject(SESProvider);
}
}
}
Lazy Injection
@Injectable()
class HeavyService {
private _processor?: ProcessorService;
get processor() {
if (!this._processor) {
this._processor = inject(ProcessorService);
}
return this._processor;
}
async process(data: any) {
// Processor only injected when actually needed
return this.processor.run(data);
}
}
Multi-Provider Pattern
interface Plugin {
name: string;
execute(): void;
}
@Injectable()
class PluginManager {
private plugins: Plugin[] = [];
registerPlugin(plugin: Plugin) {
this.plugins.push(plugin);
}
executeAll() {
this.plugins.forEach(p => p.execute());
}
}
// Register multiple implementations
app.container.resolve(PluginManager).registerPlugin(inject(AnalyticsPlugin));
app.container.resolve(PluginManager).registerPlugin(inject(LoggingPlugin));
app.container.resolve(PluginManager).registerPlugin(inject(CachePlugin));
Best Practices
✅ Do
- Use constructor injection as the default
- Keep services focused on one responsibility
- Inject interfaces, not concrete classes (when polymorphism needed)
- Use scoped services for request-specific state
- Mock dependencies in tests
- Register services at app startup
- Use
@Injectable()for all services
❌ Don’t
- Create circular dependencies
- Inject too many dependencies (>5 suggests design issue)
- Store request state in singleton services
- Use service locator pattern (prefer explicit injection)
- Manually instantiate injectable services
- Share container instances across apps
- Forget to dispose of scoped containers
🎯 DI System Philosophy
Bunty’s DI system combines annotation-based injection with scoped containers. Each application has an independent service registry and lifecycle management system. Developers write services as plain classes with clear dependency contracts, while the framework handles construction, injection, and scoping automatically. This design enables modular, testable, and long-lived architectures that scale cleanly across multiple applications.
Summary
Bunty’s dependency injection system provides:
- Scoped containers - Each app has isolated services
- Multiple injection styles - Constructor, property, and runtime injection
- Flexible lifetimes - Singleton, transient, and scoped services
- Container hierarchy - Root and nested scopes for isolation
- Easy testing - Mock dependencies with test containers
- Clean architecture - Explicit dependencies and clear boundaries
Build maintainable, testable services with confidence.
Next Steps
- Learn about Core Concepts and architectural patterns
- Build services for your HTTP API
- Set up Database access