Services
Services are classes that handle business logic and can be injected into controllers or other services. This separation of concerns makes your code more testable and maintainable.
Defining Services
A service is a class decorated with @Injectable():
import { Injectable } from '@zeltjs/core';
@Injectable()
export class UserService {
private users = new Map<string, { id: string; name: string }>();
findAll() {
return Array.from(this.users.values());
}
findOne(id: string) {
return this.users.get(id);
}
create(name: string) {
const id = crypto.randomUUID();
const user = { id, name };
this.users.set(id, user);
return user;
}
}
Dependency Injection
Use inject() to inject services into controllers:
import { Controller, Get, Post, inject, pathParam, validated } from '@zeltjs/core';
import * as v from 'valibot';
import { UserService } from './user.service';
const CreateUserBody = v.object({
name: v.string(),
});
@Controller('/users')
export class UserController {
constructor(private userService = inject(UserService)) {}
@Get('/')
findAll() {
return { users: this.userService.findAll() };
}
@Get('/:id')
findOne(id = pathParam('id')) {
const user = this.userService.findOne(id);
if (!user) {
throw new Error('User not found');
}
return user;
}
@Post('/')
create(body = validated(CreateUserBody)) {
return this.userService.create(body.name);
}
}
Service-to-Service Injection
Services can inject other services:
import { Injectable, inject } from '@zeltjs/core';
import { DatabaseService } from './database.service';
import { LoggerService } from './logger.service';
@Injectable()
export class UserService {
constructor(
private db = inject(DatabaseService),
private logger = inject(LoggerService)
) {}
async findAll() {
this.logger.log('Finding all users');
return this.db.query('SELECT * FROM users');
}
}
Singleton Scope
By default, services are singletons — the same instance is shared across all injections within the application lifecycle. This is ideal for:
- Database connections
- Configuration services
- Caching services
@Injectable()
export class ConfigService {
private config: Record<string, string>;
constructor() {
this.config = {
DATABASE_URL: process.env.DATABASE_URL ?? '',
API_KEY: process.env.API_KEY ?? '',
};
}
get(key: string): string {
return this.config[key] ?? '';
}
}
Testing with Mock Services
The singleton pattern makes testing straightforward — you can provide mock implementations:
import { describe, it, expect, vi } from 'vitest';
import { createTestContainer } from '@zeltjs/testing';
import { UserController } from './user.controller';
import { UserService } from './user.service';
describe('UserController', () => {
it('should return all users', async () => {
const mockUsers = [{ id: '1', name: 'John' }];
const container = createTestContainer()
.override(UserService, {
findAll: () => mockUsers,
});
const controller = container.resolve(UserController);
const result = controller.findAll();
expect(result).toEqual({ users: mockUsers });
});
});
Best Practices
- Single Responsibility — Each service should have one clear purpose
- Interface Segregation — Keep service methods focused and cohesive
- Dependency Injection — Always inject dependencies rather than creating them directly
- Testability — Design services to be easily mockable in tests