I've recently started learning Nestjs and backend development in general. I got familiarized with the concept of DTO. Each and every tutorial/article on the Internet focuses on their usage for accepting and sending API requests. And what about the data transfer between the app and the database? Just like the web, databases are part of the infrastructure layer. That means that we need to create an abstraction between the database and the rest of the app, as well as decouple the persistence from the domain. After all, my persistence level differs a lot from the domain level. So I thought DTOs with some mapping might be helpful. But I'm wondering, how do you 'translate' data from and to persistence? Do you have any special DTO types? Or do you simply preform mapping manually in the repository? Does the 'translation' path look like 'persistence' --> 'domain' --> 'view' (for read queries) or is it redundant?
2 Answers
You're thinking in the right direction. Most tutorials show how to use DTOs (Data Transfer Objects) for handling requests and responses — like between the frontend and the API.
But what about data going between your app and the database? That’s also important — and many real projects handle it using different DTOs or mappers.
When reading from DB:
const user = await this.userRepository.findOne({where: {"user_id": 1} })
return new User(
user.user_id,
user.first_name,
user.last_name
)
When saving to DB:
const user = new User();
user.user_id = data.user_id;
user.first_name = data.first_name;
user.last_name = data.last_name;
await this.userRepository.save(user)
Note
You can use some function defined in class which accept some arguments and return filtered value;
Comments
I agree with @Ashish Srivastava’s answer — especially in typical NestJS apps — but I'd like to expand on it by considering deeper architectural layers, particularly around the domain–infrastructure boundary.
How you handle DTOs really depends on how many layers exist between your controllers and your persistence layer, and how these layers are allowed to interact (e.g., whether one module can access another’s domain models, which is commonly allowed but still worth considering carefully).
Coming from a C# background (where architecture patterns like Clean Architecture or Onion Architecture are common), I've found this rule of thumb to work well — and it's very applicable to NestJS/TypeScript too:
Use DTOs between the Controller and Service layers — this is your Presentation Layer, and DTOs here serve as API contracts (often used with validation decorators like
class-validator).If you have additional layers between Service and your Domain Model (like an Application Service or Command Handler), you might introduce other types, but these aren’t always called "DTOs". You might call them things like
CreateUserResultorUserData, defined as simple classes or interfaces.This allows other modules to access these interfaces or types without tightly coupling to the HTTP-specific DTOs.
For mapping, I usually use static methods in the DTO or Mapper classes, like
fromEntity,toEntity,fromDomain,toDomain, etc. This makes transformation and encapsulation explicit and testable.
This pattern keeps your application modular, decoupled, and easier to test or evolve later (e.g., if you change the persistence mechanism, or expose the domain logic via GraphQL or gRPC in the future).
Architectural layers:
Controller (REST layer)
↓
Service (Application logic)
↓
Domain (Business logic / core models)
↓
Repository (Persistence abstraction)
↓
ORM Entity (Database representation)
UserEntity or UserModel
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity('users')
export class UserEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
username: string;
@Column()
email: string;
}
UserDomain
export class User {
constructor(
public readonly id: string,
public username: string,
public email: string,
) {}
changeEmail(newEmail: string) {
this.email = newEmail;
}
}
UserMapper
export class UserMapper {
static toDomain(entity: UserEntity): User {
return new User(entity.id, entity.username, entity.email);
}
static toEntity(domain: User): UserEntity {
const entity = new UserEntity();
entity.id = domain.id;
entity.username = domain.username;
entity.email = domain.email;
return entity;
}
}
UserDto
import { User } from '@/domain/user/user';
export class UserResponseDto {
id: string;
username: string;
email: string;
static fromDomain(user: User): UserResponseDto {
const dto = new UserResponseDto();
dto.id = user.id;
dto.username = user.username;
dto.email = user.email;
return dto;
}
}
This example give you a flexibility on how many layers you want to implement, how they are interacted with each other and how to manage a specified layer logic.
toDBandfromDBstatic methods on the DTOs. For projects that use an ORM, we havetoDTOandfromDTOon the Object classes