Services
Services contain your application’s business logic. They are plain TypeScript classes decorated with @Injectable() so the IoC container can resolve and inject them.
Anatomy of a service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// src/services/product.service.ts
import { Injectable } from 'streetjs';
import { ProductRepository } from '../repositories/product.repository.js';
import { NotFoundException } from 'streetjs';
export interface Product {
id: string;
name: string;
price: number;
createdAt: Date;
updatedAt: Date;
}
export interface CreateProductInput {
name: string;
price: number;
}
@Injectable()
export class ProductService {
constructor(
private readonly repository: ProductRepository,
) {}
async findAll(page: number, limit: number) {
return this.repository.findAll(page, limit);
}
async findById(id: string): Promise<Product> {
const product = await this.repository.findById(id);
if (!product) throw new NotFoundException(`Product ${id} not found`);
return product;
}
async create(input: CreateProductInput): Promise<Product> {
const now = new Date();
const product: Product = {
id: crypto.randomUUID(),
name: input.name,
price: input.price,
createdAt: now,
updatedAt: now,
};
await this.repository.create(product);
return product;
}
async update(id: string, input: Partial<CreateProductInput>): Promise<Product> {
const existing = await this.findById(id);
const updated: Product = { ...existing, ...input, updatedAt: new Date() };
await this.repository.update(updated);
return updated;
}
async delete(id: string): Promise<void> {
await this.findById(id); // throws NotFoundException if missing
await this.repository.delete(id);
}
}
The @Injectable() decorator
@Injectable() marks a class for IoC resolution. Under the hood it calls Reflect.defineMetadata to record the class so the container can find it.
1
2
3
4
5
6
7
8
import { Injectable } from 'streetjs';
@Injectable()
export class EmailService {
async send(to: string, subject: string, body: string): Promise<void> {
// ...
}
}
Rules:
- Every class that is injected into another class must be decorated with
@Injectable() - The decorator must appear before any other decorators on the class
import 'reflect-metadata'must be the first import in your entry point (src/main.ts)
Constructor injection
Dependencies are declared as constructor parameters. The container reads the parameter types via Reflect.getMetadata('design:paramtypes', ...) and resolves each one automatically.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Injectable()
export class OrderService {
constructor(
private readonly orders: OrderRepository,
private readonly products: ProductService,
private readonly email: EmailService,
) {}
async placeOrder(userId: string, productId: string): Promise<Order> {
const product = await this.products.findById(productId);
const order = await this.orders.create({ userId, productId, total: product.price });
await this.email.send(userId, 'Order confirmed', `Order #${order.id} placed.`);
return order;
}
}
The container resolves OrderRepository, ProductService, and EmailService automatically — no manual wiring needed.
Manual registration
For services that require runtime configuration (e.g. a database pool, a secret key), register them manually before the container resolves anything:
1
2
3
4
5
6
7
8
// src/main.ts
import { container, PgPool, JwtService } from 'streetjs';
const pool = new PgPool({ /* ... */ });
await pool.initialize();
container.register(PgPool, pool);
container.register(JwtService, new JwtService(process.env['JWT_SECRET']!));
After manual registration, any @Injectable() class that declares PgPool or JwtService as a constructor parameter will receive the registered instance.
Singleton behaviour
The container stores one instance per class. The first container.resolve(MyService) creates the instance; subsequent calls return the same object.
1
2
3
const a = container.resolve(ProductService);
const b = container.resolve(ProductService);
console.log(a === b); // true
This means services are stateful across requests. Keep per-request state in ctx.state, not in service properties.
Accessing the container directly
In rare cases (e.g. a factory function, a gateway), resolve a service directly:
1
2
3
4
5
6
import { container } from 'streetjs';
import { NotificationService } from './notification.service.js';
// Inside a WebSocket handler
const notifications = container.resolve(NotificationService);
await notifications.push(userId, message);
Service layer patterns
Validation in services
Validate business rules in the service, not the controller:
1
2
3
4
5
6
7
8
9
10
async register(email: string, password: string): Promise<User> {
if (password.length < 8) {
throw new BadRequestException('Password must be at least 8 characters');
}
const exists = await this.repository.emailExists(email);
if (exists) {
throw new ConflictException(`Email ${email} is already registered`);
}
// ...
}
Pagination
Return a consistent pagination shape:
1
2
3
4
5
6
7
8
async findAll(page: number, limit: number) {
const offset = (page - 1) * limit;
const [items, total] = await Promise.all([
this.repository.list(limit, offset),
this.repository.count(),
]);
return { items, total, page, limit, pages: Math.ceil(total / limit) };
}
Transactions
For multi-step operations that must be atomic, use the pool directly:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { container, PgPool } from 'streetjs';
@Injectable()
export class TransferService {
private readonly pool = container.resolve(PgPool);
async transfer(fromId: string, toId: string, amount: number): Promise<void> {
await this.pool.query('BEGIN');
try {
await this.pool.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[amount, fromId]
);
await this.pool.query(
'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
[amount, toId]
);
await this.pool.query('COMMIT');
} catch (err) {
await this.pool.query('ROLLBACK');
throw err;
}
}
}
Testing services
Services are plain classes — test them directly without HTTP:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { describe, it, before } from 'node:test';
import assert from 'node:assert/strict';
import { container } from 'streetjs';
import { ProductService } from '../src/services/product.service.js';
describe('ProductService', () => {
let service: ProductService;
before(() => {
service = container.resolve(ProductService);
});
it('creates a product', async () => {
const product = await service.create({ name: 'Widget', price: 9.99 });
assert.equal(product.name, 'Widget');
assert.equal(product.price, 9.99);
assert.ok(product.id);
});
it('throws NotFoundException for unknown id', async () => {
await assert.rejects(
() => service.findById('00000000-0000-0000-0000-000000000000'),
{ name: 'NotFoundException' }
);
});
});