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, or is
@Global()but was never imported by this application — a@Global()module still has to be imported once, anywhere - Whether it comes from a named registration, in which case the suggestion names
forFeature(<token>)rather than the registration's internal class
Matching is by class identity, not by name: a same-named class in a package this application never imports is not a candidate, and it does not suppress the "decorate it with @Service() and list it in a module's providers" advice, which prints whenever nothing else matched.
Circular Dependencies
The order of providers does not matter. Each provider is constructed after the providers of its own module that it injects, whatever order the array lists them in — a consumer may come first:
import { BaseService, Module, Service } from '@onebun/core';
@Service()
export class Database extends BaseService {}
@Service()
export class UserRepository extends BaseService {
constructor(private database: Database) {
super();
}
}
@Service()
export class UserService extends BaseService {
constructor(private repository: UserRepository) {
super();
}
}
@Module({
// Consumers first: Database is still constructed first, then UserRepository, then UserService
providers: [UserService, UserRepository, Database],
})
export class UserModule {}A parameter typed as an abstract or base class, which DI resolves to a registered subclass (see Architecture), follows the same rule: the consumer waits until a provider of its module that extends the type is built, so it may be listed before that implementation and before the implementation's own dependencies. The one exception is an @Optional() parameter of that kind: it does not wait, so list the implementation first — see @Optional().
The construction order is deterministic: providers are taken in the listed order, and one whose dependency is not built yet goes to the back of the queue. A list that already puts dependencies first is therefore built exactly in the listed order, and onModuleInit runs in that order too. A consumer-first list boots however long its chain is — CircularDependencyError is reserved for a real cycle (below).
A real cycle (A → B → A) is detected and throws CircularDependencyError. The chain names only the cycle: a provider that merely waits on it — X in X -> Y -> Z -> Y — is listed among the unresolved services, not in the chain.
CircularDependencyError: Circular dependency detected in module AppModule!
Dependency chain: ServiceA -> ServiceB -> ServiceA
Unresolved services: ServiceA, ServiceBRestructure your code to break the cycle — e.g., extract the shared logic into a third service.
A provider waiting for a provider of its module that was never constructed is not a cycle. It fails with DependencyResolutionError naming that dependency and why it has no instance: this copy of @onebun/core sees no @Service() on it (it is undecorated, or decorated by a second copy of the framework), or creating it threw — the original error is logged as Failed to create service <Name>:
DependencyResolutionError: Could not resolve dependency UserRepository for service UserService.
- UserRepository is listed in the providers of UserModule, but was never constructed: creating it threw, and the error was logged as "Failed to create service UserRepository".For an abstract-typed parameter the dependency named is the parameter's type, and the hint names the provider extending it that was never constructed — StripeGateway (it extends PaymentGateway). An @Optional() parameter does not fail here: see @Optional().
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 |
The destroy hooks also run when start() fails: the failed boot runs the shutdown sequence over every instance it built before rethrowing — including one whose onModuleInit never ran or threw halfway. Release what exists, as DatabaseService below does with if (this.pool). See When start() fails.
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 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
Pipeline elements get the module hooks too, on the instance that actually serves requests. A middleware or interceptor class receives onModuleInit, onApplicationInit, onModuleDestroy and onApplicationDestroy — the init hook runs in a pass of its own, after routes are registered and before the server accepts anything, because that is when those instances exist. Guards do not: a guard class is constructed per request by design, so there is no single instance for a hook to belong to, and a guard implementing onModuleInit is reported at startup rather than silently skipped. Put the setup in a @Service() the guard injects.
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: the application releases nothing — the cache and the queue adapter gave their holds back when they closed; a debug line names any remaining holder
8. Application destroy hook → onApplicationDestroy(signal)
9. Logger transport flushed
FAILED START (a startup step threw):
1. The error is logged
2. The SHUTDOWN sequence runs over what was built; steps with nothing to release are skipped,
and a loggerLayer, spanProcessors or metrics registry passed in options is left open
3. start() rejects with the error it caught (an onModuleInit failure arrives wrapped by
Effect as a FiberFailure, message intact) — a stop() before the next start() runs no
step again; it only closes what step 2 left open, as every stop() doesA destroy hook cannot 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;
}
}