Skip to content

Entity & DTO Principle

Two distinct boundaries exist in every API request cycle:

  1. The database boundary β€” defined by Entities
  2. The client boundary β€” defined by DTOs

Conflating these two β€” using entities for API validation or DTOs for database mapping β€” is one of the most common architectural mistakes in NestJS projects. This guide defines the rules for each, explains why they must remain separate, and shows exactly how they connect through the controller and service layers.


An Entity is a TypeScript class that maps directly to a database table. It defines the structure, constraints, and relationships of your stored data. It knows nothing about HTTP, Swagger, or API clients.

RuleWhy
❌ No @ApiProperty()Entities describe the database, not the API contract. Swagger belongs on DTOs.
βœ… Specify database: in @EntityRequired for multi-database routing in TypeORM
βœ… All columns must have a comment:Serves as inline schema documentation
βœ… Implement ITimestampEnsures consistent created_at, updated_at, deleted_at fields
βœ… Primary key must be id (UUID)Consistent PK naming across all entities
βœ… Nullable columns must use type | nullPrevents TypeScript from hiding nullable values
❌ No cross-service @ManyToOne relationsEntities from other bounded contexts cannot be referenced directly
βœ… Define once β€” never share across servicesEntity files are internal to their owning bounded context
apps/data-owner-bc/src/modules/resource/entities/resource.entity.ts
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { ResourceStatus } from '@lib/common/enum/resource-status.enum';
import { AppDatabases } from '@lib/common/enum/app-databases.enum';
import { ITimestamp } from '@lib/common/interfaces/timestamp.interface';
import { IUser } from '@lib/common/interfaces/user.interface';
/**
* Specifies both table name and database connection.
* The `database` value must match the AppDatabases enum.
*/
@Entity({ name: 'resources', database: AppDatabases.APP_CORE })
@Index('idx_resources_reference_number', ['reference_number'], { unique: true })
@Index('idx_resources_external_id', ['external_id'], { unique: true })
export class Resource implements ITimestamp {
/**
* Primary key: always 'id', always UUID.
*/
@PrimaryGeneratedColumn('uuid')
id: string;
/**
* Required field β€” no nullable, no | null
*/
@Column({
type: 'varchar',
length: 20,
unique: true,
comment: 'System-generated unique reference number for this resource',
})
reference_number: string;
/**
* Nullable field β€” must declare | null in the type
*/
@Column({
type: 'varchar',
length: 50,
unique: true,
nullable: true,
comment: 'External identifier from the upstream system, if applicable',
})
external_id: string | null;
@Column({
type: 'varchar',
length: 100,
nullable: true,
comment: 'Resource display name',
})
name: string | null;
@Column({
type: 'date',
nullable: true,
comment: 'Registration or intake date for this resource',
})
registered_date: string | null;
/**
* Enum column β€” uses char with enum constraint
*/
@Column({
type: 'varchar',
enum: ResourceStatus,
nullable: true,
comment: 'Current lifecycle status of the resource',
})
status: ResourceStatus | null;
@Column({
type: 'boolean',
default: true,
comment: 'Whether this resource is currently active',
})
is_active: boolean;
@Column({
type: 'varchar',
nullable: true,
comment: 'Contact email address',
})
email: string | null;
// ── Timestamps (required via ITimestamp) ─────────────────────────────
@CreateDateColumn({
type: 'timestamptz',
nullable: true,
default: null,
comment: 'Record creation timestamp in UTC',
})
created_at: Date;
@UpdateDateColumn({
type: 'timestamptz',
nullable: true,
default: null,
comment: 'Record last-updated timestamp in UTC',
})
updated_at: Date;
@Column({
type: 'uuid',
nullable: true,
default: null,
comment: 'ID of the user who created this record',
})
created_by: string | Partial<IUser> | null;
@Column({
type: 'uuid',
nullable: true,
default: null,
comment: 'ID of the user who last updated this record',
})
updated_by: string | Partial<IUser> | null;
@DeleteDateColumn({
type: 'timestamptz',
nullable: true,
default: null,
comment: 'Soft-delete timestamp; null means the record is active',
})
deleted_at: Date | null;
}

A DTO (Data Transfer Object) is a class that defines the shape of data coming into your API. It uses class-validator for validation rules and class-transformer for type coercion. It knows nothing about the database.

ConcernEntityDTO
PurposeDescribes the database tableDescribes what the client sends
ValidationDatabase-level constraintsclass-validator decorators
SwaggerNever β€” private to the DBAlways β€” documents the API
ScopeBC-internal implementationPublic API contract
MutabilityControlled by TypeORMControlled by client input
@IsNotEmpty() // Cannot be empty string or null
@IsString() // Must be a string
@IsEmail() // Must be a valid email format
@IsEnum(Status) // Must be a member of the enum
@IsISO8601() // Must be a valid ISO 8601 date string (with timezone)
@IsUUID() // Must be a valid UUID
@MaxLength(50) // String length limit
@Matches(/^[0-9]{10}$/, { message: 'Must be exactly 10 digits' })
@Type(() => Date) // Converts "2024-01-15" string β†’ Date object
@Type(() => Number) // Converts "42" string β†’ number
@Type(() => NestedDTO) // Used with @ValidateNested() for nested objects
@Transform(({ value }) => value.trim()) // Custom transformation
apps/data-owner-bc/src/modules/resource/dto/create-resource.dto.ts
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsDateString,
IsEmail,
IsEnum,
IsISO8601,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
ValidateNested,
} from 'class-validator';
import { ResourceStatus } from '@lib/common/enum/resource-status.enum';
import { CreateContactDTO } from './create-contact.dto';
export class CreateResourceDTO {
@IsOptional()
@IsString()
@MaxLength(20)
@ApiPropertyOptional({
description: 'Reference number (auto-generated if omitted)',
example: 'REF-00001',
})
reference_number?: string;
@IsOptional()
@IsString()
@MaxLength(50)
@Matches(/^[A-Z0-9\-]{1,50}$/, { message: 'External ID must be alphanumeric.' })
@ApiPropertyOptional({
description: 'External system identifier, if applicable',
example: 'EXT-ABC123',
})
external_id?: string;
@IsNotEmpty()
@IsString()
@MaxLength(100)
@ApiProperty({
description: 'Display name of the resource',
example: 'Acme Corporation',
})
name: string;
@IsOptional()
@IsDateString()
@ApiPropertyOptional({
description: 'Registration date',
example: '2024-01-15',
})
registered_date?: string;
@IsOptional()
@IsEnum(ResourceStatus)
@ApiPropertyOptional({
description: 'Initial status',
enum: ResourceStatus,
example: ResourceStatus.ACTIVE,
})
status?: ResourceStatus;
@IsOptional()
@IsEmail()
@ApiPropertyOptional({
description: 'Contact email address',
example: 'contact@example.com',
})
email?: string;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional({
description: 'Whether the resource is active on creation',
example: true,
})
is_active?: boolean;
/**
* Nested DTO for a related object
*/
@IsOptional()
@Type(() => CreateContactDTO)
@ValidateNested()
@ApiPropertyOptional({
description: 'Primary contact person',
type: CreateContactDTO,
})
primary_contact?: CreateContactDTO;
}

NestJS’s @nestjs/mapped-types package lets you derive Update DTOs from Create DTOs without rewriting validation rules:

update-resource.dto.ts
import { OmitType, PartialType } from '@nestjs/mapped-types';
import { CreateResourceDTO } from './create-resource.dto';
/**
* OmitType removes 'reference_number' β€” it should never change after creation.
* PartialType makes all remaining fields optional β€” PATCH semantics.
*
* Result: a fully validated Update DTO with zero duplicated code.
*/
export class UpdateResourceDTO extends PartialType(
OmitType(CreateResourceDTO, ['reference_number']),
) {}

The types in your DTO must be consistent with the nullability rules of your Entity. Mismatches are a common source of runtime bugs.

Entity columnDTO field
nullable: false (default)@IsNotEmpty(), field: type
nullable: true@IsOptional(), field?: type
// Entity: non-nullable column
@Column({ type: 'varchar' })
name: string;
// βœ… Correct DTO: required
@IsString()
@IsNotEmpty()
name: string;
// ──────────────────────────────────────────
// Entity: nullable column
@Column({ type: 'varchar', nullable: true })
description: string | null;
// βœ… Correct DTO: optional
@IsString()
@IsOptional()
description?: string;
// ❌ Wrong DTO: marks it required but entity allows null
@IsString()
@IsNotEmpty()
description: string;

The controller uses the DTO to validate incoming data. The service maps the DTO to the entity and persists it.

resources.controller.ts
@ResourceType('resources')
@ApiTags('Resources')
@Controller('resources')
export class ResourcesController extends BaseControllerOperations<
Resource,
CreateResourceDTO,
UpdateResourceDTO,
ResourcesService
> {
constructor(private readonly resourcesService: ResourcesService) {
super(resourcesService);
}
@Post()
@RequirePermission('resource:create')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a new resource' })
@ApiBody({ type: CreateResourceDTO })
@ApiJsonApiCreatedResponse('resources', ResourceResponseDTO)
create(
@Body() dto: CreateResourceDTO, // ← DTO validates and transforms input
@CurrentUser() user: IUserSession,
): Promise<Resource> {
// Data is now validated. Pass the DTO to the service.
// The service maps it to the Resource entity and saves it.
return this.resourcesService.createResource(dto, user);
}
@Put(':id')
@RequirePermission('resource:update')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Update a resource' })
@ApiBody({ type: UpdateResourceDTO })
@ApiJsonApiResponse('resources', HttpStatus.OK, ResourceResponseDTO)
update(
@Param('id') id: string,
@Body() dto: UpdateResourceDTO, // ← All fields optional via PartialType
@CurrentUser() user: IUserSession,
): Promise<Resource> {
return this.resourcesService.updateResource(id, dto, user);
}
}

Every controller that returns formatted data must declare @ResourceType:

@ResourceType('resources')
@Controller('resources')
export class ResourcesController { ... }

The global TransformInterceptor reads this decorator to determine the type field in the JSON:API response envelope:

{
"data": {
"type": "resources", ← comes from @ResourceType('resources')
"id": "uuid-123",
"attributes": { ... }
}
}

If @ResourceType is missing, the interceptor skips formatting and returns the raw service object β€” which is not compliant with the API standard.


ConceptEntityDTO
DescribesDatabase table structureAPI input contract
Used byTypeORM, database migrationsclass-validator, Swagger, controllers
Has @ApiProperty❌ Neverβœ… Always
Has column commentsβœ… RequiredNot applicable
Nullable typingfield: type | nullfield?: type with @IsOptional()
Shared across services❌ Never❌ Never (BC-local API contracts)
Reusable viaNot applicablePartialType, OmitType, PickType