Architecting Production-Grade Node.js Applications with Typescript, NestJS, and Decorator Modularity
Let's embark on a concise exploration of the technological framework underpinning our article. Our current venture involves the creation of a Node.js application with a strong emphasis on delivering robust production-grade code. To attain this objective, we've opted for Typescript—a choice that bestows our JavaScript code with comprehensive and accurate typing capabilities, thus enhancing code quality.
However, we're not solely relying on the rudiments of pure Node.js development. Instead, we're leveraging the power of NestJS, a robust framework that operates on top of Node.js. NestJS doesn't just offer a mere structural framework, but also delves deep into architectural paradigms. It introduces the concept of modular injection, which plays a pivotal role in our application's architecture.
The distinctive feature of NestJS lies in its intricate utilization of the decorator pattern, a fundamental design principle. The decorator pattern imbues our codebase with a mechanism to augment classes or properties, facilitating the implementation of cross-cutting concerns. This results in cleaner and more modular code, as the decorator pattern allows us to encapsulate functionalities and apply them consistently wherever needed.
To elucidate further, NestJS's modularity empowers developers to craft isolated modules, each encapsulating a specific set of features. This modularity extends to the injection of these modules, providing the developer with unparalleled flexibility. These modules, armed with their unique capabilities, can be effortlessly injected wherever they're required within the application.
In essence, our approach combines the prowess of Typescript's robust typing, NestJS's modular architecture, and the decorator pattern's elegant structural enhancements. This amalgamation equips us with the tools needed to build a scalable, maintainable, and well-structured Node.js application tailored to production-grade standards..
import { Module } from '@nestjs/common'
import { MyController } from './my.controller'
import { MyService } from './my.service'
import { FeatureModule } from '../feature/feature.module'
@Module({
imports: [FeatureModule]
controllers: [MyController],
providers: [MyService],
})
export class MyModule {}
The excerpt above illustrates the conventional injection pattern for a Nestjs module, as outlined in the documentation. This pattern consistently comprises four essential components:
- Entity definition
- Service
- Controller
- Module
Creating a Policy-Based Authorization System
Our initial stride involves establishing a sound database model that aligns with our architecture. This ensures our ability to govern object access in accordance with organizational scope.
{
"id": 1,
"action": "READ",
"object_id": "5dd7df5e-71c4-4b9b-b87a-c6eaf5d91508",
"condition": {
"department_id": "${id}"
}
}
model Role {
id String @id @default(uuid())
name String
user User[]
permissions RolePermissions[]
}
model User {
id String @id @default(uuid())
created_at DateTime @default(now())
email String @unique
name String
role_id String
department_id String
}
model RolePermission {
id String @id @default(uuid())
role_id String
permission_id String
}
model Permission {
id String @id @default(uuid())
created_at DateTime
action String
object_id String
condition Json
}
In the pursuit of attaining such a degree of authorization within the realm of nodejs, we can rely on established industry solutions like CASL.
The initial approach I would suggest involves mitigating code duplication, a common occurrence within nestjs applications. This can be achieved by introducing Data Transfer Objects (DTOs) for validation purposes during the create and update processes.
@Entity()
export class User {
@ApiProperty({ type: 'string', format: 'uuid' })
@PrimaryGeneratedColumn('uuid')
id: string
@ApiProperty({ type: 'string' })
@IsString()
@IsOptional({ groups: ['UPDATE'] })
@Column({ type: 'varchar' })
name: string
@ApiProperty({ type: 'string', format: 'email' })
@IsEmail()
@IsOptional({ groups: ['UPDATE'] })
@Column({ type: 'varchar', unique: true, nullable: true })
email: string
@ApiProperty({ type: 'string', format: 'uuid' })
@IsUUID()
@IsOptional({ groups: ['UPDATE'] })
@Column({ nullable: true })
department_id: string
}
TThe aforementioned approach enables your Schema/Entity/Model to govern the structure of your object prior to being inserted into the database. This empowers the team to reuse schemas to align with client configurations, eliminating the need for extra interface definitions.
In the context of most production applications, supporting CRUD operations is essential. When employing Prisma, accomplishing this merely requires the creation of a service and controller, usually involving just a few lines of code.
// user.service.ts
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
async create(dto: UserEntity) {
return this.prisma.user.create({ data: dto })
}
async findOneBy(options) {
return this.prisma.user.findFirstOrThrow(options)
}
async update(id, dto) {
return this.prisma.user.update({ where: { id }, data: { ...dto } })
}
async delete(query) {
return this.prisma.user.delete({ where: { ...query } })
}@Get(':id')
@UseGuards(JwtAuthGuard)
@ApiOkResponse({ type: UserEntity })
async findFirst(
@Param('id', ParseUUIDPipe) id: string,
@CaslForbiddenError() forbiddenError: ICaslForbiddenError
) {
const user = await this.service.findFirst(id)
forbiddenError.throwUnlessCan('read', subject('User', user))
return user
}
}
// user.controller.ts
@Controller('users')
@ApiTags('users')
export class UsersController {
constructor(private readonly service: UsersService) {}
@Post()
@UseGuards(JwtAuthGuard)
@ApiCreatedResponse({ type: UserEntity })
create(@Body() dto: UserEntity) {
return this.service.create(dto)
}
}
// You need to bring all the pieces together in a module
@Module({
controllers: [UsersController],
providers: [UsersService],
imports: [/* Additional modules you may need*/],
exports: [UsersService]
})
export class UsersModule {}
Nevertheless, when experimenting with other alternatives, you often encounter the need to repetitively define identical routes, methods, and queries for each of your resources.
// typeorm example
@EntityRepository()
export class CustomService {
constructor(
@InjectRepository(CustomEntity)
private readonly repo: Repository<CustomEntity>,
) {}
async createOne(dto: CreateDto): Promise<CustomEntity> {
try {
return await this.repo.save(dto)
} catch (error) {
return Promise.reject(error)
}
}
async updateOne(dto: UpdateDto, query): Promise<boolean> {
try {
const record = await this.repo.findOne({
where: query,
})
// assert record found
return await this.repo.update(
{ ...record },
{
...dto,
},
)
} catch (error) {
return Promise.reject(error)
}
}
async findOne(query: Query): Promise<CustomEntity> {
// implementation
}
}
By now, it's important to recognize that your current architecture is reaching a point where its maintenance is becoming challenging. Exploring alternatives to enhance your team's development speed should be seriously considered.