TypeORM Package
The @hazeljs/typeorm package provides TypeORM integration for HazelJS applications. It includes an injectable DataSource service, base repository pattern, and automatic connection lifecycle—connect on module init, disconnect on destroy.
Purpose
When using TypeORM you need a single DataSource, repository-style data access, and clean integration with dependency injection. The @hazeljs/typeorm package provides:
- DataSource integration — TypeORM DataSource as injectable
TypeOrmServicewith lifecycle hooks - Repository pattern —
BaseRepository<T>with find, findOne, create, save, update, delete, and count - Decorators —
@Repository({ model: 'User' })and@InjectRepository()for DI - forRoot — Optional
TypeOrmModule.forRoot(options)for custom DataSource options - Error handling — Built-in mapping of TypeORM errors (e.g. unique constraint, foreign key) to readable errors
Architecture
The package wraps TypeORM's DataSource and integrates with HazelJS dependency injection:
graph TD A["Your Services & Controllers"] --> B["BaseRepository<br/>(CRUD & Custom Methods)"] B --> C["TypeOrmService<br/>(DataSource Wrapper)"] C --> D["TypeORM DataSource<br/>(Repository, Manager)"] D --> E["Database<br/>(PostgreSQL, MySQL, etc)"] style A fill:#3b82f6,stroke:#60a5fa,stroke-width:2px,color:#fff style B fill:#3b82f6,stroke:#60a5fa,stroke-width:2px,color:#fff style C fill:#3b82f6,stroke:#60a5fa,stroke-width:2px,color:#fff style D fill:#8b5cf6,stroke:#a78bfa,stroke-width:2px,color:#fff style E fill:#10b981,stroke:#34d399,stroke-width:2px,color:#fff
Key Components
- TypeOrmModule — Registers
TypeOrmService; optionalforRoot(options)for custom DataSource config - TypeOrmService — Injectable wrapper around TypeORM DataSource;
onModuleInit/onModuleDestroy,getRepository(entity) - BaseRepository — Abstract base class for entity repositories with standard CRUD
- Repository decorator —
@Repository({ model: 'User' })for metadata and DI - InjectRepository — Parameter decorator to inject repository instances
Advantages
1. Entity-based
Use TypeORM entities and decorators; no separate schema file—define models in TypeScript.
2. Repository pattern
Consistent data access with BaseRepository<T>; add custom methods per entity.
3. Lifecycle management
Connection is initialized on module init and destroyed on module destroy—no manual connect/disconnect.
4. Error handling
Base repository maps TypeORM errors (unique constraint, foreign key, not found) to clear messages.
5. DI integration
@Repository implies @Injectable() — one decorator does the job for repositories. Services use @Service(). Inject repositories via @InjectRepository(); everything works with the HazelJS container.
6. Transactions
Use TypeOrmService.dataSource.transaction() for atomic operations.
Installation
npm install @hazeljs/typeorm typeorm
Quick Start
1. Set DATABASE_URL
# .env
DATABASE_URL="postgresql://user:pass@localhost:5432/mydb"
2. Register the module
import { HazelModule } from '@hazeljs/core';
import { TypeOrmModule } from '@hazeljs/typeorm';
@HazelModule({
imports: [TypeOrmModule],
})
export class AppModule {}
With custom options (e.g. entities, logging):
import { TypeOrmModule } from '@hazeljs/typeorm';
@HazelModule({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'user',
password: 'pass',
database: 'mydb',
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: false,
}),
],
})
export class AppModule {}
3. Define an entity
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity('users')
export class UserEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ unique: true })
email!: string;
@Column()
name!: string;
}
4. Create a repository
@Repository implies @Injectable() — you do not need to add both decorators.
import { BaseRepository, Repository, TypeOrmService } from '@hazeljs/typeorm';
import { UserEntity } from './user.entity';
@Repository({ model: 'User' })
export class UserRepository extends BaseRepository<UserEntity> {
constructor(typeOrm: TypeOrmService) {
super(typeOrm, UserEntity);
}
async findByEmail(email: string): Promise<UserEntity | null> {
return this.findOne({ where: { email } });
}
}
5. Use in a service
import { Service } from '@hazeljs/core';
import { InjectRepository } from '@hazeljs/typeorm';
import { UserRepository } from './user.repository';
@Service()
export class UserService {
constructor(
@InjectRepository()
private readonly userRepository: UserRepository
) {}
async findAll() {
return this.userRepository.find();
}
async create(data: { email: string; name: string }) {
return this.userRepository.create(data);
}
}
TypeOrm Service
Inject TypeOrmService when you need the DataSource or a repository directly:
import { Service } from '@hazeljs/core';
import { TypeOrmService } from '@hazeljs/typeorm';
import { UserEntity } from './user.entity';
@Service()
export class UserService {
constructor(private readonly typeOrm: TypeOrmService) {}
async findAll() {
const repo = this.typeOrm.getRepository(UserEntity);
return repo.find();
}
}
Base Repository
BaseRepository<T> exposes these methods (all delegate to TypeORM's Repository<T>):
- find(options?) — Find many entities
- findOne(options) — Find one or null
- create(data) — Create entity and save
- save(entity) — Insert or update
- update(criteria, partial) — Update by criteria
- delete(criteria) — Delete by criteria
- count(options?) — Count entities
Subclass and pass the entity class to the constructor:
@Repository({ model: 'User' })
export class UserRepository extends BaseRepository<UserEntity> {
constructor(typeOrm: TypeOrmService) {
super(typeOrm, UserEntity);
}
async findActive() {
return this.find({ where: { active: true } });
}
}
@Repository and @InjectRepository
@Repository is a class decorator that stores the entity name in metadata and implicitly registers the class as injectable. You do not need @Injectable() — @Repository does that for you.
@Repository({ model: 'User' })
export class UserRepository extends BaseRepository<UserEntity> {
constructor(typeOrm: TypeOrmService) {
super(typeOrm, UserEntity);
}
}
// ❌ Redundant — @Injectable() is already implied by @Repository
@Injectable()
@Repository({ model: 'User' })
export class UserRepository extends BaseRepository<UserEntity> { /* ... */ }
Shorthand: @Repository('User').
To use a non-singleton scope, pass scope directly to @Repository:
@Repository({ model: 'Session', scope: 'transient' })
export class SessionRepository extends BaseRepository<SessionEntity> {
constructor(typeOrm: TypeOrmService) {
super(typeOrm, SessionEntity);
}
}
@InjectRepository is a parameter decorator so the container injects the repository by type:
@Service()
export class UserService {
constructor(
@InjectRepository() private userRepository: UserRepository
) {}
}
Complete Example
Repository, service, and controller wired together:
import { Service, Controller, Get, Post, Put, Delete, Param, Body } from '@hazeljs/core';
import { BaseRepository, Repository, InjectRepository, TypeOrmService } from '@hazeljs/typeorm';
import { UserEntity } from './user.entity';
@Repository({ model: 'User' })
export class UserRepository extends BaseRepository<UserEntity> {
constructor(typeOrm: TypeOrmService) {
super(typeOrm, UserEntity);
}
async findByEmail(email: string) {
return this.findOne({ where: { email } });
}
}
@Service()
export class UserService {
constructor(
@InjectRepository() private readonly userRepository: UserRepository
) {}
async findAll() {
return this.userRepository.find();
}
async findOne(id: string) {
return this.userRepository.findOne({ where: { id } });
}
async create(data: { email: string; name: string }) {
return this.userRepository.create(data);
}
async update(id: string, data: { email?: string; name?: string }) {
await this.userRepository.update({ id }, data);
return this.userRepository.findOne({ where: { id } });
}
async delete(id: string) {
await this.userRepository.delete({ id });
}
}
@Controller('users')
export class UsersController {
constructor(private readonly userService: UserService) {}
@Get()
async findAll() {
return this.userService.findAll();
}
@Get(':id')
async findOne(@Param('id') id: string) {
return this.userService.findOne(id);
}
@Post()
async create(@Body() body: { email: string; name: string }) {
return this.userService.create(body);
}
@Put(':id')
async update(
@Param('id') id: string,
@Body() body: { email?: string; name?: string }
) {
return this.userService.update(id, body);
}
@Delete(':id')
async delete(@Param('id') id: string) {
await this.userService.delete(id);
}
}
Transactions
TypeORM transactions guarantee that multiple database operations either all succeed or all fail together — critical for operations like transfers, order placement, or any multi-table write where partial success would leave data in an inconsistent state.
Access the transaction API through TypeOrmService.dataSource.transaction(). The callback receives an EntityManager — use it instead of repositories inside the transaction so all queries share the same database connection and transaction context.
Basic Transaction
import { Service } from '@hazeljs/core';
import { TypeOrmService } from '@hazeljs/typeorm';
import { Account } from './account.entity';
@Service()
export class TransferService {
constructor(private readonly typeOrm: TypeOrmService) {}
async transfer(fromId: string, toId: string, amount: number): Promise<void> {
await this.typeOrm.dataSource.transaction(async (manager) => {
// Both operations run in the same transaction.
// If the increment throws, the decrement is rolled back automatically.
await manager.decrement(Account, { id: fromId }, 'balance', amount);
await manager.increment(Account, { id: toId }, 'balance', amount);
});
}
}
Transaction with Multiple Entities
Use the manager to work across entity types in one atomic operation:
import { Service } from '@hazeljs/core';
import { TypeOrmService } from '@hazeljs/typeorm';
import { Order } from './order.entity';
import { OrderItem } from './order-item.entity';
import { ProductInventory } from './product-inventory.entity';
@Service()
export class OrderService {
constructor(private readonly typeOrm: TypeOrmService) {}
async placeOrder(
userId: string,
items: { productId: string; quantity: number; price: number }[],
): Promise<Order> {
return this.typeOrm.dataSource.transaction(async (manager) => {
// 1. Create the order header
const order = manager.create(Order, {
userId,
status: 'confirmed',
total: items.reduce((sum, i) => sum + i.price * i.quantity, 0),
});
await manager.save(order);
// 2. Create each line item
const lineItems = items.map((i) =>
manager.create(OrderItem, { orderId: order.id, ...i }),
);
await manager.save(lineItems);
// 3. Decrement inventory for every product
for (const item of items) {
const result = await manager.decrement(
ProductInventory,
{ productId: item.productId },
'stock',
item.quantity,
);
// Roll back the whole transaction if any product is out of stock
if (!result.affected) {
throw new Error(`Product ${item.productId} not found in inventory`);
}
}
return order;
});
}
}
Transaction with Query Builder
For more complex queries inside a transaction, use the manager's query builder:
async adjustUserBalance(userId: string, delta: number): Promise<void> {
await this.typeOrm.dataSource.transaction(async (manager) => {
// Lock the row for the duration of the transaction (SELECT ... FOR UPDATE)
const account = await manager
.createQueryBuilder(Account, 'account')
.setLock('pessimistic_write')
.where('account.userId = :userId', { userId })
.getOneOrFail();
if (delta < 0 && account.balance + delta < 0) {
throw new Error('Insufficient balance');
}
await manager.update(Account, { userId }, { balance: account.balance + delta });
});
}
Isolation Levels
Control transaction isolation when you need to prevent dirty reads, non-repeatable reads, or phantom reads:
import { IsolationLevel } from 'typeorm/driver/types/IsolationLevel';
async criticalUpdate(id: string, data: Partial<Record>): Promise<void> {
await this.typeOrm.dataSource.transaction(
'SERIALIZABLE' as IsolationLevel,
async (manager) => {
const record = await manager.findOneByOrFail(Record, { id });
await manager.save(Record, { ...record, ...data });
},
);
}
| Isolation Level | Prevents |
|---|---|
READ COMMITTED (default) | Dirty reads |
REPEATABLE READ | Dirty reads, non-repeatable reads |
SERIALIZABLE | Dirty reads, non-repeatable reads, phantom reads |
Error Handling in Transactions
TypeORM rolls back automatically when the callback throws. Catch the error in the caller to respond appropriately:
import { Service } from '@hazeljs/core';
@Service()
export class PaymentService {
constructor(private readonly typeOrm: TypeOrmService) {}
async processPayment(orderId: string, amount: number): Promise<{ success: boolean; error?: string }> {
try {
await this.typeOrm.dataSource.transaction(async (manager) => {
const order = await manager.findOneByOrFail(Order, { id: orderId });
if (order.status !== 'pending') {
throw new Error(`Order ${orderId} is already ${order.status}`);
}
await manager.update(Order, { id: orderId }, { status: 'paid' });
await manager.save(Payment, manager.create(Payment, { orderId, amount }));
});
return { success: true };
} catch (err) {
// Transaction was automatically rolled back
return { success: false, error: (err as Error).message };
}
}
}
Error Handling
The base repository's handleError maps common TypeORM/database errors:
- 23505 — Unique constraint violation
- 23503 — Foreign key constraint violation
- EntityNotFoundError — Record not found
You can catch and handle these in services or let them bubble as readable errors.
Best Practices
- Use entities — Define TypeORM entities with decorators; register them in DataSource options (or use globs in
forRoot). - One repository per entity — Extend
BaseRepository<YourEntity>and add custom methods. - Use @InjectRepository — Let the container inject repositories so they stay testable.
- Transactions — Use
dataSource.transaction()for multi-step operations that must be atomic. - Avoid synchronize in production — Use migrations; set
synchronize: falsein production.