OneBun Architecture
Overview
OneBun is built on three pillars:
- NestJS-inspired DI - Decorators for modules, controllers, services
- Effect.js - Type-safe side effect management internally
- Bun.js - Fast runtime with native TypeScript support
Component Hierarchy
OneBunApplication
│
├── OneBunModule (Root Module)
│ ├── Controllers[]
│ │ └── Routes[] (from decorators)
│ ├── Services[] (Providers)
│ └── Child Modules[]
│ └── (recursive structure)
│
├── Logger (SyncLogger)
├── ConfigService
├── MetricsService (optional)
└── TraceService (optional)Dependency Injection System
How DI Works
- Service Registration:
@Service()decorator registers class with Effect.js Context tag - Module Assembly:
@Module()collects controllers and providers - Dependency Resolution: Framework analyzes constructor parameters
- Instance Creation: Services created in dependency order, then controllers
DI Resolution Flow
// 1. Service is decorated
@Service()
export class UserService extends BaseService {
// Dependencies are injected via constructor
// Logger and config are injected automatically
constructor(private cacheService: CacheService) {
super();
}
}
// 2. Module declares dependencies
@Module({
imports: [CacheModule], // provides CacheService
providers: [UserService],
controllers: [UserController],
})
export class UserModule {}
// 3. At startup, framework:
// a. Creates CacheService first (from imported module)
// b. Sets ambient init context (logger + config)
// c. Creates UserService with CacheService injected
// (config and logger available in constructor after super())
// d. Clears init context
// e. Creates UserController with UserService injectedAuto-Detection Algorithm
The framework uses multiple strategies to detect dependencies:
// Priority 1: TypeScript design:paramtypes (when emitDecoratorMetadata is true)
const designTypes = Reflect.getMetadata('design:paramtypes', target);
// Priority 2: Constructor source code analysis (fallback)
// Matches patterns like: "private userService: UserService"
const constructorStr = target.toString();
const typeMatch = param.match(/:\s*([A-Za-z][A-Za-z0-9]*)/);
// Note: Classes MUST have a decorator (@Service, @Controller, etc.) for
// design:paramtypes to be emitted. Without a decorator, DI will not work.Automatic DI (Recommended)
In most cases, dependencies are injected automatically without any decorator:
@Controller('/users')
export class UserController extends BaseController {
constructor(
private userService: UserService, // Automatically injected
private cache: CacheService, // Automatically injected
) {
super();
}
}Explicit Injection (Edge Cases)
Use @Inject() only when automatic DI cannot resolve the dependency:
import { Inject } from '@onebun/core';
@Controller('/users')
export class UserController extends BaseController {
constructor(
// Use @Inject for:
// - Interface/abstract class injection
// - Token-based injection
// - Overriding automatic resolution
@Inject(UserService) private userService: UserService,
) {
super();
}
}Effect.js Integration
Internal vs External API
| Layer | API Style | Why |
|---|---|---|
| Framework internals | Effect.js | Composable error handling, resource management |
| Client code (controllers, services) | Promises | Simpler, familiar API |
| Advanced use cases | Both | Effect.runPromise() bridge |
Effect.js in Module System
// Module initialization returns Effect
setup(): Effect.Effect<unknown, never, void> {
return this.createControllerInstances();
}
// Layer composition for DI
const layer = Layer.merge(
Layer.succeed(ServiceTag, serviceInstance),
loggerLayer,
);Using Effect.js in Services
@Service()
export class DataService extends BaseService {
// Promise-based (recommended for most cases)
async fetchData(): Promise<Data> {
return await fetch('/api/data').then(r => r.json());
}
// Effect-based (for advanced composition)
fetchDataEffect(): Effect.Effect<Data, FetchError, never> {
return pipe(
Effect.tryPromise({
try: () => fetch('/api/data'),
catch: (e) => new FetchError(e),
}),
Effect.flatMap((response) =>
Effect.tryPromise(() => response.json())
),
);
}
// Bridge: use Effect internally, expose Promise
async fetchWithRetry(): Promise<Data> {
const effect = pipe(
this.fetchDataEffect(),
Effect.retry({ times: 3 }),
);
return Effect.runPromise(effect);
}
}Request Lifecycle
HTTP Request
│
▼
┌─────────────────────────────────────────────┐
│ Bun.serve fetch handler │
│ ├── Extract trace headers │
│ ├── Start trace span (if enabled) │
│ └── Match route │
└─────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Middleware Chain │
│ ├── Global middleware (ApplicationOptions) │
│ ├── Module middleware (OnModuleConfigure) │
│ ├── Controller middleware (@UseMiddleware) │
│ └── Route middleware (@UseMiddleware) │
└─────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Parameter Extraction & Validation │
│ ├── Path params (@Param) │
│ ├── Query params (@Query) │
│ ├── Body (@Body) with ArkType validation │
│ └── Headers (@Header) │
└─────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Controller Handler │
│ ├── Execute handler method │
│ └── Return Response │
└─────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Response Processing │
│ ├── Response validation (if schema) │
│ ├── Record metrics │
│ ├── End trace span │
│ └── Return to client │
└─────────────────────────────────────────────┘Module System
Module Types
- Root Module: Entry point, contains app-level configuration
- Feature Modules: Domain-specific (UserModule, OrderModule)
- Infrastructure Modules: Cross-cutting (CacheModule, DrizzleModule)
Module Lifecycle
Initialization Phase:
// Phase 1: Import child modules and collect exported services
if (metadata.imports) {
for (const importModule of metadata.imports) {
const childModule = new OneBunModule(importModule, loggerLayer, config);
// Merge layers, collect exported services
}
}
// Phase 2: Create this module's services with DI
this.createServicesWithDI(metadata);
// Phase 3: Call onModuleInit() on services that implement it
// (sequentially in dependency order — dependencies complete before dependents)
// Recursively traverses ALL modules in the import tree (depth-first)
await this.callServicesOnModuleInit(); // root + all descendants
// Phase 4: Create controllers with injected services (all modules)
this.createControllersWithDI();
// Phase 5: Call onModuleInit() on controllers that implement it (all modules)
await this.callControllersOnModuleInit();
// Phase 6: After all modules ready, call onApplicationInit() (before HTTP server)
await this.callOnApplicationInit();Shutdown Phase:
// Phase 1: Call beforeApplicationDestroy(signal) on all services and controllers
await this.callBeforeApplicationDestroy(signal);
// Phase 2: HTTP server stops (handled by application)
// Phase 3: Call onModuleDestroy() on all services and controllers
await this.callOnModuleDestroy();
// Phase 4: Call onApplicationDestroy(signal) on all services and controllers
await this.callOnApplicationDestroy(signal);Lifecycle Hooks
Services and controllers can implement lifecycle hooks by importing the interfaces. See Services API — Lifecycle Hooks for the full reference, execution order, and standalone service patterns.
import {
OnModuleInit,
OnApplicationInit,
OnModuleDestroy,
BeforeApplicationDestroy,
OnApplicationDestroy
} from '@onebun/core';
@Service()
export class DatabaseService extends BaseService implements OnModuleInit, OnModuleDestroy {
private connection: Connection | null = null;
async onModuleInit(): Promise<void> {
this.connection = await createConnection(this.config.database.url);
this.logger.info('Database connected');
}
async onModuleDestroy(): Promise<void> {
await this.connection?.close();
this.logger.info('Database disconnected');
}
}Service Export/Import
Exports are only needed when a module is imported by another. Within a module, all of its providers are automatically available to its controllers and to other providers in the same module; you do not need to list them in exports.
// CacheModule exports CacheService so that importing modules can use it
@Module({
providers: [CacheService],
exports: [CacheService],
})
export class CacheModule {}
// UserModule imports and uses CacheService
@Module({
imports: [CacheModule],
providers: [UserService], // can inject CacheService (imported)
})
export class UserModule {}Example without exports (same-module only): a module with providers: [MyService] and controllers: [MyController] can inject MyService into MyController without listing MyService in exports.
Controller Architecture
Route Registration
@Controller('/users')
export class UserController extends BaseController {
@Get('/') // GET /users/
@Get('/:id') // GET /users/:id
@Post('/') // POST /users/
@Put('/:id') // PUT /users/:id
@Delete('/:id') // DELETE /users/:id
}Metadata Storage
// Stored in META_CONTROLLERS Map
const metadata: ControllerMetadata = {
path: '/users',
routes: [
{
path: '/',
method: HttpMethod.GET,
handler: 'findAll',
params: [],
middleware: [],
},
{
path: '/:id',
method: HttpMethod.GET,
handler: 'findOne',
params: [{ type: ParamType.PATH, name: 'id', index: 0 }],
},
],
};Service Architecture
BaseService Features
export class BaseService {
protected logger: SyncLogger; // Available after super() in constructor
protected config: unknown; // Available after super() in constructor
// Logger and config are injected via ambient init context,
// making them available immediately after super() in subclass constructors.
// initializeService() is kept as a fallback for manual/test usage.
}
// Lifecycle hooks are optional - implement the interface to use:
// onModuleInit is called for ALL providers, even standalone services
// not injected anywhere. Hooks run sequentially in dependency order.
@Service()
class MyService extends BaseService implements OnModuleInit {
async onModuleInit(): Promise<void> {
// Called after construction and DI injection
// All dependencies' onModuleInit have already completed
}
}Service Layer Pattern
@Service()
export class UserService extends BaseService {
// Dependencies injected via constructor
// Logger and config available immediately after super()
constructor(
private repository: UserRepository, // Data access
private cacheService: CacheService, // Cross-cutting
) {
super();
// this.config and this.logger are available here
}
async findById(id: string): Promise<User | null> {
// Check cache first
const cached = await this.cacheService.get(`user:${id}`);
if (cached) return cached;
// Fetch from database
const user = await this.repository.findById(id);
// Cache result
if (user) {
await this.cacheService.set(`user:${id}`, user, { ttl: 300 });
}
return user;
}
}Configuration System
Schema Definition
export const envSchema = {
server: {
port: Env.number({ default: 3000 }),
host: Env.string({ default: '0.0.0.0' }),
},
database: {
url: Env.string({ env: 'DATABASE_URL', sensitive: true }),
maxConnections: Env.number({ default: 10 }),
},
features: {
enableCache: Env.boolean({ default: true }),
allowedOrigins: Env.array({ separator: ',' }),
},
};Configuration Flow
.env file → EnvLoader → EnvParser → ConfigProxy → TypedEnv
↓
validation
↓
type-safe access: config.get('database.url')Observability Stack
Logging
// Levels: trace, debug, info, warn, error, fatal
this.logger.info('User created', { userId: user.id });
// Child loggers inherit context
const childLogger = this.logger.child({ requestId: '123' });Metrics (Prometheus)
http_request_duration_seconds{method, route, status_code}
http_requests_total{method, route, status_code}
process_cpu_seconds_total
process_memory_bytes{type}Tracing (OpenTelemetry-compatible)
// Automatic HTTP request tracing
// Headers: traceparent, tracestate, x-trace-id, x-span-id
// Manual span creation
@Span('operation-name')
async myOperation(): Promise<void> {
// Span automatically created and closed
}Error Handling
OneBun provides standardized error responses via this.error() on controllers and typed error classes (NotFoundError, InternalServerError, etc.). See Controllers API — error() for the response format and usage examples.
Multi-Service Architecture
Single Process, Multiple Services
const multiApp = new MultiServiceApplication({
services: {
users: {
module: UsersModule,
port: 3001,
},
orders: {
module: OrdersModule,
port: 3002,
envOverrides: {
DB_NAME: { value: 'orders_db' },
},
},
},
envSchema,
});Service Communication
// Generate typed client from service definition
const usersClient = createServiceClient(UsersServiceDefinition, {
baseUrl: 'http://localhost:3001',
});
// Call with full type safety
const user = await usersClient.users.findById('123');