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 multiple strategies to detect dependencies:

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

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:
    // - Interface/abstract class injection
    // - Token-based injection
    // - Overriding automatic resolution
    @Inject(UserService) private userService: UserService,
  ) {
    super();
  }
}

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


┌─────────────────────────────────────────────┐
│ 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

  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: 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.

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.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: 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

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

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)

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)

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

typescript
const multiApp = new MultiServiceApplication({
  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 LGPL-3.0 License.