# Dependency Injection Guide
Complete guide to dependency injection patterns in Zacatl.
> **⚠️ Class Tokens vs String Tokens:** The canonical pattern uses **class-based tokens** (`@inject(MyClass)`) for automatic type safety and resolution. String tokens (`@inject("myToken")`) require manual registration and are only for advanced use cases. See [String Token Warning](#string-token-warning) below.
---
## Overview
Zacatl uses [tsyringe](https://github.com/microsoft/tsyringe) for dependency injection. Choose the approach that fits your app:
| Approach | Best For | Pros | Cons |
| --------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------- |
| **1. Decorators** | Standalone modules, libraries | ✅ Minimal setup
✅ Standard tsyringe pattern
✅ Fine-grained control | ⚠️ Manual registration calls needed |
| **2. Service Architecture** | REST APIs, microservices, full apps ⭐ | ✅ **Zero manual registration**
✅ Automatic layer wiring
✅ Best practices enforced | ⚠️ Requires layer structure |
**Recommendation:** Use **Service Architecture** for production applications — it automates everything and keeps DI complexity out of your code.
---
## Approach 1: Decorators (Recommended for Production)
**Best for:** Standard TypeScript apps compiled with `tsc`
### Setup
Ensure your `tsconfig.json` has:
```json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
```
### Basic Usage
```typescript
import {
inject,
singleton,
BadRequestError,
ValidationError,
InternalServerError,
} from '@sentzunhat/zacatl';
// Define a repository
@singleton()
class UserRepository {
async findById(id: string) {
// Implementation
return { id, name: 'John Doe' };
}
}
// Inject dependencies
@singleton()
class UserService {
constructor(
@inject(UserRepository)
private userRepo: UserRepository,
) {}
async getUser(id: string) {
const user = await this.userRepo.findById(id);
if (!user) {
throw new BadRequestError({
message: 'User not found',
reason: 'USER_NOT_FOUND',
});
}
return user;
}
}
// Multiple dependencies
@singleton()
class NotificationService {
constructor(
@inject(UserRepository)
private userRepo: UserRepository,
@inject(EmailService)
private emailService: EmailService,
@inject(LogService)
private logService: LogService,
) {}
async notifyUser(userId: string, message: string) {
const user = await this.userRepo.findById(userId);
if (!user) {
this.logService.warn(`User ${userId} not found`);
return;
}
await this.emailService.send(user.email, message);
this.logService.info(`Notification sent to ${userId}`);
}
}
```
### Manual Registration Helpers (Advanced)
If you are not using the Service architecture, prefer Zacatl's DI helper functions
over calling `container.register(...)` directly.
```typescript
import {
registerDependency,
registerSingleton,
registerValue,
resolveDependency,
} from '@sentzunhat/zacatl/dependency-injection';
registerDependency(UserService, UserService);
registerSingleton(NotificationService, NotificationService);
const userRepository = new UserRepository();
registerValue(UserRepository, userRepository);
const service = resolveDependency(UserService);
```
Use this approach only when you intentionally manage DI outside of the Service
layers. For most applications, the Service architecture is simpler and more robust.
### Real-World Example
From a production authentication service:
```typescript
import {
inject,
singleton,
BadRequestError,
ValidationError,
InternalServerError,
} from '@sentzunhat/zacatl';
@singleton()
export class AttestOptionsProviderAdapter {
constructor(
@inject(DeviceProviderAdapter)
private deviceProvider: DeviceProviderAdapter,
@inject(ChallengeRepositoryAdapter)
private challengeRepository: ChallengeRepositoryAdapter,
@inject(SessionProviderAdapter)
private sessionProvider: SessionProviderAdapter,
) {}
public async generate(input: AttestGenerateOptionsInput) {
const { token, fingerprint, tenantId, partnerId } = input;
if (!token) {
throw new BadRequestError({
message: 'invalid token',
reason: 'CLIENT_TOKEN_MISSING',
});
}
// Use injected dependencies
const device = await this.deviceProvider.get({
fingerprint,
tenancy: { partnerId, tenantId },
});
const challenge = await this.challengeRepository.create({
value: this.generateChallengeValue(),
deviceId: device.id,
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
});
return {
value: challenge.value,
token: device.token.server,
};
}
}
```
---
---
## Approach 2: Service Architecture (Full-Stack Apps)
**Best for:** REST APIs, microservices, full applications — **automatic DI with zero manual registration**
The Service architecture handles ALL DI registration automatically. Just pass your layer classes to the Service constructor, and it wires everything together.
### What Service Does Automatically
When you pass classes to the Service:
```typescript
new Service({
layers: {
domain: { providers: [OrderService, UserService] },
infrastructure: { repositories: [OrderRepo, UserRepo] },
application: {
entryPoints: { rest: { routes: [GetOrderHandler, CreateOrderHandler] } },
},
},
});
```
The Service automatically:
1. **Scans all classes** for `@singleton()` and `@inject()` decorators
2. **Registers each class** with the tsyringe container
3. **Resolves all dependencies** based on constructor `@inject()` annotations
4. **Creates instances** when layers start
5. **Injects resolved dependencies** into all handlers before routing
No manual `container.register()` calls needed.
### Domain Layer: `providers` vs `services`
Both `providers` and `services` are valid in domain layer config:
```typescript
domain: {
providers: [UserService, AuthService], // Business logic providers
services: [EmailWorker, JobProcessor], // Service/worker classes
}
```
**Use `providers`** for business logic consumed by handlers.
**Use `services`** for CLI commands, workers, or standalone services.
Both work identically - choose based on semantic meaning. See [Layer Registration](../service/layer-registration.md#domain-layer-providers-vs-services) for details.
### Basic Setup
```typescript
import { Service, singleton } from '@sentzunhat/zacatl';
// Define your classes
@singleton()
class EmailProvider {
async send(to: string, message: string) {
console.log(`Email sent to ${to}`);
}
}
@singleton()
class UserProvider {
constructor(private emailProvider: EmailProvider) {}
async createUser(email: string) {
// Create user...
await this.emailProvider.send(email, 'Welcome!');
return { id: '123', email };
}
}
// Service auto-registers everything
const service = new Service({
type: ServiceType.SERVER,
layers: {
domain: {
providers: [EmailProvider, UserProvider],
},
},
});
await service.start();
```
✅ No manual DI setup. No `container.register()`. No boilerplate.
See the [Service Adapter Pattern](../service/service-adapter-pattern.md) for full details.
---
## Common Patterns
### Pattern 1: Repository with Service
```typescript
import { inject, singleton, NotFoundError } from '@sentzunhat/zacatl';
@singleton()
class UserRepository {
async findById(id: string) {
// DB query
return { id, email: 'user@example.com' };
}
async create(data: any) {
// DB insert
return { id: 'new-id', ...data };
}
}
@singleton()
class UserService {
constructor(
@inject(UserRepository)
private userRepo: UserRepository,
) {}
async getUser(id: string) {
const user = await this.userRepo.findById(id);
if (!user) {
throw new NotFoundError({
message: 'User not found',
reason: 'USER_NOT_FOUND',
});
}
return user;
}
}
```
### Pattern 2: Multiple Dependencies
```typescript
@singleton()
class OrderService {
constructor(
@inject(OrderRepository)
private orderRepo: OrderRepository,
@inject(PaymentProvider)
private paymentProvider: PaymentProvider,
@inject(EmailProvider)
private emailProvider: EmailProvider,
@inject(Logger)
private logger: Logger,
) {}
async createOrder(userId: string, items: Item[]) {
this.logger.info(`Creating order for user ${userId}`);
const order = await this.orderRepo.create({ userId, items });
await this.paymentProvider.charge(order.total);
await this.emailProvider.sendOrderConfirmation(userId, order);
return order;
}
}
```
### Pattern 3: Abstract-Class DI (Manual Binding)
```typescript
import { inject, singleton, container } from '@sentzunhat/zacatl';
// Define abstract token
abstract class NotificationService {
abstract send(userId: string, message: string): Promise;
}
// Implementations
@singleton()
class EmailNotificationService extends NotificationService {
async send(userId: string, message: string) {
console.log(`Email to ${userId}: ${message}`);
}
}
@singleton()
class SmsNotificationService extends NotificationService {
async send(userId: string, message: string) {
console.log(`SMS to ${userId}: ${message}`);
}
}
// Manual binding (must run before Service creation)
container.registerSingleton(NotificationService, EmailNotificationService);
// Inject by class token
@singleton()
class UserService {
constructor(
@inject(NotificationService)
private notificationService: NotificationService,
) {}
}
```
**Note:** Prefer class-based injection with concrete providers. Use abstract tokens only when you need a swappable contract, and bind them manually before creating a `Service`.
---
## Best Practices
### 1. Choose the Right Approach
```typescript
// ✅ Production app with tsc - use decorators
@singleton()
class MyService {
constructor(@inject(MyRepo) private repo: MyRepo) {}
}
// ✅ CLI tool with tsx - use helpers
registerSingletonWithDependencies(MyRepo);
registerSingletonWithDependencies(MyService, [MyRepo]);
// ✅ Full REST API - use Service architecture
const service = new Service({
type: ServiceType.SERVER,
layers: {
domain: { providers: [MyService, MyRepo] },
},
});
```
### 2. Keep Dependencies Explicit
```typescript
// ✅ Good - clear what's needed
@singleton()
class OrderService {
constructor(
@inject(OrderRepo) private orderRepo: OrderRepo,
@inject(PaymentService) private paymentService: PaymentService,
) {}
}
// ❌ Bad - hidden dependency
@singleton()
class OrderService {
async createOrder() {
const paymentService = resolveDependency(PaymentService);
}
}
```
### 3. Use Interfaces for Flexibility
> **⚠️ String Token Warning:** This pattern uses string tokens and requires manual registration. Only use this for advanced cases where you need runtime polymorphism. The canonical pattern uses class tokens.
```typescript
// Define contract
interface ILogger {
log(message: string): void;
}
// Multiple implementations
class ConsoleLogger implements ILogger {
log(message: string) {
console.log(message);
}
}
class FileLogger implements ILogger {
log(message: string) {
fs.appendFileSync('app.log', message + '\n');
}
}
// Manual registration with string token (advanced only)
container.register('ILogger', {
useClass: process.env.NODE_ENV === 'production' ? FileLogger : ConsoleLogger,
});
// Inject using string token
class MyService {
constructor(@inject('ILogger') private logger: ILogger) {}
}
```
**Preferred alternative using class tokens:**
```typescript
// Use abstract class instead of interface for DI
abstract class LoggerPort {
abstract log(message: string): void;
}
@singleton()
class ConsoleLogger extends LoggerPort {
log(message: string) {
console.log(message);
}
}
// Register concrete implementation
container.registerInstance(LoggerPort, new ConsoleLogger());
// Inject using class token (automatic type safety)
@singleton()
class MyService {
constructor(@inject(LoggerPort) private logger: LoggerPort) {}
}
```
### 4. Avoid Circular Dependencies
```typescript
// ❌ Bad - circular dependency
@singleton()
class UserService {
constructor(@inject(OrderService) private orderService: OrderService) {}
}
@singleton()
class OrderService {
constructor(@inject(UserService) private userService: UserService) {}
}
// ✅ Good - introduce a third service
@singleton()
class UserOrderCoordinator {
constructor(
@inject(UserService) private userService: UserService,
@inject(OrderService) private orderService: OrderService,
) {}
}
```
---
## Troubleshooting
### "TypeInfo not known" Errors
**Problem:** Getting errors like `TypeInfo not known for "MyClass"` when using tsx or ts-node
**Solution:** Use helper functions instead of decorators:
```typescript
// Instead of decorators
import { registerSingletonWithDependencies } from '@sentzunhat/zacatl';
registerSingletonWithDependencies(MyRepository);
registerSingletonWithDependencies(MyService, [MyRepository]);
```
### Dependency Not Registered
**Problem:** `Error: Dependency X not registered`
**Solution:** Register dependencies **before** dependents:
```typescript
// ✅ Correct order
registerSingletonWithDependencies(Logger);
registerSingletonWithDependencies(Database, [Logger]);
registerSingletonWithDependencies(Service, [Database, Logger]);
```
### Getting Different Instances
**Problem:** Expecting singleton behavior but getting new instances
**Solution:** Use `registerSingletonWithDependencies` not `registerWithDependencies`:
```typescript
// ✅ Singleton - same instance
registerSingletonWithDependencies(MyService, []);
// ❌ Transient - new instances
registerWithDependencies(MyService, []);
```
---
## String Token Warning
**String tokens are for advanced/manual use only.** The canonical Zacatl pattern uses **class-based tokens** for automatic type safety.
### ❌ Avoid String Tokens
```typescript
// String token - requires manual registration, no type safety
container.registerInstance('MyRepository', new MyRepositoryAdapter());
@singleton()
class MyService {
constructor(@inject('MyRepository') private repo: any) {}
// ^^^^^^^^^^^^^^^^ ^^^ - loses type safety
}
```
### ✅ Use Class Tokens
```typescript
// Class token - automatic type resolution and type safety
container.registerInstance(MyRepositoryAdapter, new MyRepositoryAdapter());
@singleton()
class MyService {
constructor(@inject(MyRepositoryAdapter) private repo: MyRepositoryAdapter) {}
// ^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^ - full type safety
}
```
**When to use abstract classes instead of interfaces:**
```typescript
// Abstract class can be used as a DI token
abstract class RepositoryPort {
abstract findById(id: string): Promise;
}
// Concrete implementation
@singleton()
class SequelizeRepository extends RepositoryPort {
async findById(id: string) {
/* ... */
}
}
// Register and inject using class token
container.registerInstance(RepositoryPort, new SequelizeRepository());
@singleton()
class Service {
constructor(@inject(RepositoryPort) private repo: RepositoryPort) {}
}
```
**Only use string tokens when:**
- You need runtime polymorphism that can't be achieved with class tokens
- You're integrating with third-party code that requires string tokens
- You understand you must manually register every string token before use
---
## Migration from Older Versions
### From Manual DI
**Before:**
```typescript
container.register(Logger, { useClass: Logger });
container.register(Database, {
useFactory: (c) => new Database(c.resolve(Logger)),
});
```
**After:**
```typescript
registerSingletonWithDependencies(Logger);
registerSingletonWithDependencies(Database, [Logger]);
```
---
---
## Importing from Zacatl
**Recommended:** Import everything from `@sentzunhat/zacatl`:
```typescript
import {
// DI decorators and container
inject,
singleton,
container,
// Errors
BadRequestError,
ValidationError,
NotFoundError,
// Other utilities
Service,
BaseRepository,
} from '@sentzunhat/zacatl';
```
This ensures you're using the correct versions bundled with Zacatl. You can also import directly from `tsyringe` if needed, but the Zacatl exports are recommended for consistency.
---
## See Also
- [Service Adapter Pattern](../service/service-adapter-pattern.md)
- [tsyringe Documentation](https://github.com/microsoft/tsyringe)
- [Error Handling](../error/README.md)
- [Examples](../../examples/)