docs: extract DI pattern guide to separate file with moduleLoader pattern (#26756)
* docs: add moduleLoader pattern documentation to knowledge-base Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * docs: rewrite DI section to use moduleLoader pattern as primary approach - Replace manual module loading examples with moduleLoader pattern - Add Mistake 4 about manual module loading being not type-safe - Update Core Concepts to explain moduleLoader components - Show both depsMap (multiple deps) and dep (single dep) usage - Emphasize build-time type safety throughout Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * docs: extract DI pattern to separate file - Move DI documentation from knowledge-base.md to di-pattern.md - Add brief reference in knowledge-base.md pointing to the new file - Keeps knowledge-base.md focused on quick notes while DI gets its own detailed guide Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
# Dependency Injection (DI) Pattern
|
||||
|
||||
We use a Dependency Injection pattern powered by `@evyweb/ioctopus` to manage service and repository dependencies. This pattern ensures proper dependency management, testability, and consistent instantiation of services throughout the codebase.
|
||||
|
||||
We use the **moduleLoader pattern** which provides type-safe dependency injection. This ensures that if a service adds a new dependency, TypeScript will catch missing dependencies at build time rather than runtime.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
The DI system consists of four main components:
|
||||
|
||||
**Tokens**: Unique symbols that identify each service or repository in the DI container. Every injectable class needs a corresponding token. Tokens should be defined in a `tokens.ts` file within the feature's `di/` directory.
|
||||
|
||||
**Modules** (`.module.ts` files): Define how classes are instantiated and what dependencies they require using the `bindModuleToClassOnToken` function. Each module exports a `moduleLoader` object that knows how to load itself and its dependencies.
|
||||
|
||||
**Containers** (`.container.ts` files): Create a container instance and expose getter functions that consumers use to obtain service instances. Containers use the moduleLoader to automatically load all required dependencies.
|
||||
|
||||
**`bindModuleToClassOnToken`**: A type-safe function that binds a class to a token and declares its dependencies. TypeScript ensures the declared dependencies match what the class constructor expects.
|
||||
|
||||
## How It Works
|
||||
|
||||
Here's the complete flow for creating a DI-enabled service:
|
||||
|
||||
**Step 1: Create tokens in the feature's di directory**
|
||||
|
||||
```typescript
|
||||
// packages/features/myfeature/di/tokens.ts
|
||||
export const MY_FEATURE_DI_TOKENS = {
|
||||
MY_SERVICE: Symbol("MyService"),
|
||||
MY_SERVICE_MODULE: Symbol("MyServiceModule"),
|
||||
};
|
||||
```
|
||||
|
||||
Then import these tokens in the central tokens file:
|
||||
|
||||
```typescript
|
||||
// packages/features/di/tokens.ts
|
||||
import { MY_FEATURE_DI_TOKENS } from "@calcom/features/myfeature/di/tokens";
|
||||
|
||||
export const DI_TOKENS = {
|
||||
// ...existing tokens
|
||||
...MY_FEATURE_DI_TOKENS,
|
||||
};
|
||||
```
|
||||
|
||||
**Step 2: Define the service class with constructor injection**
|
||||
|
||||
For services with multiple dependencies, use a dependencies interface:
|
||||
|
||||
```typescript
|
||||
// packages/features/myfeature/services/MyService.ts
|
||||
export interface IMyServiceDeps {
|
||||
bookingRepo: BookingRepository;
|
||||
userRepo: UserRepository;
|
||||
}
|
||||
|
||||
export class MyService {
|
||||
constructor(private deps: IMyServiceDeps) {}
|
||||
|
||||
async doSomething() {
|
||||
const bookings = await this.deps.bookingRepo.findMany({...});
|
||||
const user = await this.deps.userRepo.findById({...});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For services/repositories with a single dependency, pass it directly:
|
||||
|
||||
```typescript
|
||||
// packages/features/myfeature/repositories/MyRepository.ts
|
||||
export class MyRepository {
|
||||
constructor(private prismaClient: PrismaClient) {}
|
||||
|
||||
async findById(id: string) {
|
||||
return this.prismaClient.myModel.findUnique({ where: { id } });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Create a module file with moduleLoader**
|
||||
|
||||
```typescript
|
||||
// packages/features/myfeature/di/MyService.module.ts
|
||||
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
|
||||
import { MyService } from "@calcom/features/myfeature/services/MyService";
|
||||
|
||||
import { moduleLoader as bookingRepositoryModuleLoader } from "./BookingRepository.module";
|
||||
import { moduleLoader as userRepositoryModuleLoader } from "./UserRepository.module";
|
||||
import { MY_FEATURE_DI_TOKENS } from "./tokens";
|
||||
|
||||
const thisModule = createModule();
|
||||
const token = MY_FEATURE_DI_TOKENS.MY_SERVICE;
|
||||
const moduleToken = MY_FEATURE_DI_TOKENS.MY_SERVICE_MODULE;
|
||||
|
||||
const loadModule = bindModuleToClassOnToken({
|
||||
module: thisModule,
|
||||
moduleToken,
|
||||
token,
|
||||
classs: MyService,
|
||||
// For multiple dependencies, use depsMap:
|
||||
depsMap: {
|
||||
bookingRepo: bookingRepositoryModuleLoader,
|
||||
userRepo: userRepositoryModuleLoader,
|
||||
},
|
||||
});
|
||||
|
||||
export const moduleLoader: ModuleLoader = {
|
||||
token,
|
||||
loadModule,
|
||||
};
|
||||
|
||||
export type { MyService };
|
||||
```
|
||||
|
||||
For a single dependency, use `dep` instead of `depsMap`:
|
||||
|
||||
```typescript
|
||||
// packages/features/myfeature/di/MyRepository.module.ts
|
||||
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
|
||||
import { MyRepository } from "@calcom/features/myfeature/repositories/MyRepository";
|
||||
|
||||
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
|
||||
import { MY_FEATURE_DI_TOKENS } from "./tokens";
|
||||
|
||||
const thisModule = createModule();
|
||||
const token = MY_FEATURE_DI_TOKENS.MY_REPOSITORY;
|
||||
const moduleToken = MY_FEATURE_DI_TOKENS.MY_REPOSITORY_MODULE;
|
||||
|
||||
const loadModule = bindModuleToClassOnToken({
|
||||
module: thisModule,
|
||||
moduleToken,
|
||||
token,
|
||||
classs: MyRepository,
|
||||
// For single dependency, use dep:
|
||||
dep: prismaModuleLoader,
|
||||
});
|
||||
|
||||
export const moduleLoader: ModuleLoader = {
|
||||
token,
|
||||
loadModule,
|
||||
};
|
||||
|
||||
export type { MyRepository };
|
||||
```
|
||||
|
||||
**Step 4: Create a container that uses the moduleLoader**
|
||||
|
||||
```typescript
|
||||
// packages/features/myfeature/di/MyService.container.ts
|
||||
import { createContainer } from "@calcom/features/di/di";
|
||||
|
||||
import { type MyService, moduleLoader as myServiceModuleLoader } from "./MyService.module";
|
||||
|
||||
const myServiceContainer = createContainer();
|
||||
|
||||
export function getMyService(): MyService {
|
||||
myServiceModuleLoader.loadModule(myServiceContainer);
|
||||
return myServiceContainer.get<MyService>(myServiceModuleLoader.token);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Use the service via the container's getter function**
|
||||
|
||||
```typescript
|
||||
// Anywhere in the codebase
|
||||
import { getMyService } from "@calcom/features/myfeature/di/MyService.container";
|
||||
|
||||
const myService = getMyService();
|
||||
await myService.doSomething();
|
||||
```
|
||||
|
||||
## Why moduleLoader Is Type-Safe
|
||||
|
||||
The `bindModuleToClassOnToken` function in `packages/features/di/di.ts` uses TypeScript generics to infer the required dependencies from the class constructor:
|
||||
|
||||
```typescript
|
||||
depsMap: Record<
|
||||
keyof (TClass extends new (deps: infer TDeps) => any ? TDeps : never),
|
||||
ModuleLoader
|
||||
>
|
||||
```
|
||||
|
||||
This means if `MyService` adds a new constructor dependency (e.g., `eventTypeRepo`), TypeScript will error because `depsMap` is missing that key. The error happens at build time, not runtime.
|
||||
|
||||
The `loadModule` function also automatically loads all dependencies recursively, so you don't need to manually track the dependency chain.
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
**Mistake 1: Creating a repository or service class with all static methods**
|
||||
|
||||
Static methods bypass the DI system entirely, making the code harder to test and breaking the dependency chain.
|
||||
|
||||
```typescript
|
||||
// Bad - Static methods bypass DI
|
||||
export class BookingRepository {
|
||||
static async findById(id: string) {
|
||||
return prisma.booking.findUnique({ where: { id } });
|
||||
}
|
||||
}
|
||||
|
||||
// Usage (wrong - no DI)
|
||||
const booking = await BookingRepository.findById("123");
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Good - Instance methods with constructor injection
|
||||
export class BookingRepository {
|
||||
constructor(private prismaClient: PrismaClient) {}
|
||||
|
||||
async findById(id: string) {
|
||||
return this.prismaClient.booking.findUnique({ where: { id } });
|
||||
}
|
||||
}
|
||||
|
||||
// Usage (correct - via DI container)
|
||||
const bookingRepo = getBookingRepository();
|
||||
const booking = await bookingRepo.findById("123");
|
||||
```
|
||||
|
||||
**Mistake 2: Manually instantiating a class instead of using the DI container**
|
||||
|
||||
Even if you define a class with constructor injection, manually calling `new` bypasses the DI system and its benefits.
|
||||
|
||||
```typescript
|
||||
// Bad - Manual instantiation bypasses DI
|
||||
import { MyService } from "@calcom/features/myfeature/services/MyService";
|
||||
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const bookingRepo = new BookingRepository(prisma);
|
||||
const myService = new MyService({ bookingRepo });
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Good - Use the DI container's getter function
|
||||
import { getMyService } from "@calcom/features/myfeature/di/MyService.container";
|
||||
|
||||
const myService = getMyService();
|
||||
```
|
||||
|
||||
**Mistake 3: Importing Prisma directly in a service instead of using repository injection**
|
||||
|
||||
Services should depend on repositories, not directly on Prisma. This maintains proper separation of concerns.
|
||||
|
||||
```typescript
|
||||
// Bad - Service imports Prisma directly
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export class MyService {
|
||||
async doSomething() {
|
||||
const bookings = await prisma.booking.findMany({...}); // Wrong!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Good - Service depends on repository via DI
|
||||
export interface IMyServiceDeps {
|
||||
bookingRepo: BookingRepository;
|
||||
}
|
||||
|
||||
export class MyService {
|
||||
constructor(private deps: IMyServiceDeps) {}
|
||||
|
||||
async doSomething() {
|
||||
const bookings = await this.deps.bookingRepo.findMany({...});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Mistake 4: Manual module loading in containers (not type-safe)**
|
||||
|
||||
Don't manually call `container.load()` for each dependency. This is not type-safe and can lead to runtime errors if you forget to load a dependency.
|
||||
|
||||
```typescript
|
||||
// Bad - Manual module loading is not type-safe
|
||||
const container = createContainer();
|
||||
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
|
||||
container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule);
|
||||
container.load(DI_TOKENS.MY_SERVICE_MODULE, myServiceModule);
|
||||
// If MyService adds a new dependency, TypeScript won't catch the missing load()
|
||||
|
||||
export function getMyService() {
|
||||
return container.get<MyService>(DI_TOKENS.MY_SERVICE);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Good - Use moduleLoader for type-safe dependency loading
|
||||
import { moduleLoader as myServiceModuleLoader } from "./MyService.module";
|
||||
|
||||
const container = createContainer();
|
||||
|
||||
export function getMyService(): MyService {
|
||||
myServiceModuleLoader.loadModule(container); // Automatically loads all dependencies
|
||||
return container.get<MyService>(myServiceModuleLoader.token);
|
||||
}
|
||||
```
|
||||
|
||||
## Why Use DI?
|
||||
|
||||
- **Build-time safety**: TypeScript catches missing dependencies before runtime when using the moduleLoader pattern
|
||||
- **Testability**: Dependencies can be easily mocked in tests by providing alternative implementations
|
||||
- **Consistency**: All instances are created the same way with proper dependencies
|
||||
- **Maintainability**: Changing a dependency only requires updating the module binding, not every usage site
|
||||
- **Automatic dependency resolution**: The moduleLoader automatically loads all dependencies recursively
|
||||
- **Self-documenting**: Each module declares its own dependencies, making the dependency graph explicit
|
||||
+1
-203
@@ -734,206 +734,4 @@ The main reason for doing so is that Dayjs uses a useful, but highly risky plugi
|
||||
|
||||
## Dependency Injection (DI) Pattern
|
||||
|
||||
We use a Dependency Injection pattern powered by `@evyweb/ioctopus` to manage service and repository dependencies. This pattern ensures proper dependency management, testability, and consistent instantiation of services throughout the codebase.
|
||||
|
||||
### Core Concepts
|
||||
|
||||
The DI system consists of three main components:
|
||||
|
||||
**Tokens** (`packages/features/di/tokens.ts`): Unique symbols that identify each service or repository in the DI container. Every injectable class needs a corresponding token.
|
||||
|
||||
**Modules** (`packages/features/di/modules/*.ts`): Define how classes are instantiated and what dependencies they require. Modules bind tokens to class implementations.
|
||||
|
||||
**Containers** (`packages/features/di/containers/*.ts`): Assemble modules together and expose getter functions that consumers use to obtain service instances.
|
||||
|
||||
### How It Works
|
||||
|
||||
Here's the flow using `BusyTimesService` as an example:
|
||||
|
||||
**Step 1: Define the service class with constructor injection**
|
||||
|
||||
```typescript
|
||||
// packages/features/busyTimes/services/getBusyTimes.ts
|
||||
export interface IBusyTimesService {
|
||||
bookingRepo: BookingRepository;
|
||||
}
|
||||
|
||||
export class BusyTimesService {
|
||||
constructor(public readonly dependencies: IBusyTimesService) {}
|
||||
|
||||
async getBusyTimes(params: {...}) {
|
||||
// Use dependencies via this.dependencies.bookingRepo
|
||||
const bookings = await this.dependencies.bookingRepo.findAllExistingBookingsForEventTypeBetween({...});
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Create a module that binds the service to its token**
|
||||
|
||||
```typescript
|
||||
// packages/features/di/modules/BusyTimes.ts
|
||||
import { BusyTimesService } from "@calcom/features/busyTimes/services/getBusyTimes";
|
||||
import { createModule } from "../di";
|
||||
import { DI_TOKENS } from "../tokens";
|
||||
|
||||
export const busyTimesModule = createModule();
|
||||
busyTimesModule.bind(DI_TOKENS.BUSY_TIMES_SERVICE).toClass(BusyTimesService, {
|
||||
bookingRepo: DI_TOKENS.BOOKING_REPOSITORY,
|
||||
} satisfies Record<keyof IBusyTimesService, symbol>);
|
||||
```
|
||||
|
||||
**Step 3: Create a container that loads all required modules and exposes a getter**
|
||||
|
||||
```typescript
|
||||
// packages/features/di/containers/BusyTimes.ts
|
||||
import type { BusyTimesService } from "@calcom/features/busyTimes/services/getBusyTimes";
|
||||
import { DI_TOKENS } from "@calcom/features/di/tokens";
|
||||
import { prismaModule } from "@calcom/features/di/modules/Prisma";
|
||||
import { createContainer } from "../di";
|
||||
import { bookingRepositoryModule } from "../modules/Booking";
|
||||
import { busyTimesModule } from "../modules/BusyTimes";
|
||||
|
||||
const container = createContainer();
|
||||
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
|
||||
container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule);
|
||||
container.load(DI_TOKENS.BUSY_TIMES_SERVICE_MODULE, busyTimesModule);
|
||||
|
||||
export function getBusyTimesService() {
|
||||
return container.get<BusyTimesService>(DI_TOKENS.BUSY_TIMES_SERVICE);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Use the service via the container's getter function**
|
||||
|
||||
```typescript
|
||||
// Anywhere in the codebase
|
||||
import { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes";
|
||||
|
||||
const busyTimesService = getBusyTimesService();
|
||||
const busyTimes = await busyTimesService.getBusyTimes({...});
|
||||
```
|
||||
|
||||
### Common Mistakes to Avoid
|
||||
|
||||
**Mistake 1: Creating a repository or service class with all static methods**
|
||||
|
||||
Static methods bypass the DI system entirely, making the code harder to test and breaking the dependency chain.
|
||||
|
||||
```typescript
|
||||
// ❌ Bad - Static methods bypass DI
|
||||
export class BookingRepository {
|
||||
static async findById(id: string) {
|
||||
return prisma.booking.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
static async create(data: BookingCreateInput) {
|
||||
return prisma.booking.create({ data });
|
||||
}
|
||||
}
|
||||
|
||||
// Usage (wrong - no DI)
|
||||
const booking = await BookingRepository.findById("123");
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Instance methods with constructor injection
|
||||
export class BookingRepository {
|
||||
constructor(private prismaClient: PrismaClient) {}
|
||||
|
||||
async findById(id: string) {
|
||||
return this.prismaClient.booking.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async create(data: BookingCreateInput) {
|
||||
return this.prismaClient.booking.create({ data });
|
||||
}
|
||||
}
|
||||
|
||||
// Usage (correct - via DI container)
|
||||
const bookingRepo = getBookingRepository();
|
||||
const booking = await bookingRepo.findById("123");
|
||||
```
|
||||
|
||||
**Mistake 2: Manually instantiating a class instead of using the DI container**
|
||||
|
||||
Even if you define a class with constructor injection, manually calling `new` bypasses the DI system and its benefits.
|
||||
|
||||
```typescript
|
||||
// ❌ Bad - Manual instantiation bypasses DI
|
||||
import { BusyTimesService } from "@calcom/features/busyTimes/services/getBusyTimes";
|
||||
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
// Wrong: manually creating instances
|
||||
const bookingRepo = new BookingRepository(prisma);
|
||||
const busyTimesService = new BusyTimesService({ bookingRepo });
|
||||
const busyTimes = await busyTimesService.getBusyTimes({...});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Use the DI container's getter function
|
||||
import { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes";
|
||||
|
||||
// Correct: let the container manage instantiation
|
||||
const busyTimesService = getBusyTimesService();
|
||||
const busyTimes = await busyTimesService.getBusyTimes({...});
|
||||
```
|
||||
|
||||
**Mistake 3: Importing Prisma directly in a service instead of using repository injection**
|
||||
|
||||
Services should depend on repositories, not directly on Prisma. This maintains proper separation of concerns.
|
||||
|
||||
```typescript
|
||||
// ❌ Bad - Service imports Prisma directly
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export class MyService {
|
||||
async doSomething() {
|
||||
const bookings = await prisma.booking.findMany({...}); // Wrong!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Service depends on repository via DI
|
||||
export interface IMyService {
|
||||
bookingRepo: BookingRepository;
|
||||
}
|
||||
|
||||
export class MyService {
|
||||
constructor(public readonly dependencies: IMyService) {}
|
||||
|
||||
async doSomething() {
|
||||
const bookings = await this.dependencies.bookingRepo.findMany({...});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Adding a New Service to the DI System
|
||||
|
||||
When creating a new service or repository that should use DI:
|
||||
|
||||
1. **Add tokens** to `packages/features/di/tokens.ts`:
|
||||
```typescript
|
||||
export const DI_TOKENS = {
|
||||
// ...existing tokens
|
||||
MY_SERVICE: Symbol("MyService"),
|
||||
MY_SERVICE_MODULE: Symbol("MyServiceModule"),
|
||||
};
|
||||
```
|
||||
|
||||
2. **Create the service class** with a dependencies interface and constructor injection.
|
||||
|
||||
3. **Create a module** in `packages/features/di/modules/MyService.ts` that binds the service to its token.
|
||||
|
||||
4. **Create a container** in `packages/features/di/containers/MyService.ts` that loads all required modules and exports a getter function.
|
||||
|
||||
5. **Use the getter function** everywhere you need the service - never manually instantiate.
|
||||
|
||||
### Why Use DI?
|
||||
|
||||
- **Testability**: Dependencies can be easily mocked in tests by providing alternative implementations
|
||||
- **Consistency**: All instances are created the same way with proper dependencies
|
||||
- **Maintainability**: Changing a dependency only requires updating the module binding, not every usage site
|
||||
- **Explicit dependencies**: The dependency graph is clear and documented in the module definitions
|
||||
See [di-pattern.md](./di-pattern.md) for the complete guide on using the type-safe moduleLoader pattern for dependency injection.
|
||||
|
||||
Reference in New Issue
Block a user