Skip to content

Adding an API Module

This guide walks through adding a new feature module to the NestJS API.

1. Create the Module Directory

api/src/<module>/
├── <module>.module.ts
├── <module>.controller.ts
├── <module>.service.ts
├── <module>.service.spec.ts
└── dto/
    └── <name>.dto.ts

Entities live in the shared/ package (shared/src/entities/) so the API, data-fetching, and data-processing services can all import them via @gis-data/shared.

2. Define the Entity

Create shared/src/entities/<name>.entity.ts and add a re-export to shared/src/index.ts:

typescript
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  UpdateDateColumn,
  Index,
} from 'typeorm';

@Entity('<table_name>')
export class MyEntity {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'varchar', length: 255 })
  name: string;

  @Column({ type: 'text', nullable: true })
  description: string | null;

  @Column({ type: 'boolean', default: true })
  isActive: boolean;

  @Index()
  @CreateDateColumn({ type: 'timestamptz' })
  createdAt: Date;

  @UpdateDateColumn({ type: 'timestamptz' })
  updatedAt: Date;
}

For PostGIS geometry columns:

typescript
interface GeoPoint {
  type: 'Point';
  coordinates: [number, number];
}

@Index({ spatial: true })
@Column({
  type: 'geometry',
  spatialFeatureType: 'Point',
  srid: 4326,
})
geom: GeoPoint;

3. Create DTOs

Create api/src/<module>/dto/<name>.dto.ts:

typescript
import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';

export class MyQueryDto {
  @ApiPropertyOptional({ description: 'Iskalni niz' })
  @IsOptional()
  @IsString()
  search?: string;

  @ApiPropertyOptional({ description: 'Stran', example: 1 })
  @IsOptional()
  @IsInt()
  @Min(1)
  @Type(() => Number)
  page?: number;

  @ApiPropertyOptional({ description: 'Število na stran', example: 20 })
  @IsOptional()
  @IsInt()
  @Min(1)
  @Max(100)
  @Type(() => Number)
  limit?: number;
}

export class CreateMyDto {
  @ApiPropertyOptional({ description: 'Ime' })
  @IsString()
  name: string;

  @ApiPropertyOptional({ description: 'Opis' })
  @IsOptional()
  @IsString()
  description?: string;
}

Remember:

  • Swagger descriptions in Slovenian
  • Use @Type(() => Number) for numeric query params
  • Use @IsOptional() for optional fields

4. Create the Service

Create api/src/<module>/<module>.service.ts:

typescript
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { MyEntity } from './entities/<name>.entity';
import { MyQueryDto, CreateMyDto } from './dto/<name>.dto';

@Injectable()
export class MyService {
  constructor(
    @InjectRepository(MyEntity)
    private readonly myRepository: Repository<MyEntity>,
  ) {}

  async findAll(query: MyQueryDto) {
    const page = query.page || 1;
    const limit = query.limit || 20;

    const qb = this.myRepository.createQueryBuilder('e');

    if (query.search) {
      qb.andWhere('e.name ILIKE :search', { search: `%${query.search}%` });
    }

    qb.orderBy('e.createdAt', 'DESC')
      .skip((page - 1) * limit)
      .take(limit);

    const [data, total] = await qb.getManyAndCount();

    return { data, total, page, limit };
  }

  async findOne(id: string) {
    const entity = await this.myRepository.findOne({ where: { id } });
    if (!entity) {
      throw new NotFoundException(`Entity with id ${id} not found`);
    }
    return entity;
  }

  async create(dto: CreateMyDto) {
    const entity = this.myRepository.create(dto);
    return this.myRepository.save(entity);
  }
}

5. Create the Controller

Create api/src/<module>/<module>.controller.ts:

typescript
import { Controller, Get, Post, Body, Param, Query, ParseUUIDPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiNotFoundResponse } from '@nestjs/swagger';
import { MyService } from './<module>.service';
import { MyQueryDto, CreateMyDto } from './dto/<name>.dto';
import { Public } from '../auth/decorators/public.decorator';
import { Roles } from '../auth/decorators/roles.decorator';

@ApiTags('My Module')
@Controller('<module>')
export class MyController {
  constructor(private readonly myService: MyService) {}

  @Get()
  @Public()
  @ApiOperation({ summary: 'Vrni seznam elementov' })
  findAll(@Query() query: MyQueryDto) {
    return this.myService.findAll(query);
  }

  @Get(':id')
  @Public()
  @ApiOperation({ summary: 'Vrni podrobnosti elementa' })
  @ApiNotFoundResponse({ description: 'Element ni najden' })
  findOne(@Param('id', ParseUUIDPipe) id: string) {
    return this.myService.findOne(id);
  }

  @Post()
  @Roles('admin', 'superadmin')
  @ApiOperation({ summary: 'Ustvari nov element' })
  create(@Body() dto: CreateMyDto) {
    return this.myService.create(dto);
  }
}

6. Create the Module

Create api/src/<module>/<module>.module.ts:

typescript
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MyEntity } from '@gis-data/shared';
import { MyController } from './<module>.controller';
import { MyService } from './<module>.service';

@Module({
  imports: [TypeOrmModule.forFeature([MyEntity])],
  controllers: [MyController],
  providers: [MyService],
})
export class MyModule {}

7. Register in AppModule

Edit api/src/app.module.ts:

typescript
import { MyModule } from './<module>/<module>.module';

@Module({
  imports: [
    // ... existing modules
    MyModule,
  ],
})
export class AppModule {}

Since autoLoadEntities: true is set, the entity will be automatically discovered.

8. Create a Migration

Generate a migration for the new entity (run from the project root):

bash
pnpm migration:generate migrations/<MigrationName>

Then run it:

bash
pnpm migration:run

9. Write Tests

Unit Test

Create api/src/<module>/<module>.service.spec.ts:

typescript
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NotFoundException } from '@nestjs/common';
import { MyService } from './<module>.service';
import { MyEntity } from '@gis-data/shared';

function createMockQueryBuilder(result: any = []) {
  const qb: Record<string, jest.Mock> = {};
  qb.andWhere = jest.fn().mockReturnValue(qb);
  qb.orderBy = jest.fn().mockReturnValue(qb);
  qb.skip = jest.fn().mockReturnValue(qb);
  qb.take = jest.fn().mockReturnValue(qb);
  qb.getManyAndCount = jest.fn().mockResolvedValue([result, result.length]);
  return qb;
}

describe('MyService', () => {
  let service: MyService;
  let mockRepository: Record<string, jest.Mock>;

  beforeEach(async () => {
    mockRepository = {
      createQueryBuilder: jest.fn().mockReturnValue(createMockQueryBuilder()),
      findOne: jest.fn(),
      create: jest.fn((dto) => dto),
      save: jest.fn((entity) => ({ id: 'new-uuid', ...entity })),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        MyService,
        { provide: getRepositoryToken(MyEntity), useValue: mockRepository },
      ],
    }).compile();

    service = module.get(MyService);
  });

  it('should return paginated results', async () => {
    const result = await service.findAll({});
    expect(result).toHaveProperty('data');
    expect(result).toHaveProperty('total');
  });

  it('should throw NotFoundException for missing entity', async () => {
    mockRepository.findOne.mockResolvedValue(null);
    await expect(service.findOne('missing-id')).rejects.toThrow(NotFoundException);
  });
});

E2E Test

Create api/test/<module>.e2e-spec.ts:

typescript
import * as request from 'supertest';
import { INestApplication } from '@nestjs/common';
import { createTestApp, getDataSource, loginAsUser } from './setup-e2e';
import { DataSource } from 'typeorm';

describe('My Module (e2e)', () => {
  let app: INestApplication;
  let ds: DataSource;

  beforeAll(async () => {
    app = await createTestApp();
    ds = getDataSource(app);
  });

  afterAll(async () => {
    if (app) await app.close();
  });

  it('GET /api/<module> returns 200', async () => {
    await request(app.getHttpServer())
      .get('/api/<module>')
      .expect(200);
  });
});

10. Verify

bash
# Build
cd api && pnpm build

# Run unit tests
pnpm test:unit

# Run e2e tests
pnpm test:e2e

Then check Swagger docs at http://localhost:8001/docs — your endpoints should appear under their @ApiTags() group.