Entity & DTO Principle
Two distinct boundaries exist in every API request cycle:
- The database boundary β defined by Entities
- 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.
Part 1: Entities β The Database Model
Section titled βPart 1: Entities β The Database Modelβ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.
Entity Rules
Section titled βEntity Rulesβ| Rule | Why |
|---|---|
β No @ApiProperty() | Entities describe the database, not the API contract. Swagger belongs on DTOs. |
β
Specify database: in @Entity | Required for multi-database routing in TypeORM |
β
All columns must have a comment: | Serves as inline schema documentation |
β
Implement ITimestamp | Ensures 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 | null | Prevents TypeScript from hiding nullable values |
β No cross-service @ManyToOne relations | Entities from other bounded contexts cannot be referenced directly |
| β Define once β never share across services | Entity files are internal to their owning bounded context |
Full Entity Example
Section titled βFull Entity Exampleβ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;}Part 2: DTOs β The API Contract
Section titled βPart 2: DTOs β The API Contractβ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.
Why Separate DTOs from Entities?
Section titled βWhy Separate DTOs from Entities?β| Concern | Entity | DTO |
|---|---|---|
| Purpose | Describes the database table | Describes what the client sends |
| Validation | Database-level constraints | class-validator decorators |
| Swagger | Never β private to the DB | Always β documents the API |
| Scope | BC-internal implementation | Public API contract |
| Mutability | Controlled by TypeORM | Controlled by client input |
class-validator β Declarative Input Validation
Section titled βclass-validator β Declarative Input Validationβ@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' })class-transformer β Automatic Type Coercion
Section titled βclass-transformer β Automatic Type Coercionβ@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 transformationFull DTO Example
Section titled βFull DTO Exampleβ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;}Reusing DTOs with Mapped Types
Section titled βReusing DTOs with Mapped TypesβNestJSβs @nestjs/mapped-types package lets you derive Update DTOs from Create DTOs without rewriting validation rules:
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']),) {}Part 3: Entity-DTO Type Consistency
Section titled βPart 3: Entity-DTO Type ConsistencyβThe types in your DTO must be consistent with the nullability rules of your Entity. Mismatches are a common source of runtime bugs.
The Rule
Section titled βThe Ruleβ| Entity column | DTO 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;Part 4: Connecting It All β Controller + Service
Section titled βPart 4: Connecting It All β Controller + ServiceβThe controller uses the DTO to validate incoming data. The service maps the DTO to the entity and persists it.
@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); }}Part 5: The @ResourceType Decorator
Section titled βPart 5: The @ResourceType Decoratorβ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.
Summary
Section titled βSummaryβ| Concept | Entity | DTO |
|---|---|---|
| Describes | Database table structure | API input contract |
| Used by | TypeORM, database migrations | class-validator, Swagger, controllers |
Has @ApiProperty | β Never | β Always |
| Has column comments | β Required | Not applicable |
| Nullable typing | field: type | null | field?: type with @IsOptional() |
| Shared across services | β Never | β Never (BC-local API contracts) |
| Reusable via | Not applicable | PartialType, OmitType, PickType |