NeuroAgent

DTO to Dictionary Conversion: Best Practices in Layered Architecture

Discover the optimal layer for converting DTO to dictionary before database storage in multi-layered architecture. Learn best practices for DTO mapping and layer responsibility separation.

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?

NeuroAgent

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

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:

typescript
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

typescript
// 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

typescript
// 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

typescript
// 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)

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#)

csharp
// 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

typescript
// 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

typescript
// Bad: Saving DTO to database
class UserService {
  async save(dto: UserDTO): Promise<void> {
    await this.repository.save(dto); // Violates encapsulation
  }
}

3. Layer Mixing

typescript
// 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

typescript
// Bad: Direct DTO usage in all layers
class UserService {
  async save(dto: UserDTO): Promise<UserDTO> {
    return this.repository.save(dto); // Layer mixing
  }
}

Conclusion

  1. DTO conversion should happen in the service layer, not in the repository or controller, to ensure clear separation of responsibilities.

  2. Repositories should remain clean - they should work only with domain entities and abstract database implementation details.

  3. Use specialized tools for automated mapping (Mapperly, MapStruct, Automapper) to reduce the amount of boilerplate code.

  4. DTO Assembler is a powerful pattern for centralizing the transformation logic between domain models and DTOs.

  5. 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

  1. A Better Way to Project Domain Entities into DTOs · Nick Chamberlain
  2. Designing the infrastructure persistence layer - .NET | Microsoft Learn
  3. Which layer should be used for conversion to DTO from Domain Object - Stack Overflow
  4. Architecting with Java Persistence: Patterns and Strategies - InfoQ
  5. Building a Layered Architecture in NestJS & Typescript: Repository Pattern, DTOs, and Validators | Medium
  6. Repository Pattern with Layered Architecture, dotnet | Medium
  7. architectural patterns - Three layer architecture and using DTOs to transfer data between layers - Software Engineering Stack Exchange
  8. Implementing DTOs, Mappers & the Repository Pattern using the Sequelize ORM [with Examples] - DDD w/ TypeScript | Khalil Stemmler