Skip to main content

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:

  1. Controller Layer: Defines REST endpoints and handles HTTP request/response
  2. Service Layer: Implements business logic and orchestrates operations
  3. 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
  4. Schema Layer: Defines the data structure and validation rules
  5. 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 normalizeResult and normalizeMany methods
    • Transaction management with withTransaction method
    • Entity transformation with transformToEntity method

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

  1. Controller: Accepts request with CreateDTO
  2. Service: Validates, applies business rules
  3. Repository: Persists to database
  4. Controller: Returns created resource

Read Operations Flow

  1. Controller: Accepts query parameters
  2. Service: Applies filtering, pagination logic
  3. Repository: Queries database
  4. Service: Transforms results if needed
  5. Controller: Returns formatted response

Update Operation Flow

  1. Controller: Accepts ID and UpdateDTO
  2. Service: Validates existence, applies business rules
  3. Repository: Updates in database
  4. Controller: Returns updated resource

Delete Operation Flow

  1. Controller: Accepts ID
  2. Service: Validates existence, checks constraints
  3. Repository: Deletes from database
  4. 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> from libs/api/utils-api/src/lib/repository/base-repository.common.ts to leverage the standard CRUD operations, document normalization, and transaction management functionality
  • Entity Normalization: Use the inherited normalizeResult and normalizeMany methods to ensure proper entity transformation
  • Transaction Management: Use withTransaction for 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.

References