Skip to content

OneBun Architecture

Overview

OneBun is built on three pillars:

  1. NestJS-inspired DI - Decorators for modules, controllers, services
  2. Effect.js - Type-safe side effect management internally
  3. 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

  1. Service Registration: @Service() decorator registers class with Effect.js Context tag
  2. Module Assembly: @Module() collects controllers and providers
  3. Dependency Resolution: Framework analyzes constructor parameters
  4. Instance Creation: Services created in dependency order, then controllers

DI Resolution Flow

typescript
// 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 injected

Auto-Detection Algorithm

The framework uses TypeScript metadata to detect dependencies:

typescript
// Primary: TypeScript design:paramtypes (when emitDecoratorMetadata is true)
const designTypes = Reflect.getMetadata('design:paramtypes', target);

// Fallback: Custom metadata storage (WeakMap-based, in decorators/metadata.ts)
// Populated by @Service(), @Inject(), and other decorators via META_CONSTRUCTOR_PARAMS Map

// Note: Classes MUST have a decorator (@Service, @Controller, etc.) for
// design:paramtypes to be emitted. Without a decorator, DI will not work.

In most cases, dependencies are injected automatically without any decorator:

typescript
@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:

typescript
import { Inject } from '@onebun/core';

@Controller('/users')
export class UserController extends BaseController {
  constructor(
    // Use @Inject for:
    // - Token-based injection: picks WHICH named registration this parameter gets,
    //   e.g. @Inject(ANALYTICS_DB) private analytics: DrizzleService
    // - Overriding the type inferred from design:paramtypes
    @Inject(UserService) private userService: UserService,
  ) {
    super();
  }
}

@Inject() accepts a concrete constructor, a symbol or a string — nothing else:

  • Interfaces cannot be injected. They have no runtime value, so @Inject(SomeInterface) is error TS2693: 'SomeInterface' only refers to a type, but is being used as a value here.
  • An abstract-class-typed parameter needs no @Inject at all. DI resolves it to a registered subclass automatically (instance instanceof type), while @Inject(AbstractClass) is error TS2345 — an abstract constructor is not assignable to new (...args: any[]) => T. This works only when exactly one subclass is registered; with two, the first one listed in providers wins silently.
  • There is no { provide, useClass } binding form. An object provider throws OneBunInvalidProviderError at startup; substitute implementations with TestingModule.overrideProvider().

Effect.js Integration

Internal vs External API

LayerAPI StyleWhy
Framework internalsEffect.jsComposable error handling, resource management
Client code (controllers, services)PromisesSimpler, familiar API
Advanced use casesBothEffect.runPromise() bridge

Effect.js in Module System

typescript
// 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

typescript
@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)      │
└─────────────────────────────────────────────┘


┌─────────────────────────────────────────────┐
│ Guards (@UseGuards)                         │
│  ├── Controller-level guards                │
│  └── Route-level guards                     │
│  (403 Forbidden if guard rejects)           │
└─────────────────────────────────────────────┘


┌─────────────────────────────────────────────┐
│ Interceptors (@UseInterceptors)             │
│  ├── Global → Controller → Route            │
│  └── Onion order (wrap handler execution)   │
└─────────────────────────────────────────────┘


┌─────────────────────────────────────────────┐
│ 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)        │
│  ├── Response flows back through            │
│  │   interceptors (onion exit)              │
│  ├── Record metrics                         │
│  ├── End trace span                         │
│  └── Return to client                       │
└─────────────────────────────────────────────┘
    │ (on error)

┌─────────────────────────────────────────────┐
│ Exception Filters (@UseFilters)             │
│  ├── Route → Controller → Global            │
│  └── Last matching filter wins              │
└─────────────────────────────────────────────┘

Module System

Module Types

  1. Root Module: Entry point, contains app-level configuration
  2. Feature Modules: Domain-specific (UserModule, OrderModule)
  3. Infrastructure Modules: Cross-cutting (CacheModule, DrizzleModule)

Module Lifecycle

Initialization Phase:

typescript
// 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:

typescript
// Phase 1: Refuse new requests — every route answers 503 Service Unavailable.
//          The listener deliberately stays open, so a load balancer sees a refusal.
// Phase 2: Drain in-flight requests, bounded by half of `shutdownTimeout`.
//          Anything still open when that deadline expires is force-closed, with a warn.
// Phase 3: Close the HTTP listener — before any destroy hook runs.

// Phase 4: Call beforeApplicationDestroy(signal) on all services and controllers
//          (the socket is already closed here — this hook cannot serve HTTP)
await this.callBeforeApplicationDestroy(signal);

// Phase 5: Framework teardown — WebSocket cleanup, queue service stop,
//          queue adapter disconnect, trace flush

// Phase 6: Call onModuleDestroy() on all services and controllers
await this.callOnModuleDestroy();

// Phase 7: Release the shared Redis connection, then
//          call onApplicationDestroy(signal) on all services and controllers
await this.callOnApplicationDestroy(signal);

The whole sequence is bounded by shutdownTimeout (default 15000ms): the first half bounds the drain, the rest bounds the destroy hooks. On expiry the remaining teardown is abandoned and the phase still running is named in the error log; on the signal path the process then exits with code 1. stop() is idempotent — a second call awaits the first and re-runs nothing. Applications with no HTTP server skip phases 1–3. See Core — Graceful Shutdown for the full 11-step order.

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.

typescript
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.get('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.

typescript
// 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

typescript
@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

typescript
// 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

typescript
export class BaseService {
  protected logger: SyncLogger;                // Available after super() in constructor
  protected config: IConfig<OneBunAppConfig>;  // 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

typescript
@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

typescript
import { Env, type InferConfigType } from '@onebun/core';

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: ',' }),
  },
};

export type AppConfig = InferConfigType<typeof envSchema>;

declare module '@onebun/core' {
  interface OneBunAppConfig extends AppConfig {}
}

Configuration Flow

.env file → EnvLoader → EnvParser → ConfigProxy → TypedEnv

           validation

           type-safe access: config.get('database.url')

Observability Stack

Logging

typescript
// 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)

{prefix}http_request_duration_seconds{method, route, status_code, controller, action}
{prefix}http_requests_total{method, route, status_code, controller, action}
{prefix}memory_usage_bytes{type}
{prefix}cpu_usage_ratio
{prefix}uptime_seconds

Tracing (OpenTelemetry-compatible)

typescript
// 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 throw new HttpException(statusCode, message) and typed error classes (NotFoundError, InternalServerError, etc.). See Controllers API for the response format and usage examples.

Multi-Service Architecture

Single Process, Multiple Services

typescript
const multiApp = new OneBunApplication({
  services: {
    users: {
      module: UsersModule,
      port: 3001,
    },
    orders: {
      module: OrdersModule,
      port: 3002,
      envOverrides: {
        DB_NAME: { value: 'orders_db' },
      },
    },
  },
  envSchema,
});

Service Communication

typescript
// 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');

Released under the MPL-2.0 License.