Skip to content

Services API

Package: @onebun/core

BaseService

Base class for all application services. Provides logger and configuration access.

Class Definition

typescript
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

typescript
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():

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

  1. Decorated with @Service()
  2. Listed in module's providers array

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

typescript
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

InterfaceMethodWhen Called
OnModuleInitonModuleInit()After service instantiation and DI
OnApplicationInitonApplicationInit()After all modules initialized, before HTTP server starts
OnModuleDestroyonModuleDestroy()During shutdown, after HTTP server stops
BeforeApplicationDestroybeforeApplicationDestroy(signal?)At the very start of shutdown
OnApplicationDestroyonApplicationDestroy(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:

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

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

typescript
@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):

typescript
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

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

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

Logging with Context

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

typescript
@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:

typescript
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

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

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

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

typescript
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

typescript
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;
  }
}

Released under the LGPL-3.0 License.