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 — the order of providers does not matter, except for an @Optional() parameter typed as an abstract class, which does not wait for its implementation — 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),
// the class's OWN entry — a subclass never borrows its parent's
const designTypes = Reflect.getOwnMetadata('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.

Bun emits that metadata through the global Reflect.metadata, so @onebun/core installs a complete Reflect Metadata API on the global Reflect — all nine functions of the proposal, with per-member keys and the prototype walk — unless an implementation is already there. It interoperates with a reflect-metadata loaded later by a third-party library (tsyringe, @simplewebauthn/server): a 0.2.x copy registers its own store in the provider registry the core publishes and still reads everything decorated before it loaded, and a 0.1.x copy finds nothing to fill in. Any import order works; see Reflect Metadata and Other Libraries.

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. The consumer may be listed before the subclass, and before the subclass's own dependencies: it waits until the subclass is built. An @Optional() parameter does not wait — it gets undefined, with a warning, unless the subclass is already built — so list the subclass before that consumer. This works only when exactly one subclass is registered; with two, whichever is constructed first wins silently — the first one listed in providers, unless it waits for a dependency listed after it.
  • 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: Call onApplicationDestroy(signal) on all services and controllers
//          (the shared Redis client is not released here: each consumer gave its hold back)
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 ​

One service calls another through a client built from the callee's module. The definition reflects the routes out of the controllers' decorator metadata, and the client reaches each endpoint as client.<ControllerClassName>.<handlerName>(...):

typescript
import {
  BaseController,
  Controller,
  createServiceClient,
  createServiceDefinition,
  Get,
  Module,
  Param,
} from '@onebun/core';

@Controller('/users')
class UsersController extends BaseController {
  @Get('/:id')
  findById(@Param('id') id: string) {
    return { id, name: 'Ada' };
  }
}

@Module({ controllers: [UsersController] })
class UsersModule {}

const UsersServiceDefinition = createServiceDefinition(UsersModule);

const usersClient = createServiceClient(UsersServiceDefinition, {
  url: 'http://localhost:3001',
});

// Sends GET /users/123. The key is the controller class name
const response = await usersClient.UsersController.findById('123');

// `response` is `any`: the HTTP client's envelope around the server's `{ success, result }` body
const user = response.result.result;

The client checks controller and method names at run time: a name the definition lacks throws when it is read. It does not check types. Any name compiles, arguments are any[], and the result is any. See What the client checks.

Released under the MPL-2.0 License.