Services API
Package: @onebun/core
BaseService
Base class for all application services. Provides logger and configuration access.
Class Definition
export class BaseService {
protected logger: SyncLogger; // Available after super() in constructor
protected config: IConfig<OneBunAppConfig>; // Available after super() in constructor
/** Set ambient init context (called by framework before construction) */
static setInitContext(logger: SyncLogger, config: IConfig<OneBunAppConfig>): void;
/** Clear ambient init context (called by framework after construction) */
static clearInitContext(): void;
/** Initialize service with logger and config (fallback, called by framework) */
initializeService(logger: SyncLogger, config: IConfig<OneBunAppConfig>): void;
/** Check if service is initialized */
get isInitialized(): boolean;
/** Run an effect with error handling */
protected async runEffect<A>(effect: Effect.Effect<never, never, A>): Promise<A>;
/** Format an error for consistent handling */
protected formatError(error: unknown): Error;
}Creating a Service
Automatic Logger and Config Injection
The framework automatically injects logger and config into services extending BaseService. Both are available immediately after super() in the constructor — you can use this.config and this.logger directly in the constructor body. The framework uses an ambient init context set before service construction.
Basic Service
import { Service, BaseService } from '@onebun/core';
@Service()
export class CounterService extends BaseService {
private count = 0;
increment(): number {
this.count++;
this.logger.debug('Counter incremented', { count: this.count });
return this.count;
}
decrement(): number {
this.count--;
return this.count;
}
getValue(): number {
return this.count;
}
}Service with Dependencies
Dependencies are automatically injected via constructor. Logger and config are available immediately after super():
import { Service, BaseService } from '@onebun/core';
import { CacheService } from '@onebun/cache';
@Service()
export class UserService extends BaseService {
// Dependencies are auto-injected via constructor
// Logger and config are available immediately after super()
constructor(
private cacheService: CacheService,
private repository: UserRepository,
) {
super();
}
async findById(id: string): Promise<User | null> {
// Check cache first
const cacheKey = `user:${id}`;
const cached = await this.cacheService.get<User>(cacheKey);
if (cached) {
this.logger.debug('User found in cache', { id });
return cached;
}
// Fetch from database
const user = await this.repository.findById(id);
if (user) {
await this.cacheService.set(cacheKey, user, { ttl: 300 });
}
return user;
}
}Service Registration
Services must be:
- Decorated with
@Service() - Listed in module's
providersarray
Within a module, all providers are automatically available to that module's controllers and to other providers in the same module. The exports array is only required when you want to use a provider in another module that imports this one (cross-module injection).
import { Module } from '@onebun/core';
import { CacheModule } from '@onebun/cache';
import { UserService } from './user.service';
import { UserRepository } from './user.repository';
@Module({
imports: [CacheModule], // Makes CacheService available
providers: [
UserService,
UserRepository, // Also a service
],
exports: [UserService], // Only needed for use in other modules that import this one
})
export class UserModule {}Dependency Resolution Errors
The framework validates all dependencies at bootstrap. If a required dependency cannot be resolved, DependencyResolutionError is thrown with diagnostic suggestions:
DependencyResolutionError: Could not resolve dependency CacheService for service UserService.
- CacheService is exported from CacheModule. Add CacheModule to imports of UserModule.The error message searches all registered modules and reports:
- Whether the dependency exists in another module and needs to be imported
- Whether the dependency exists but is not exported from its module
- Whether a global module should have auto-resolved it
Circular Dependencies
Circular dependencies (A → B → A) are detected and throw CircularDependencyError with the full chain:
CircularDependencyError: Circular dependency detected in module AppModule!
Dependency chain: ServiceA -> ServiceB -> ServiceARestructure your code to break the cycle — e.g., extract the shared logic into a third service.
Optional Dependencies
Use @Optional() to allow a dependency to be undefined when not available:
@Service()
export class AnalyticsService extends BaseService {
constructor(@Optional() private metrics?: MetricsCollector) {
super();
}
}See @Optional() for details.
Lifecycle Hooks
Services can implement lifecycle hooks to execute code at specific points in the application lifecycle.
Available Hooks
| Interface | Method | When Called |
|---|---|---|
OnModuleInit | onModuleInit() | After service instantiation and DI |
OnApplicationInit | onApplicationInit() | After all modules initialized, before HTTP server starts |
OnModuleDestroy | onModuleDestroy() | During shutdown, after HTTP server stops |
BeforeApplicationDestroy | beforeApplicationDestroy(signal?) | After the drain and listener close — first hook of the teardown |
OnApplicationDestroy | onApplicationDestroy(signal?) | At the very end of shutdown |
Destroy hooks run after the server is gone
beforeApplicationDestroy is the first hook, not the first act of shutdown. Three phases precede it: new requests are answered 503, the in-flight ones are drained (force-closed once the drain budget — half of shutdownTimeout, 7.5s by default — expires), and the HTTP listener is closed. A request issued from inside a destroy hook to the application's own server fails to connect, so these hooks are for cleanup, not for serving or self-calling. See Graceful Shutdown for the full 11-step sequence.
Eager Instantiation & Standalone Services
All services listed in providers are instantiated eagerly during module initialization — not lazily on first use. onModuleInit is called for every service that implements the interface, even if the service is not injected into any controller or other service.
This makes it safe to use "standalone" services whose main work happens inside onModuleInit — for example, cron schedulers, event listeners, or background workers.
Initialization Order
onModuleInit hooks are called sequentially in dependency order: if service A depends on service B, then B's onModuleInit will complete before A's onModuleInit starts. This guarantees that when your onModuleInit runs, all injected dependencies are already fully initialized.
Cross-Module Lifecycle
onModuleInit is called for services and controllers in all modules across the entire import tree — not just the root module. If AppModule imports UserModule which imports AuthModule, then services in all three modules will have their onModuleInit called. Deeply nested modules are initialized first (depth-first order).
Usage
Implement lifecycle interfaces to hook into the application lifecycle:
// The lifecycle interfaces are type-only exports, so they need `type` — the scaffold that
// `bun create @onebun` writes sets `verbatimModuleSyntax`, under which a value import of them
// is TS1484.
import {
Service,
BaseService,
type OnModuleInit,
type OnModuleDestroy,
} from '@onebun/core';
@Service()
export class DatabaseService extends BaseService implements OnModuleInit, OnModuleDestroy {
private pool: ConnectionPool | null = null;
async onModuleInit(): Promise<void> {
// Called after DI, before application starts
this.pool = await createPool(this.config.get('database.url'));
this.logger.info('Database pool initialized');
}
async onModuleDestroy(): Promise<void> {
// Called during graceful shutdown
if (this.pool) {
await this.pool.end();
this.logger.info('Database pool closed');
}
}
async query<T>(sql: string): Promise<T[]> {
if (!this.pool) {
throw new Error('Database not initialized');
}
return this.pool.query(sql);
}
}Destroy hooks are bounded
shutdownTimeout (default 15000 ms) caps the whole shutdown sequence, and the in-flight HTTP drain may consume the first half of it — so await this.pool.end() above is not guaranteed to finish. When the budget runs out the framework stops waiting; it does not cancel. On await app.stop() the call resolves at the deadline and logs Shutdown timed out after <N>ms while running onModuleDestroy hooks; abandoning the rest of the teardown, while the overrunning hook and every hook after it keep running unawaited and complete afterstop() has already returned. On the SIGTERM/SIGINT path the process exits with code 1 as soon as stop() resolves, so those trailing hooks are killed mid-flight and never complete; a shutdown that finishes inside the budget exits 0. Keep destroy hooks well under the budget, or raise shutdownTimeout.
Standalone Service Example
A service that is not injected anywhere but performs useful work via onModuleInit:
import { Service, BaseService, type OnModuleInit, type OnModuleDestroy } from '@onebun/core';
@Service()
export class TaskSchedulerService extends BaseService implements OnModuleInit, OnModuleDestroy {
private intervalId: Timer | null = null;
async onModuleInit(): Promise<void> {
// Start background work — no injection needed
this.intervalId = setInterval(() => {
this.logger.debug('Running scheduled task...');
}, 60_000);
this.logger.info('Task scheduler started');
}
async onModuleDestroy(): Promise<void> {
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
}Then simply register it in a module's providers:
@Module({
providers: [TaskSchedulerService],
// No controllers or other services reference it — it works on its own
})
export class SchedulerModule {}Shutdown Hooks with Signal
The shutdown hooks receive the signal that triggered the shutdown (e.g., SIGTERM, SIGINT) — in multi-service mode it is always undefined, because the parent stops each child with a bare stop(), so keep the || 'unknown' fallback:
import {
Service,
BaseService,
BeforeApplicationDestroy,
OnApplicationDestroy
} from '@onebun/core';
@Service()
export class CleanupService extends BaseService
implements BeforeApplicationDestroy, OnApplicationDestroy {
async beforeApplicationDestroy(signal?: string): Promise<void> {
this.logger.info(`Shutdown initiated by ${signal || 'unknown'}`);
// Notify external services, flush buffers, etc.
}
async onApplicationDestroy(signal?: string): Promise<void> {
this.logger.info('Final cleanup completed');
// Last chance to do cleanup
}
}Lifecycle Order
STARTUP:
1. Framework sets ambient init context (logger + config)
2. Service instantiation → constructor()
└─ super() picks up logger and config from init context
└─ this.config and this.logger are available after super()
3. Framework clears init context
4. Framework calls initializeService() (no-op if already initialized via constructor)
5. Module init hook → onModuleInit() (sequential, in dependency order)
6. Application init hook → onApplicationInit()
7. HTTP server starts
SHUTDOWN:
1. New requests answered 503 — the listener stays open on purpose
2. In-flight requests drained (force-closed once the drain deadline expires)
3. HTTP listener closed
4. Before destroy hook → beforeApplicationDestroy(signal)
5. WebSockets closed, queue stopped and disconnected, traces flushed
6. Module destroy hook → onModuleDestroy()
7. Shared Redis released
8. Application destroy hook → onApplicationDestroy(signal)
9. Logger transport flushedA destroy hook can no longer serve or reach the application's own HTTP server: the listener is closed at step 3, before hook 4, and a request to it from inside a hook is refused at the socket.
Accessing Logger
@Service()
export class EmailService extends BaseService {
async send(to: string, subject: string, body: string): Promise<boolean> {
this.logger.info('Sending email', { to, subject });
try {
// Send email logic
await this.smtp.send({ to, subject, body });
this.logger.info('Email sent successfully', { to });
return true;
} catch (error) {
this.logger.error('Failed to send email', {
to,
error: error instanceof Error ? error.message : String(error),
});
return false;
}
}
}Log Levels
this.logger.trace('Very detailed info'); // Level 0
this.logger.debug('Debug information'); // Level 1
this.logger.info('General information'); // Level 2
this.logger.warn('Warning message'); // Level 3
this.logger.error('Error occurred'); // Level 4
this.logger.fatal('Fatal error'); // Level 5Logging with Context
// Additional context as object
this.logger.info('User action', {
userId: user.id,
action: 'login',
ip: request.ip,
});
// Error logging
this.logger.error('Operation failed', new Error('Something went wrong'));
// Multiple arguments
this.logger.debug('Processing', data, { step: 1 }, 'extra info');Accessing Configuration
@Service()
export class DatabaseService extends BaseService {
private readonly connectionUrl: string;
private readonly maxConnections: number;
constructor() {
super();
// Config is available immediately after super()
// With InferConfigType + module augmentation, these are fully typed — no casts needed
this.connectionUrl = this.config.get('database.url');
this.maxConnections = this.config.get('database.maxConnections');
}
async connect(): Promise<void> {
// Config is also available in regular methods
this.logger.info('Connecting to database', { maxConnections: this.maxConnections });
// ...
}
}Service with Tracing
Use @Span() decorator to create trace spans:
import { Service, BaseService } from '@onebun/core';
import { Span } from '@onebun/trace';
@Service()
export class OrderService extends BaseService {
@Span('create-order')
async createOrder(data: CreateOrderDto): Promise<Order> {
this.logger.info('Creating order', { customerId: data.customerId });
// All code inside is traced
const order = await this.repository.create(data);
await this.notificationService.notify(data.customerId, order.id);
return order;
}
@Span('process-payment')
async processPayment(orderId: string, amount: number): Promise<PaymentResult> {
// Traced operation
return this.paymentGateway.charge(orderId, amount);
}
@Span() // Default span name is `ClassName.methodName`
async validateOrder(order: Order): Promise<boolean> {
// Span name: "OrderService.validateOrder" — the class prefix is part of it
return this.validator.validate(order);
}
}Using Effect.js in Services
Bridge: Effect to Promise
import { Service, BaseService, Effect } from '@onebun/core';
import { pipe } from 'effect';
@Service()
export class DataService extends BaseService {
// Internal Effect-based implementation
private fetchDataEffect(id: string): Effect.Effect<Data, FetchError, never> {
return pipe(
Effect.tryPromise({
try: () => fetch(`/api/data/${id}`).then(r => r.json()),
catch: (e) => new FetchError(String(e)),
}),
Effect.tap((data) =>
Effect.sync(() => this.logger.debug('Data fetched', { id }))
),
);
}
// Public Promise-based API
async fetchData(id: string): Promise<Data> {
const effect = pipe(
this.fetchDataEffect(id),
Effect.retry({ times: 3, delay: '1 second' }),
);
return this.runEffect(effect);
}
}runEffect Helper
The runEffect method handles Effect execution:
protected async runEffect<A>(effect: Effect.Effect<never, never, A>): Promise<A> {
try {
return await Effect.runPromise(effect);
} catch (error) {
throw this.formatError(error);
}
}Repository Pattern
Services often work with repositories:
// user.repository.ts
import { Service, BaseService } from '@onebun/core';
import { DrizzleService, eq } from '@onebun/drizzle';
import { users } from './schema';
@Service()
export class UserRepository extends BaseService {
constructor(private db: DrizzleService) {
super();
}
async findById(id: string): Promise<User | null> {
const result = await this.db.select().from(users).where(eq(users.id, id)).limit(1);
return result[0] || null;
}
async findAll(options?: { limit?: number; offset?: number }): Promise<User[]> {
return await this.db.select().from(users)
.limit(options?.limit || 100)
.offset(options?.offset || 0);
}
async create(data: InsertUser): Promise<User> {
const result = await this.db.insert(users).values(data).returning();
return result[0];
}
async update(id: string, data: Partial<InsertUser>): Promise<User | null> {
const result = await this.db.update(users).set(data).where(eq(users.id, id)).returning();
return result[0] || null;
}
async delete(id: string): Promise<boolean> {
const result = await this.db.delete(users).where(eq(users.id, id)).returning();
return result.length > 0;
}
}
// user.service.ts
@Service()
export class UserService extends BaseService {
constructor(
private repository: UserRepository,
private cacheService: CacheService,
private eventService: EventService,
) {
super();
}
async createUser(data: CreateUserDto): Promise<User> {
// Business logic
const existingUser = await this.repository.findByEmail(data.email);
if (existingUser) {
throw new ConflictError('Email already exists');
}
// Create user
const user = await this.repository.create({
...data,
password: await hash(data.password),
});
// Side effects
await this.eventService.emit('user.created', { userId: user.id });
this.logger.info('User created', { userId: user.id });
return user;
}
}Service Tags (Advanced)
For advanced Effect.js usage, you can create custom tags:
import { Service, BaseService } from '@onebun/core';
import { Context } from 'effect';
// Create custom tag
export const UserServiceTag = Context.GenericTag<UserService>('UserService');
@Service(UserServiceTag)
export class UserService extends BaseService {
// Service with explicit tag
}
// Use in Effect-based code
const program = pipe(
UserServiceTag,
Effect.flatMap((userService) => Effect.promise(() => userService.findAll())),
);Complete Service Example
import { Service, BaseService, type } from '@onebun/core';
import { Span } from '@onebun/trace';
import { CacheService } from '@onebun/cache';
// Types
interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
interface CreateUserDto {
name: string;
email: string;
password: string;
}
interface UpdateUserDto {
name?: string;
email?: string;
}
interface PaginatedResult<T> {
items: T[];
total: number;
page: number;
limit: number;
}
@Service()
export class UserService extends BaseService {
private users = new Map<string, User>();
constructor(private cacheService: CacheService) {
super();
}
@Span('user-find-all')
async findAll(options?: { page?: number; limit?: number }): Promise<PaginatedResult<User>> {
const page = options?.page || 1;
const limit = options?.limit || 10;
const offset = (page - 1) * limit;
this.logger.debug('Finding all users', { page, limit });
const allUsers = Array.from(this.users.values());
const items = allUsers.slice(offset, offset + limit);
return {
items,
total: allUsers.length,
page,
limit,
};
}
@Span('user-find-by-id')
async findById(id: string): Promise<User | null> {
// Check cache
const cacheKey = `user:${id}`;
const cached = await this.cacheService.get<User>(cacheKey);
if (cached) {
this.logger.trace('User cache hit', { id });
return cached;
}
this.logger.trace('User cache miss', { id });
const user = this.users.get(id) || null;
if (user) {
await this.cacheService.set(cacheKey, user, { ttl: 300 });
}
return user;
}
@Span('user-create')
async create(data: CreateUserDto): Promise<User> {
// Check for duplicate email
const existingUser = await this.findByEmail(data.email);
if (existingUser) {
this.logger.warn('Duplicate email attempt', { email: data.email });
throw new Error('Email already exists');
}
const user: User = {
id: crypto.randomUUID(),
name: data.name,
email: data.email,
createdAt: new Date(),
};
this.users.set(user.id, user);
this.logger.info('User created', { userId: user.id, email: user.email });
return user;
}
@Span('user-update')
async update(id: string, data: UpdateUserDto): Promise<User | null> {
const user = this.users.get(id);
if (!user) {
this.logger.warn('User not found for update', { id });
return null;
}
const updatedUser = { ...user, ...data };
this.users.set(id, updatedUser);
// Invalidate cache
await this.cacheService.delete(`user:${id}`);
this.logger.info('User updated', { userId: id, fields: Object.keys(data) });
return updatedUser;
}
@Span('user-delete')
async delete(id: string): Promise<boolean> {
const deleted = this.users.delete(id);
if (deleted) {
await this.cacheService.delete(`user:${id}`);
this.logger.info('User deleted', { userId: id });
}
return deleted;
}
private async findByEmail(email: string): Promise<User | null> {
for (const user of this.users.values()) {
if (user.email === email) {
return user;
}
}
return null;
}
}