📦
Bunty

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

Have questions? Join our Discord community
Found an issue? Edit this page on GitHub