CRUD factory beyond Ultimate and Address Module (Nest JS Ecommerce Series 03)

Deepak Mandal
Dev Genius
Published in
8 min readMay 8, 2024

--

Photo by Borna Bevanda on Unsplash

Now, What we called a crud will change forever in this article. As we will be moving everything in certain factory. Why we are doing that? What issue it solves? Those are important questions before any feature implementation. We are doing it because I am lazy as f**k, Also need something we call DRY. Don’t want to implement things those are same for every module, maintaining them will be bigger crisis than any other issues. Same pagination feature, same update feature and any other feature/endpoint in CRUD we want to implement in each module. But when change comes, we take too much time and even produce bugs because we have to implement things in each modules’ endpoint.

So, now how we want to proceed, We did hade our first phase CRUD operations in below article.

Now we are moving to the next phase of CRUD implementation with factory concept that handles underneath implementation and returns the class. What this phase of CRUD lacks is proper type hinting, as I already mentioned I am lazy. But I will move this factory in next phase and introduce better type hinting. Right now, the implementation is just more of any hell. Really sorry for that, but I just prefer more of phase wise implementation. Why I prefer that because it gives me more idea what I am missing and accumulate all those features then I implement them at once after sometime.

Controller Factory

Let me just paste here the code, then I can begin the whole explanation with required libraries, like for pagination nestjs-paginate.

// common/crud/crud.controller.ts

import {
Body,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
UsePipes,
} from '@nestjs/common';
import { ApiBody, ApiOkResponse, PickType } from '@nestjs/swagger';
import { AbstractValidationPipe } from 'common/pipes/abstract.pipe';
import { UserParam } from 'common/decorators/user.decorator';
import { Customer } from 'common/entities/customer.entity';
import {
ApiPaginationQuery,
Paginate,
PaginateQuery,
PaginateConfig,
} from 'nestjs-paginate';
import { ValidationGroup } from './validation-group';
import { IsIDExistPipe } from 'common/pipes/IsIDExist.pipe';

export interface ControllerFactoryOptions<
Entity extends abstract new (...args: any) => any,
> {
entity: Entity;
paginateConfig: PaginateConfig<InstanceType<Entity>>;
createFields?: (keyof InstanceType<Entity>)[];
updatedFileds?: (keyof InstanceType<Entity>)[];
}

export const ControllerFactory = <
Entity extends abstract new (...args: any) => any,
>({
entity,
paginateConfig,
createFields,
updatedFileds,
}: ControllerFactoryOptions<Entity>): any => {
const createPipe = new AbstractValidationPipe(
{ whitelist: true, transform: true, groups: [ValidationGroup.CREATE] },
{ body: entity as any },
);
const updatePipe = new AbstractValidationPipe(
{ whitelist: true, transform: true, groups: [ValidationGroup.UPDATE] },
{ body: entity as any },
);

class BaseController {
service: any;

@Get()
@ApiPaginationQuery(paginateConfig)
async getAll(
@Paginate() query: PaginateQuery,
@UserParam() user: Customer,
) {
return this.service.getAll(query, user);
}

@Get(':id')
@ApiOkResponse({ type: entity as any })
async get(
@Param('id', ParseUUIDPipe, IsIDExistPipe({ entity })) id: string,
@UserParam() user: Customer,
) {
return this.service.getOne(id, user);
}

@Post()
@UsePipes(createPipe)
@ApiBody({
type: createFields
? PickType(entity as any, createFields as any)
: entity,
})
@ApiOkResponse({ type: entity })
async post(@Body() dto: Entity, @UserParam() user: Customer) {
return this.service.create(dto, user);
}

@Patch(':id')
@UsePipes(updatePipe)
@ApiOkResponse({ type: entity })
@ApiBody({
type: updatedFileds
? PickType(entity as any, updatedFileds as any)
: entity,
})
async update(
@Param('id', ParseUUIDPipe, IsIDExistPipe({ entity })) id: string,
@Body() dto: Entity,
@UserParam() user: Customer,
) {
return this.service.update(id, dto, user);
}

@Delete(':id')
async delete(
@Param('id', ParseUUIDPipe, IsIDExistPipe({ entity })) id: string,
@UserParam() user: Customer,
) {
await this.service.delete(id, user);
}
}

return BaseController;
};

Let me just go through some of the important endpoints, first thing first we need to install nestjs-paginate. You have to run

npm i nestjs-paginate

The parameters for controller factory are very straight forward entity, paginateConfig, createFields and updatedFields. Those are simple requirements, We don’t need anything very advanced implementations.

Paginate config You can see some examples. The library is good at its implementations. I preferred this library because of dynamic filter through config object. Another extra thing we did is Abstract Pipe that handles validation implementation according to the defined entity. Validation group handles the dynamic validation according to the group. To add validation pipe, we are using abstract pipe

// common/pipe/abstract.pipe.ts

import {
ArgumentMetadata,
Injectable,
Type,
ValidationPipe,
ValidationPipeOptions,
} from '@nestjs/common';

@Injectable()
export class AbstractValidationPipe extends ValidationPipe {
constructor(
options: ValidationPipeOptions,
private readonly targetTypes: {
body?: Type<any>;
query?: Type<any>;
param?: Type<any>;
},
) {
super(options);
}

async transform(value: any, metadata: ArgumentMetadata) {
const targetType = this.targetTypes[metadata.type];
if (!targetType) {
return super.transform(value, metadata);
}
return super.transform(value, { ...metadata, metatype: targetType });
}
}

Above pipe works for query, body and params. We have to just define/give the value in opposite of those. In opposite of controller, we also need a service factory that handles all the controller operations. Again I am not focusing on great type hinting, in later stages we will work on those essential things. Type hinting is very important, But we can just go with current one for while.

// common/crud/crud.service.ts

import { Customer } from 'common/entities/customer.entity';
import { paginate, PaginateConfig, PaginateQuery } from 'nestjs-paginate';
import { Repository } from 'typeorm';

interface ServiceFactoryOptions<T extends abstract new (...args: any) => any> {
entity: T;
paginateConfig: PaginateConfig<InstanceType<T>>;
}
export const ServiceFactory = <T extends abstract new (...args: any) => any>({
paginateConfig,
}: ServiceFactoryOptions<T>) => {
class BaseService {
repository: Repository<InstanceType<T>>;

async getAll(query: PaginateQuery, user?: Customer) {
return paginate(
{
...query,
filter: {
...query.filter,
customer_id: user.id,
},
},
this.repository,
paginateConfig as any,
);
}

async getOne(instance: InstanceType<T>, user?: Customer) {
return this.repository.findOneBy({
id: instance.id,
customer_id: user.id,
} as any);
}

async create(dto: any, user?: Customer) {
const newEntity = this.repository.create({
...dto,
customer_id: user.id,
});
return this.repository.save(newEntity);
}

async update(instance: InstanceType<T>, dto: any, user?: Customer) {
await this.repository.update(
{ id: instance.id, customer_id: user.id } as any,
dto,
);
return {
...instance,
...dto,
};
}

async delete(instance: InstanceType<T>, user?: Customer) {
return this.repository.softDelete({
id: instance.id,
customer_id: user.id,
} as any);
}
}

return BaseService;
};

Now if we want to work on address we need an entity. That also includes all the essential requirements for validation. So we don’t need to create any separate DTOs.

// common/entities/address.entity.ts

import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';
import { BaseEntity } from './base';
import { Customer } from './customer.entity';
import { IsOptional, IsString, MaxLength } from 'class-validator';
import { ValidationGroup } from 'common/crud/validation-group';

@Entity({ name: 'addresses' })
export class Address extends BaseEntity {
@ApiProperty({ example: 'Deepak' })
@IsOptional({ groups: [ValidationGroup.UPDATE] })
@IsString({ always: true })
@MaxLength(255, { always: true })
@Column()
first_name: string;

@ApiProperty({ example: 'Mandal' })
@IsOptional({ groups: [ValidationGroup.UPDATE] })
@MaxLength(255, { always: true })
@IsString({ always: true })
@Column()
last_name: string;

@ApiProperty({ example: 'Home' })
@IsOptional({ groups: [ValidationGroup.UPDATE] })
@MaxLength(255, { always: true })
@IsString({ always: true })
@Column()
name: string;

@ApiProperty({ example: 'John Does Road' })
@IsOptional({ groups: [ValidationGroup.UPDATE] })
@MaxLength(255, { always: true })
@IsString({ always: true })
@Column()
address: string;

@ApiProperty({ example: 'Nagpur' })
@IsOptional({ groups: [ValidationGroup.UPDATE] })
@MaxLength(255, { always: true })
@IsString({ always: true })
@Column()
city: string;

@ApiProperty({ example: 'Maharashtra' })
@IsOptional({ groups: [ValidationGroup.UPDATE] })
@MaxLength(255, { always: true })
@IsString({ always: true })
@Column()
state: string;

@ApiProperty({ example: 'India' })
@IsOptional({ groups: [ValidationGroup.UPDATE] })
@MaxLength(255, { always: true })
@IsString({ always: true })
@Column()
country: string;

@ApiProperty({ example: '469479' })
@IsOptional({ groups: [ValidationGroup.UPDATE] })
@MaxLength(255, { always: true })
@IsString({ always: true })
@Column()
zip_code: string;

@ManyToOne(() => Customer, (customer) => customer.addresses)
@JoinColumn({ name: 'customer_id' })
customer: Customer;

@ApiProperty()
@Column({ type: 'uuid' })
customer_id: string;
}

Above in entity I also included customer relationship to fulfil the entire relationship need to attach, relationship in customer entity.

// common/entities/customer.entity.ts

...

@Entity({ name: 'customers' })
export class Customer extends BaseEntity {
...

@OneToMany(() => Address, (address) => address.customer)
addresses: Address;
}

Time to move towards our address module, We need 4 things

  1. module file
  2. paginate config file
  3. service file
  4. controller file

Our Controller and service are just straight forward, but pagination config is important before that, as both of them use that to extend the Factory output class.

// apps/user/src/address/address.pagination.ts

import { Address } from 'common/entities/address.entity';
import { FilterOperator, PaginateConfig } from 'nestjs-paginate';

export const addressPaginateConfig: PaginateConfig<Address> = {
sortableColumns: ['created_at'],
defaultSortBy: [['created_at', 'DESC']],
searchableColumns: [
'name',
'address',
'first_name',
'last_name',
'city',
'state',
],
maxLimit: 50,
defaultLimit: 10,
filterableColumns: { name: [FilterOperator.EQ] },
};

We can use above in both Controller and Service

// apps/user/src/address/address.controller.ts

import { Controller, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Address } from 'common/entities/address.entity';
import { AddressService } from './address.service';
import { AuthGuard } from '@nestjs/passport';
import { ControllerFactory } from 'common/crud/crud.controller';
import { addressPaginateConfig } from './address.pagination';

@ApiTags('Address')
@Controller({
path: 'addresses',
version: '1',
})
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'))
export class AddressController extends ControllerFactory({
entity: Address,
paginateConfig: addressPaginateConfig,
createFields: [
'name',
'address',
'city',
'country',
'first_name',
'last_name',
'state',
'zip_code',
],
updatedFileds: [
'name',
'address',
'city',
'country',
'first_name',
'last_name',
'state',
'zip_code',
],
}) {
constructor(private service: AddressService) {
super();
}
}

Controller factory just generates each CRUD endpoints for us. No hassles just provide the arguments, like entity, pagination config, createFields and updateFields.

// apps/user/src/address/address.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ServiceFactory } from 'common/crud/crud.service';
import { Address } from 'common/entities/address.entity';
import { Repository } from 'typeorm';

@Injectable()
export class AddressService extends ServiceFactory({
entity: Address,
paginateConfig: { sortableColumns: ['created_at'] },
}) {
constructor(@InjectRepository(Address) repository: Repository<Address>) {
super();
this.repository = repository;
}
}

Service factory also does magic for us, as same as controller factory, just wow. Automatic creation of required service functions in CRUD operation. Nothing left for, just include all these in module, then include module in app.module.ts.

// apps/user/src/address/address.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AddressService } from './address.service';
import { AddressController } from './address.controller';
import { Address } from 'common/entities/address.entity';

@Module({
imports: [TypeOrmModule.forFeature([Address])],
providers: [AddressService],
controllers: [AddressController],
})
export class AddressModule {}

Same Address Module gets included in app module for global registration.

// apps/user/src/app.module.ts

...

const modules = [
...
AddressModule,
];
...

Just like that, and now we have all the required CRUD operations for address in Swagger too.

I just tested all that using my customer account, works very good for me. By using nestjs-paginate we got very useful pagination routes. That actually hold all the required details for sorting, filtering, searching and pagination.

The above crud factory does satisfy my needs for now. Maybe in later stage we will just upgrade it beyond just easy solutions.

Thank you for reading the whole article, we are done with our whole article on address module for customer/user. In the next article, I will cover seeding data on DB with TypeORM. We will be seeding product and product variants in the next article.

My articles will be free, as I think education should be. But one help I need from you guys is, If anyone needs senior developer in Nest JS Or any work as full stack developer. You can refer me Upwork.

--

--