Resource Module Pattern
Overview
The Resource Module Pattern is a central architectural pattern in the Wrkbelt API that structures each domain entity as a cohesive module with a consistent set of components. This pattern provides a standardized approach to implementing RESTful resources, ensuring consistency, maintainability, and adherence to separation of concerns principles across the codebase.
Architecture and Design
Module Structure
Each resource module follows a consistent structure with the following components:
resource-name/
├── dtos/
│ ├── create-resource.dto.ts # DTO for create operations
│ ├── update-resource.dto.ts # DTO for update operations
│ └── other-specific-dtos.ts # Additional DTOs as needed
├── resource-name.controller.ts # HTTP endpoint definitions
├── resource-name.module.ts # Module definition and dependencies
├── resource-name.repository.ts # Data access operations
├── resource-name.schema.ts # MongoDB schema definition
├── resource-name.service.ts # Business logic
├── resource-name.controller.spec.ts # Controller tests
└── resource-name.service.spec.ts # Service tests
This structure ensures:
- Cohesion: All related components are grouped together
- Consistency: All resources follow the same pattern
- Discoverability: Developers can easily locate relevant files
- Testability: Components can be tested in isolation
Resource Composition Layers
Each resource module is composed of several layers that work together:
- Controller Layer: Defines REST endpoints and handles HTTP request/response
- Service Layer: Implements business logic and orchestrates operations
- Repository Layer: Abstracts database operations and data access by extending the BaseRepository from
libs/api/utils-api/src/lib/repository/base-repository.common.ts, which provides standardized CRUD operations, document normalization, transaction management, and other data access utilities by default - Schema Layer: Defines the data structure and validation rules
- DTO Layer: Defines data transfer objects for input/output validation
Resource Layers Diagram
Core Components
1. Module Definition
The module definition binds all components together and declares dependencies:
@Module({
imports: [
MongooseModule.forFeature([
{
name: Permission.name,
schema: PermissionSchema,
},
]),
],
providers: [PermissionService, PermissionRepository],
controllers: [PermissionController],
exports: [PermissionService, PermissionRepository],
})
export class PermissionModule {}
Key aspects:
- Imports required dependencies (e.g., Mongoose schema)
- Registers providers (service, repository)
- Registers controllers
- Exports components used by other modules
2. Controller Implementation
Controllers define the REST endpoints and handle HTTP communication:
@ApiTags(capitalizeFirstCharacter(API_RESOURCES.PERMISSIONS))
@ApiCookieAuth()
@Controller(API_RESOURCES.PERMISSIONS)
export class PermissionController {
constructor(private readonly permissionService: PermissionService) {}
@Get()
@RequirePermissions(AllPermissions.VIEW_PERMISSIONS)
async findAll() {
return this.permissionService.findAll();
}
@Get(API_ACTIONS.PERMISSION.BY_ID)
@RequirePermissions(AllPermissions.VIEW_PERMISSIONS)
async findOne(@Param('id') id: string) {
return this.permissionService.findOneById(id);
}
@Post()
@RequirePermissions(AllPermissions.CREATE_PERMISSION)
async create(@Body() createPermissionDto: CreatePermissionDto) {
return this.permissionService.create(createPermissionDto);
}
// Additional endpoints for update, delete, etc.
}
Key aspects:
- Uses NestJS decorators for HTTP methods and route paths
- Handles request/response concerns (parameters, body, status codes)
- Delegates business logic to the service layer
- Implements authorization via decorators
- Provides API documentation through Swagger decorators
3. Service Implementation
Services contain business logic and orchestrate operations:
@Injectable()
export class PermissionService {
private readonly logger = new Logger(PermissionService.name);
constructor(private readonly permissionRepository: PermissionRepository) {}
async findAll(query: FilterQuery<PermissionDocument> = {}) {
return this.permissionRepository.find(query);
}
async findOneById(id: string): Promise<PermissionDocument> {
const permission = await this.permissionRepository.findById(id);
if (!permission) {
throw new NotFoundException(`Permission with ID '${id}' not found`);
}
return permission;
}
async create(
createPermissionDto: CreatePermissionDto
): Promise<PermissionDocument> {
const permission = await this.permissionRepository.create(
createPermissionDto
);
this.logger.log(`Created permission: ${permission.name}`);
return permission;
}
// Additional methods for business operations
}
Key aspects:
- Implements domain-specific business logic
- Handles error scenarios and validation
- Delegates data access to the repository layer
- Provides a clean API for controllers
- Implements logging for important operations
4. Repository Implementation
Repositories handle data access and database operations:
@Injectable()
export class PermissionRepository
extends BaseRepository<Permission, PermissionDocument>
implements IPermissionRepository {
constructor(
@InjectModel(Permission.name)
permissionModel: Model<PermissionDocument>,
@InjectConnection() connection: Connection
) {
super(permissionModel, connection);
}
async findByName(
permissionName: AllPermissions
): Promise<PermissionDocument | null> {
const query: FilterQuery<PermissionDocument> = { name: permissionName };
return this.findOne(query);
}
async deleteByName(permissionName: AllPermissions): Promise<boolean> {
const result = await this.deleteOne({
name: permissionName,
});
return result.deletedCount > 0;
}
}
Key aspects:
- Extends the BaseRepository with entity-specific operations
- Implements the repository interface
- Handles MongoDB-specific concerns
- Inherits powerful functionality from BaseRepository:
- Standard CRUD operations (find, findOne, findById, create, update, delete)
- Document normalization via
normalizeResultandnormalizeManymethods - Transaction management with
withTransactionmethod - Entity transformation with
transformToEntitymethod
5. Schema Definition
Schemas define the data structure for MongoDB and validation rules:
@Schema({ timestamps: true })
export class Permission {
@Prop({
unique: true,
required: true,
type: String,
enum: Object.values(AllPermissions),
})
name!: AllPermissions;
@Prop()
description!: string;
}
export const PermissionSchema = SchemaFactory.createForClass(Permission);
export type PermissionDocument = HydratedDocument<Permission>;
Key aspects:
- Uses NestJS/Mongoose decorators for schema definition
- Defines properties with validation rules
- Configures MongoDB-specific options
- Links with shared entity types
6. DTO Implementation
DTOs define the structure for input and output data:
export class CreatePermissionDto implements CreatePermissionDtoBase {
@ApiProperty({
example: AllPermissions.CREATE_MEMBERSHIP,
description: 'The name of the permission',
uniqueItems: true,
})
@IsNotEmpty()
@IsEnum(AllPermissions)
name!: AllPermissions;
@ApiProperty({
example: 'Allows creating new memberships',
description: 'The description of the permission',
})
@IsNotEmpty()
@IsString()
description!: string;
}
Key aspects:
- Implements interfaces from shared library
- Uses class-validator decorators for validation
- Uses Swagger decorators for API documentation
- Provides type safety between API and service layers
Standard CRUD Operations
Every resource module implements a standard set of CRUD operations:
Create Operation Flow
- Controller: Accepts request with CreateDTO
- Service: Validates, applies business rules
- Repository: Persists to database
- Controller: Returns created resource
Read Operations Flow
- Controller: Accepts query parameters
- Service: Applies filtering, pagination logic
- Repository: Queries database
- Service: Transforms results if needed
- Controller: Returns formatted response
Update Operation Flow
- Controller: Accepts ID and UpdateDTO
- Service: Validates existence, applies business rules
- Repository: Updates in database
- Controller: Returns updated resource
Delete Operation Flow
- Controller: Accepts ID
- Service: Validates existence, checks constraints
- Repository: Deletes from database
- Controller: Returns success status
Best Practices
1. Controller Best Practices
- Keep Controllers Thin: Focus on HTTP concerns only
- Consistent Error Handling: Use NestJS exception filters
- Proper Status Codes: Return appropriate HTTP status codes
- Input Validation: Use DTOs with validation decorators
- Comprehensive Documentation: Use Swagger annotations
2. Service Best Practices
- Encapsulate Business Logic: Keep business rules in services
- Transaction Management: Use repository transaction methods
- Error Scenarios: Handle all potential error cases
- Logging: Log important operations and errors
- Domain-Specific Validation: Implement complex validation logic
3. Repository Best Practices
- Extend BaseRepository: Always extend
BaseRepository<TEntity, TDocument>fromlibs/api/utils-api/src/lib/repository/base-repository.common.tsto leverage the standard CRUD operations, document normalization, and transaction management functionality - Entity Normalization: Use the inherited
normalizeResultandnormalizeManymethods to ensure proper entity transformation - Transaction Management: Use
withTransactionfor operations that modify multiple documents to ensure data consistency - Custom Query Methods: Create domain-specific query methods that abstract complex queries
- Performance Optimization: Use projections and lean queries for read-heavy operations
4. Schema Best Practices
- Clear Validation Rules: Define constraints with decorators
- Indexes for Performance: Add indexes for frequently queried fields
- Consistent Naming: Follow naming conventions
- Link to Shared Types: Align with shared entity definitions
5. DTO Best Practices
- Implement Shared Interfaces: Extend from shared library interfaces
- Comprehensive Validation: Use class-validator decorators
- Clear Documentation: Use Swagger annotations
- Separation of Concerns: Different DTOs for different operations
Advanced Patterns
Composite Resources
For complex domain relationships, resources can be composed:
@Controller(API_RESOURCES.USERS)
export class UserController {
// User endpoints
@Get(':userId/permissions')
@RequirePermissions(AllPermissions.VIEW_PERMISSIONS)
async getUserPermissions(@Param('userId') userId: string) {
return this.userPermissionService.getUserPermissions(userId);
}
}
Sub-Resource Controllers
For complex sub-resources, create dedicated controllers:
@Controller(`${API_RESOURCES.ORGANIZATIONS}/:organizationId/members`)
export class OrganizationMemberController {
// Organization member endpoints
}
Specialized Service Patterns
For resource-specific patterns, extend the standard structure:
@Injectable()
export class UserAuthService {
constructor(
private userService: UserService,
private tokenService: TokenService
) {}
async login(credentials: LoginDto): Promise<LoginResponseDto> {
// Login implementation
}
}
Implementation Examples
Example: Complete Resource Module
Here is a complete example of the Permission resource module:
Module Definition
@Module({
imports: [
MongooseModule.forFeature([
{
name: Permission.name,
schema: PermissionSchema,
},
]),
],
providers: [PermissionService, PermissionRepository],
controllers: [PermissionController],
exports: [PermissionService, PermissionRepository],
})
export class PermissionModule {}
Controller
@ApiTags(capitalizeFirstCharacter(API_RESOURCES.PERMISSIONS))
@ApiCookieAuth()
@Controller(API_RESOURCES.PERMISSIONS)
export class PermissionController {
constructor(private readonly permissionService: PermissionService) {}
@Get()
@RequirePermissions(AllPermissions.VIEW_PERMISSIONS)
async findAll() {
return this.permissionService.findAll();
}
@Get(API_ACTIONS.PERMISSION.BY_ID)
@RequirePermissions(AllPermissions.VIEW_PERMISSIONS)
async findOne(@Param('id') id: string) {
return this.permissionService.findOneById(id);
}
@Post()
@RequirePermissions(AllPermissions.CREATE_PERMISSION)
async create(@Body() createPermissionDto: CreatePermissionDto) {
return this.permissionService.create(createPermissionDto);
}
@Put(API_ACTIONS.PERMISSION.BY_ID)
@RequirePermissions(AllPermissions.UPDATE_PERMISSION)
async update(
@Param('id') id: string,
@Body() updatePermissionDto: UpdatePermissionDto
) {
return this.permissionService.update(id, updatePermissionDto);
}
@Delete(API_ACTIONS.PERMISSION.BY_ID)
@RequirePermissions(AllPermissions.DELETE_PERMISSION)
async delete(@Param('id') id: string) {
return this.permissionService.delete(id);
}
}
Troubleshooting Common Issues
Circular Dependencies
Problem: Circular dependencies between modules.
Solution: Use a shared module for common functionality or use forwardRef().
DTO Validation Issues
Problem: Validation decorators not working.
Solution: Ensure ValidationPipe is configured in the application bootstrap.
Missing Module Exports
Problem: Dependency injection errors for services.
Solution: Make sure services are properly exported from their modules.
Related Patterns
- Shared Type System: How shared types are used in resource modules
- Repository Pattern: How repositories are implemented within resource modules
- Authentication Pattern: How authentication integrates with resource controllers