In which layer of the architecture (route, service, repository) should a DTO object be converted to a dictionary before saving to the database? What are the best practices for mapping DTOs to database models in a multi-layered architecture and why?
Converting DTOs to Dictionaries Before Saving to Database Should Happen in the Service Layer, Not in the Repository or Router
Best practices for multi-layered architecture recommend clear separation of responsibilities, where repositories are responsible only for database operations, services for business logic and data transformation, and routers/controllers for request and response handling.
Contents
- Where to Convert DTOs to Database Models
- Layer Responsibilities in Architecture
- Best Practices for DTO Mapping
- Data Transformation Patterns
- Implementation Examples
- Common Mistakes and How to Avoid Them
Where to Convert DTOs to Database Models
A repository is an anti-pattern for converting DTOs into persistence objects. Repositories should be focused exclusively on database operations, abstracting the implementation details of persistence mechanisms. As noted on Microsoft Learn, repositories separate the dependency between the domain and data storage, but should not handle DTO conversion.
Repositories should hold the state of Aggregates, not share the state of Aggregates with the Presentation Layer - Nick Chamberlain
The service layer is the correct place to convert DTOs to domain models before saving. Services encapsulate business logic and should be responsible for transforming data between different representations. As experts explain on StackOverflow, “controller should know service, service should know repository”, which indicates a unidirectional dependency down the layers.
Layer Responsibilities in Architecture
Router/Controller Layer
- Handles incoming HTTP requests
- Validates input data
- Calls service methods with DTOs
- Transforms service layer DTOs into HTTP responses
- Should not convert DTOs to domain models
Service Layer
- Contains business logic
- Transforms DTOs to domain models and vice versa
- Calls repositories for database operations
- Manages transactions
- Key responsibility: data transformation between layers
Repository Layer
- Abstracts database operations
- Works directly with domain entities
- Provides CRUD methods for aggregates
- Should not know about the existence of DTOs
- Persists only domain models, not dictionaries
Best Practices for DTO Mapping
1. Clear Separation of Responsibilities
Each layer should have its own area of responsibility:
- DTOs - for transferring data between layers
- Domain Models - for business logic
- Database Entities - for saving to the database
As experts at InfoQ point out, DTOs emerged as a versatile tool for seamless data transfer between layers and adaptability to various data models.
2. Using DTO Assembler
DTO Assembler is a special class/pattern for converting domain models to DTOs and vice versa:
class UserAssembler {
toDTO(user: User): UserDTO {
return {
id: user.id,
name: user.name,
email: user.email,
// only necessary fields for presentation
};
}
fromDTO(dto: UserDTO): User {
return new User(dto.id, dto.name, dto.email);
}
}
3. Automated Mapping
Use automated mapping tools:
- Mapperly for C#
- MapStruct for Java
- Automapper for .NET
- class-transformer for TypeScript
4. Multi-level Transformation
In complex systems, multiple levels of transformation may be required:
HTTP DTO → Service DTO → Domain Model → Database Entity
Data Transformation Patterns
1. Direct Conversion in Service
// In service
class UserService {
createUser(userData: CreateUserDTO): UserDTO {
const user = new User(
null, // ID will be generated by DB
userData.name,
userData.email
);
const savedUser = this.userRepository.save(user);
return this.userAssembler.toDTO(savedUser);
}
}
2. Using a Mapper
// Separate mapper
class UserMapper {
toEntity(dto: UserDTO): User {
return new User(dto.id, dto.name, dto.email);
}
toDomain(entity: UserEntity): User {
return new User(entity.id, entity.name, entity.email);
}
}
3. Conversion Pipeline
// Pipeline for complex transformations
class ConversionPipeline {
async convertToDomain(dto: UserDTO): Promise<User> {
const intermediate = this.firstStage(dto);
const processed = this.secondStage(intermediate);
return this.thirdStage(processed);
}
}
Implementation Examples
Example with NestJS (TypeScript)
// Controller
@Controller('users')
export class UsersController {
constructor(private readonly userService: UserService) {}
@Post()
async createUser(@Body() createUserDto: CreateUserDto): Promise<UserDto> {
return this.userService.createUser(createUserDto);
}
}
// Service
@Injectable()
export class UserService {
constructor(
private readonly userRepository: UserRepository,
private readonly userMapper: UserMapper
) {}
async createUser(createUserDto: CreateUserDto): Promise<UserDto> {
const user = this.userMapper.toDomain(createUserDto);
const savedUser = await this.userRepository.save(user);
return this.userMapper.toDto(savedUser);
}
}
// Repository
@Injectable()
export class UserRepository {
constructor(private readonly dataSource: DataSource) {}
async save(user: User): Promise<User> {
const userEntity = this.userMapper.toEntity(user);
const saved = await this.userRepository.save(userEntity);
return this.userMapper.toDomain(saved);
}
}
Example with .NET (C#)
// Controller
[ApiController]
[Route("[controller]")]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
public UsersController(IUserService userService)
{
_userService = userService;
}
[HttpPost]
public async Task<IActionResult> CreateUser([FromBody] CreateUserDto dto)
{
var user = await _userService.CreateUserAsync(dto);
return Ok(user);
}
}
// Service
public class UserService : IUserService
{
private readonly IUserRepository _userRepository;
private readonly IMapper _mapper;
public UserService(IUserRepository userRepository, IMapper mapper)
{
_userRepository = userRepository;
_mapper = mapper;
}
public async Task<UserDto> CreateUserAsync(CreateUserDto dto)
{
var user = _mapper.Map<User>(dto);
await _userRepository.SaveAsync(user);
return _mapper.Map<UserDto>(user);
}
}
// Repository
public class UserRepository : IUserRepository
{
private readonly DbContext _context;
public UserRepository(DbContext context)
{
_context = context;
}
public async Task SaveAsync(User user)
{
var entity = _mapper.Map<UserEntity>(user);
_context.Users.Add(entity);
await _context.SaveChangesAsync();
}
}
Common Mistakes and How to Avoid Them
1. Conversion in Repository
// Bad: Repository knows about DTOs
class UserRepository {
async save(dto: UserDTO): Promise<void> {
const user = this.mapper.fromDTO(dto); // Violates SRP
// Saving...
}
}
2. Direct DTO Saving
// Bad: Saving DTO to database
class UserService {
async save(dto: UserDTO): Promise<void> {
await this.repository.save(dto); // Violates encapsulation
}
}
3. Layer Mixing
// Bad: Controller works with entities
class UserController {
async create(): Promise<void> {
const user = new User(); // Direct entity creation
await this.service.save(user);
}
}
4. Missing Conversion
// Bad: Direct DTO usage in all layers
class UserService {
async save(dto: UserDTO): Promise<UserDTO> {
return this.repository.save(dto); // Layer mixing
}
}
Conclusion
-
DTO conversion should happen in the service layer, not in the repository or controller, to ensure clear separation of responsibilities.
-
Repositories should remain clean - they should work only with domain entities and abstract database implementation details.
-
Use specialized tools for automated mapping (Mapperly, MapStruct, Automapper) to reduce the amount of boilerplate code.
-
DTO Assembler is a powerful pattern for centralizing the transformation logic between domain models and DTOs.
-
Follow unidirectional dependency between layers: controller → service → repository, to avoid circular dependencies and ensure testability of the system.
By following these practices, you’ll create a clean, maintainable, and easily testable multi-layered architecture where each layer performs its specific role, which is critical for the long-term development of enterprise applications.
Sources
- A Better Way to Project Domain Entities into DTOs · Nick Chamberlain
- Designing the infrastructure persistence layer - .NET | Microsoft Learn
- Which layer should be used for conversion to DTO from Domain Object - Stack Overflow
- Architecting with Java Persistence: Patterns and Strategies - InfoQ
- Building a Layered Architecture in NestJS & Typescript: Repository Pattern, DTOs, and Validators | Medium
- Repository Pattern with Layered Architecture, dotnet | Medium
- architectural patterns - Three layer architecture and using DTOs to transfer data between layers - Software Engineering Stack Exchange
- Implementing DTOs, Mappers & the Repository Pattern using the Sequelize ORM [with Examples] - DDD w/ TypeScript | Khalil Stemmler