In the ground since Sat Nov 08 2025
Last watered inSat Nov 08 2025
Recap
A NestJS module encapsulates related functionality (controller + service + dependencies) into a reusable, isolated unit. Creating a new module involves 4 files: the module definition, controller, service, and registration in the app module.
Real Example: Created sdk-runtime module for Peek-a-boo to separate runtime API endpoints from admin endpoints, demonstrating proper separation of concerns in NestJS.
Why Create a Module?
Modules help organize code by domain/feature:
- Separation of Concerns - Runtime API vs Admin API
- Dependency Management - Each module declares its own dependencies
- Reusability - Modules can be imported by other modules
- Testability - Isolated modules are easier to test
When to Create a New Module?
Create a new module when:
- Different Domain - User management vs feature flags vs payments
- Different Auth Requirements - Public API vs admin API
- Different Responsibility - CRUD operations vs analytics vs webhooks
- Different Client - Dashboard vs mobile app vs SDK
Example from Peek-a-boo:
- feature-flags module → Admin CRUD operations (dashboard)
- sdk-runtime module → Runtime flag fetching (client SDKs)
Same domain (feature flags), but different purposes warrant separate modules.
Anatomy of a NestJS Module
Directory Structure
1src/domains/
2├── feature-flags/ # Existing admin module
3│ ├── feature-flags.controller.ts
4│ ├── feature-flags.service.ts
5│ └── feature-flags.module.ts
6└── sdk-runtime/ # New runtime module
7 ├── sdk-runtime.controller.ts
8 ├── sdk-runtime.service.ts
9 └── sdk-runtime.module.ts
The Four Essential Files
1. Service (Business Logic)
1// sdk-runtime.service.ts
2import { Injectable } from '@nestjs/common';
3import { PrismaService } from '@/prisma/prisma.service';
4import type { FeatureFlag, Environment } from '@peek-a-boo/core';
5
6@Injectable()
7export class SdkRuntimeService {
8 constructor(private prismaService: PrismaService) {}
9
10 async getFlagsByEnvironment(
11 projectId: string,
12 environment: Environment,
13 ): Promise<FeatureFlag[]> {
14 return this.prismaService.featureFlag.findMany({
15 where: {
16 projectId,
17 environment,
18 },
19 });
20 }
21
22 async getFlagByKey(
23 projectId: string,
24 key: string,
25 environment: Environment,
26 ): Promise<FeatureFlag | null> {
27 return this.prismaService.featureFlag.findFirst({
28 where: {
29 projectId,
30 key,
31 environment,
32 },
33 });
34 }
35}
Key Points:
- @Injectable() decorator makes it available for dependency injection
- Constructor injection for dependencies (PrismaService)
- Pure business logic - no HTTP concerns
- Typed return values for type safety
2. Controller (HTTP Layer)
1// sdk-runtime.controller.ts
2import { Controller, Get, Query, Param, BadRequestException } from '@nestjs/common';
3import { SdkRuntimeService } from './sdk-runtime.service';
4import type { FeatureFlag, Environment } from '@peek-a-boo/core';
5
6@Controller('api/v1')
7export class SdkRuntimeController {
8 constructor(private readonly sdkRuntimeService: SdkRuntimeService) {}
9
10 @Get('flags')
11 async getFlags(
12 @Query('projectId') projectId: string,
13 @Query('environment') environment: string,
14 ): Promise<{ flags: FeatureFlag[]; environment: string }> {
15 // Validation
16 if (!projectId) {
17 throw new BadRequestException('projectId query parameter is required');
18 }
19
20 if (!environment) {
21 throw new BadRequestException('environment query parameter is required');
22 }
23
24 // Validate environment enum
25 const validEnvironments = ['DEVELOPMENT', 'STAGING', 'PRODUCTION'];
26 const env = environment.toUpperCase();
27 if (!validEnvironments.includes(env)) {
28 throw new BadRequestException(
29 `Invalid environment. Must be one of: ${validEnvironments.join(', ')}`,
30 );
31 }
32
33 const flags = await this.sdkRuntimeService.getFlagsByEnvironment(
34 projectId,
35 env as Environment,
36 );
37
38 return {
39 flags,
40 environment: env,
41 };
42 }
43
44 @Get('flags/:key')
45 async getFlag(
46 @Param('key') key: string,
47 @Query('projectId') projectId: string,
48 @Query('environment') environment: string,
49 ): Promise<FeatureFlag> {
50 if (!projectId) {
51 throw new BadRequestException('projectId query parameter is required');
52 }
53
54 if (!environment) {
55 throw new BadRequestException('environment query parameter is required');
56 }
57
58 const validEnvironments = ['DEVELOPMENT', 'STAGING', 'PRODUCTION'];
59 const env = environment.toUpperCase();
60 if (!validEnvironments.includes(env)) {
61 throw new BadRequestException(
62 `Invalid environment. Must be one of: ${validEnvironments.join(', ')}`,
63 );
64 }
65
66 const flag = await this.sdkRuntimeService.getFlagByKey(
67 projectId,
68 key,
69 env as Environment,
70 );
71
72 if (!flag) {
73 throw new BadRequestException(
74 `Flag with key "${key}" not found in ${env} environment`,
75 );
76 }
77
78 return flag;
79 }
80}
Key Points:
- @Controller('api/v1') sets the base route
- @Get('flags') creates route: GET /api/v1/flags
- @Query() extracts query parameters
- @Param() extracts route parameters
- Validation happens here, not in service
- Service is injected via constructor
3. Module Definition
1// sdk-runtime.module.ts
2import { Module } from '@nestjs/common';
3import { SdkRuntimeController } from './sdk-runtime.controller';
4import { SdkRuntimeService } from './sdk-runtime.service';
5import { PrismaModule } from '@/prisma/prisma.module';
6
7@Module({
8 imports: [PrismaModule], // Modules this module depends on
9 controllers: [SdkRuntimeController], // HTTP controllers
10 providers: [SdkRuntimeService], // Services/providers
11 exports: [SdkRuntimeService], // What other modules can import
12})
13export class SdkRuntimeModule {}
Key Points:
- imports - Modules needed by this module
- controllers - HTTP endpoints
- providers - Services, repositories, etc.
- exports - Make service available to other modules (optional)
4. App Module Registration
1// app.module.ts
2import { Module } from '@nestjs/common';
3import { ConfigModule } from '@nestjs/config';
4import { PrismaModule } from './prisma/prisma.module';
5import { FeatureFlagsModule } from '@/domains/feature-flags/feature-flags.module';
6import { SdkRuntimeModule } from '@/domains/sdk-runtime/sdk-runtime.module';
7
8@Module({
9 imports: [
10 ConfigModule.forRoot({
11 isGlobal: true,
12 }),
13 PrismaModule,
14 FeatureFlagsModule,
15 SdkRuntimeModule, // ← Register new module here
16 ],
17})
18export class AppModule {}
Step-by-Step Creation Process
Step 1: Create Directory
1mkdir -p apps/sdk-service/src/domains/sdk-runtime
Step 2: Create Service
Create sdk-runtime.service.ts with:
- @Injectable() decorator
- Constructor dependencies
- Business logic methods
- Typed return values
Step 3: Create Controller
Create sdk-runtime.controller.ts with:
- @Controller('route') decorator
- Route handlers with @Get(), @Post(), etc.
- Service injection via constructor
- Input validation
- HTTP-specific error handling
Step 4: Create Module
Create sdk-runtime.module.ts with:
- @Module() decorator
- Import dependencies
- Declare controllers and providers
- Export what's needed
Step 5: Register in App Module
Add new module to app.module.ts imports array.
Step 6: Test
1# Development server should auto-reload
2# Test endpoints:
3curl http://localhost:6001/api/v1/flags?projectId=xxx&environment=development
Common Patterns
Pattern 1: Shared Dependencies
If multiple modules need the same service:
1// shared.module.ts
2@Module({
3 providers: [SharedService],
4 exports: [SharedService], // Make available to importers
5})
6export class SharedModule {}
7
8// feature.module.ts
9@Module({
10 imports: [SharedModule], // Import to use SharedService
11 providers: [FeatureService],
12})
13export class FeatureModule {}
Pattern 2: Global Modules
For truly global services (config, logging):
1@Global() // ← Makes module available everywhere
2@Module({
3 providers: [ConfigService],
4 exports: [ConfigService],
5})
6export class ConfigModule {}
Pattern 3: Dynamic Modules
For modules that need configuration:
1@Module({})
2export class DatabaseModule {
3 static forRoot(options: DatabaseOptions): DynamicModule {
4 return {
5 module: DatabaseModule,
6 providers: [
7 {
8 provide: 'DATABASE_OPTIONS',
9 useValue: options,
10 },
11 DatabaseService,
12 ],
13 exports: [DatabaseService],
14 };
15 }
16}
17
18// Usage
19@Module({
20 imports: [
21 DatabaseModule.forRoot({ host: 'localhost', port: 5432 })
22 ],
23})
24export class AppModule {}
Dependency Injection Explained
How It Works
1// 1. Service declares it's injectable
2@Injectable()
3export class MyService {
4 someMethod() { }
5}
6
7// 2. Module registers it as provider
8@Module({
9 providers: [MyService],
10})
11export class MyModule {}
12
13// 3. Other classes can inject it
14@Injectable()
15export class OtherService {
16 constructor(private myService: MyService) {}
17 // ↑ NestJS automatically provides instance
18}
The Magic
NestJS:
- Scans all modules on startup
- Creates a dependency graph
- Instantiates services in correct order
- Injects dependencies via constructor
- Manages lifecycle (singleton by default)
Best Practices
1. One Responsibility Per Module
❌ Bad: CommonModule with unrelated stuff
✅ Good: AuthModule, UserModule, EmailModule
2. Controller Does HTTP, Service Does Logic
❌ Bad:
1@Get()
2async getUsers() {
3 // Database queries in controller
4 return this.prismaService.user.findMany();
5}
✅ Good:
1@Get()
2async getUsers() {
3 return this.userService.findAll(); // Delegate to service
4}
3. Validate in Controller, Not Service
❌ Bad:
1// In service
2async getFlag(key: string) {
3 if (!key) throw new Error('Key required'); // HTTP concern in service
4}
✅ Good:
1// In controller
2@Get(':key')
3async getFlag(@Param('key') key: string) {
4 if (!key) throw new BadRequestException('Key required');
5 return this.service.getFlag(key);
6}
4. Type Everything
1// Service method
2async getFlagsByEnvironment(
3 projectId: string,
4 environment: Environment,
5): Promise<FeatureFlag[]> { // ← Typed return
6 // ...
7}
5. Export Only What's Needed
1@Module({
2 providers: [ServiceA, ServiceB],
3 exports: [ServiceA], // Only ServiceA is public
4})
Testing Your Module
Unit Test the Service
1import { Test, TestingModule } from '@nestjs/testing';
2import { SdkRuntimeService } from './sdk-runtime.service';
3import { PrismaService } from '@/prisma/prisma.service';
4
5describe('SdkRuntimeService', () => {
6 let service: SdkRuntimeService;
7 let prisma: PrismaService;
8
9 beforeEach(async () => {
10 const module: TestingModule = await Test.createTestingModule({
11 providers: [
12 SdkRuntimeService,
13 {
14 provide: PrismaService,
15 useValue: {
16 featureFlag: {
17 findMany: jest.fn(),
18 findFirst: jest.fn(),
19 },
20 },
21 },
22 ],
23 }).compile();
24
25 service = module.get<SdkRuntimeService>(SdkRuntimeService);
26 prisma = module.get<PrismaService>(PrismaService);
27 });
28
29 it('should fetch flags by environment', async () => {
30 const mockFlags = [{ key: 'test', enabled: true }];
31 jest.spyOn(prisma.featureFlag, 'findMany').mockResolvedValue(mockFlags);
32
33 const result = await service.getFlagsByEnvironment('project-1', 'DEVELOPMENT');
34 expect(result).toEqual(mockFlags);
35 });
36});
Integration Test the Controller
1import { Test, TestingModule } from '@nestjs/testing';
2import { SdkRuntimeController } from './sdk-runtime.controller';
3import { SdkRuntimeService } from './sdk-runtime.service';
4
5describe('SdkRuntimeController', () => {
6 let controller: SdkRuntimeController;
7 let service: SdkRuntimeService;
8
9 beforeEach(async () => {
10 const module: TestingModule = await Test.createTestingModule({
11 controllers: [SdkRuntimeController],
12 providers: [
13 {
14 provide: SdkRuntimeService,
15 useValue: {
16 getFlagsByEnvironment: jest.fn(),
17 },
18 },
19 ],
20 }).compile();
21
22 controller = module.get<SdkRuntimeController>(SdkRuntimeController);
23 service = module.get<SdkRuntimeService>(SdkRuntimeService);
24 });
25
26 it('should return flags', async () => {
27 const mockFlags = [{ key: 'test', enabled: true }];
28 jest.spyOn(service, 'getFlagsByEnvironment').mockResolvedValue(mockFlags);
29
30 const result = await controller.getFlags('project-1', 'development');
31 expect(result.flags).toEqual(mockFlags);
32 expect(result.environment).toBe('DEVELOPMENT');
33 });
34});
Troubleshooting
"Cannot resolve dependency"
Issue: NestJS can't inject a dependency.
Solution:
- Ensure service has @Injectable() decorator
- Ensure service is in module's providers array
- If importing from another module, ensure it's exported there and imported here
"Circular dependency detected"
Issue: Module A imports Module B which imports Module A.
Solution:
- Use forwardRef():
1@Module({
2 imports: [forwardRef(() => OtherModule)],
3})
- Or refactor to eliminate circular dependency
Routes Not Working
Issue: Endpoints return 404.
Solution:
- Ensure module is imported in AppModule
- Check @Controller() route matches your URL
- Verify method decorators (@Get(), @Post(), etc.)
Commands Reference
1# Create module directory
2mkdir -p src/domains/module-name
3
4# Generate module with NestJS CLI (optional)
5nest generate module domains/module-name
6nest generate controller domains/module-name
7nest generate service domains/module-name
8
9# Test endpoints
10curl http://localhost:6001/your-route
Real-World Example: SDK Runtime Module
The sdk-runtime module demonstrates:
- ✅ Separation from admin module
- ✅ Different route prefix (/api/v1 vs /feature-flags)
- ✅ Proper dependency injection (PrismaService)
- ✅ Input validation in controller
- ✅ Business logic in service
- ✅ Type safety throughout
- ✅ Clean, testable architecture
Files created:
- sdk-runtime.service.ts - 45 lines
- sdk-runtime.controller.ts - 75 lines
- sdk-runtime.module.ts - 12 lines
- Registration in app.module.ts - 1 line
Total time to create: ~15 minutes for a production-ready module.
Key Takeaways
- Modules = Organization - Group related functionality
- Controllers = HTTP - Handle requests/responses
- Services = Logic - Business rules and data access
- DI = Magic - NestJS handles instantiation
- Testing = Isolation - Mock dependencies easily
- Separation = Maintainability - Runtime vs Admin APIs
A well-structured module is self-contained, testable, and easy to understand.