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: unknown; // Available after super() in constructor
/** Set ambient init context (called by framework before construction) */
static setInitContext(logger: SyncLogger, config: unknown): 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: unknown): 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 {}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?) | At the very start of shutdown |
OnApplicationDestroy | onApplicationDestroy(signal?) | At the very end of shutdown |
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:
import {
Service,
BaseService,
OnModuleInit,
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);
}
}Standalone Service Example
A service that is not injected anywhere but performs useful work via onModuleInit:
import { Service, BaseService, OnModuleInit, 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):
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. Before destroy hook → beforeApplicationDestroy(signal)
2. HTTP server stops
3. Module destroy hook → onModuleDestroy()
4. Application destroy hook → onApplicationDestroy(signal)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()
this.connectionUrl = this.config.get('database.url') as string;
this.maxConnections = this.config.get('database.maxConnections') as number;
}
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, Span } from '@onebun/core';
@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() // Uses method name as span name
async validateOrder(order: Order): Promise<boolean> {
// Span name: "validateOrder"
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.query(
db => 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 this.db.query(
db => db.select().from(users)
.limit(options?.limit || 100)
.offset(options?.offset || 0)
);
}
async create(data: InsertUser): Promise<User> {
const result = await this.db.query(
db => db.insert(users).values(data).returning()
);
return result[0];
}
async update(id: string, data: Partial<InsertUser>): Promise<User | null> {
const result = await this.db.query(
db => 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.query(
db => 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, Span } from '@onebun/core';
import { CacheService } from '@onebun/cache';
import { type } from 'arktype';
// 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;
}
}