Mapeamento de DTOs: cópia cega ou mapeamento explícito

Nas partes anteriores da série vimos por que separar DTO e domínio e como fazer o mapeamento básico, sem biblioteca. Mas o mapeamento de verdade raramente é "copiar propriedades": o cliente manda um nome em branco, o pedido traz uma lista de itens, a tela só quer alterar o e-mail e o resto precisa ficar como está.
Nesta terceira parte vamos além da cópia. Estruturamos mapeamentos manuais para cenários reais: invariantes e normalização, objetos aninhados e coleções, atualização parcial (PATCH), DTO de resposta, organização por feature e testes. O código completo, com os testes, está no repositório (link nas Referências) e roda com um único dotnet test.
Assista ao vídeo

Conceitos de mapeamento de DTOs: DTO, domínio e invariantes
DTO (Data Transfer Object): o formato dos dados que entram e saem pela borda do sistema (API, tela, mensagem). Ele descreve o contrato externo e não carrega regra de negócio, no máximo validações de formato simples.
Domínio e invariantes: o domínio guarda as regras essenciais, as invariantes, que precisam ser verdadeiras sempre. No exemplo: o cliente tem nome e o e-mail é válido. Quem garante isso é o próprio domínio, não quem o chama.
Mapeamento: o código que converte DTO em domínio e domínio em DTO. No mapeamento manual ele é explícito, pequeno e visível: métodos de extensão que você lê, depura e testa como qualquer outro código.

Mapeamento manual ou biblioteca?
- Manual: sem mágica, sem surpresas em tempo de execução, fácil de depurar e de otimizar. O custo é escrever e manter o código de cada conversão.
- Biblioteca (AutoMapper, Mapster): menos código repetitivo, mas a configuração vira outra coisa a manter e erros aparecem tarde, em tempo de execução.
Nesta série seguimos o caminho manual por controle, clareza e previsibilidade.
Atualização parcial (PATCH)
Em um PATCH, o cliente envia só o que quer alterar. Um erro comum é copiar todos os campos do DTO para a entidade: o que não veio (null) apaga o valor existente. A solução é um método de aplicação parcial, em que null significa "não altera".

Arquitetura do mapeamento manual de DTOs por feature
A abordagem se resume a três regras:
- O DTO representa o que veio ou voltou da borda e não decide nada.
- O domínio mantém as invariantes e normaliza os valores.
- O mapeamento conecta os dois de forma explícita, em métodos de extensão pequenos.
No sentido de entrada, o mapeamento chama as fábricas e os métodos do domínio (Cliente.Criar, AlterarNome), de modo que a validação nunca é pulada. No sentido de saída (domínio para resposta), a conversão costuma ser direta e é fácil de otimizar, principalmente combinada com projeção na consulta.
Organização por feature
O mapeamento deve ficar perto do tipo e do contexto de uso. Em vez de um Mapeamentos.cs gigante, cada feature tem sua pasta, com seus DTOs, seu domínio e seus mapeamentos lado a lado.

Mapeamento manual de DTOs em .NET: exemplo com testes
O exemplo é uma biblioteca (MapeamentoDtos) com as features Clientes e Pedidos, mais um projeto de testes xUnit. O código completo está no repositório (link nas Referências). Começamos pelo DTO de entrada, que só carrega dados:
namespace MapeamentoDtos.Clientes;
// DTO de entrada: o que veio da borda (API, UI, mensagem).
// Só carrega dados, sem regra de negócio.
public sealed record CriarClienteDto(string? Nome, string? Email);A entidade Cliente garante as invariantes (nome obrigatório, e-mail válido) e normaliza os valores:
namespace MapeamentoDtos.Clientes;
// Entidade (mutável, por clareza) que garante as invariantes:
// nome obrigatório e e-mail válido.
// Os valores também são normalizados aqui.
public sealed class Cliente
{
public Guid Id { get; private set; } = Guid.NewGuid();
public string Nome { get; private set; } = default!;
public string Email { get; private set; } = default!;
public static Cliente Criar(string? nome, string? email)
{
var c = new Cliente();
c.AlterarNome(nome);
c.AlterarEmail(email);
return c;
}
public void AlterarNome(string? nome)
{
if (string.IsNullOrWhiteSpace(nome))
throw new ArgumentException("Nome é obrigatório.");
Nome = nome.Trim();
}
public void AlterarEmail(string? email)
{
if (string.IsNullOrWhiteSpace(email)
|| !email.Contains('@'))
throw new ArgumentException("E-mail inválido.");
Email = email.Trim().ToLowerInvariant();
}
}O mapeamento de DTO para domínio fica pequeno, porque a regra está no domínio:
namespace MapeamentoDtos.Clientes;
// DTO -> Domínio: o DTO só fornece dados;
// o domínio garante a integridade.
public static class ClienteMapeamentos
{
public static Cliente ParaDominio(this CriarClienteDto dto)
=> Cliente.Criar(dto.Nome, dto.Email);
}Objetos aninhados e coleções: Pedido com Itens
Em sistemas reais há estrutura: Pedido com Itens. Os DTOs de criação:
namespace MapeamentoDtos.Pedidos;
public sealed record CriarPedidoDto(
Guid ClienteId,
List<CriarItemDto> Itens);
public sealed record CriarItemDto(
string Produto,
int Quantidade,
decimal PrecoUnitario);O domínio protege a regra de que um pedido tem ao menos um item e calcula o total:
namespace MapeamentoDtos.Pedidos;
public sealed class Pedido
{
private readonly List<ItemPedido> _itens = new();
public Guid Id { get; } = Guid.NewGuid();
public Guid ClienteId { get; }
public IReadOnlyList<ItemPedido> Itens => _itens;
public decimal Total => _itens.Sum(i => i.Subtotal);
public Pedido(Guid clienteId, IEnumerable<ItemPedido> itens)
{
ClienteId = clienteId;
_itens.AddRange(itens);
if (_itens.Count == 0)
throw new ArgumentException("Pedido sem itens.");
}
}
public sealed record ItemPedido(
string Produto, int Quantidade, decimal PrecoUnitario)
{
public decimal Subtotal => Quantidade * PrecoUnitario;
}Cada tipo sabe mapear o seu pedaço, o que deixa o código limpo e fácil de testar:
namespace MapeamentoDtos.Pedidos;
// Objetos aninhados e coleções:
// cada tipo sabe mapear o seu pedaço.
public static class PedidoMapeamentos
{
public static ItemPedido ParaDominio(this CriarItemDto dto)
{
if (dto.Quantidade <= 0)
throw new ArgumentException("Quantidade inválida.");
return new ItemPedido(
dto.Produto.Trim(),
dto.Quantidade,
dto.PrecoUnitario);
}
public static Pedido ParaDominio(this CriarPedidoDto dto)
=> new(dto.ClienteId,
dto.Itens.Select(i => i.ParaDominio()));
}Atualização parcial (PATCH)
O DTO de atualização tem todos os campos opcionais, e null significa "não altera":
namespace MapeamentoDtos.Clientes;
// DTO de atualização parcial (PATCH):
// null significa "não altera".
public sealed record AtualizarClienteDto(
string? Nome,
string? Email);O patch aplica somente o que veio, chamando os métodos do domínio, então a validação continua centralizada:
namespace MapeamentoDtos.Clientes;
public static class ClientePatch
{
// Aplica somente o que veio; a validação continua no domínio.
public static void Aplicar(
this Cliente cliente, AtualizarClienteDto dto)
{
if (dto.Nome is not null)
cliente.AlterarNome(dto.Nome);
if (dto.Email is not null)
cliente.AlterarEmail(dto.Email);
}
}Domínio para DTO de resposta
Para respostas, o mapeamento costuma ser extremamente direto:
namespace MapeamentoDtos.Clientes;
public sealed record ClienteRespostaDto(
Guid Id, string Nome, string Email);namespace MapeamentoDtos.Clientes;
// Domínio -> DTO de resposta: simples, explícito e rápido.
public static class ClienteRespostaMapeamentos
{
public static ClienteRespostaDto ParaResposta(this Cliente c)
=> new(c.Id, c.Nome, c.Email);
public static List<ClienteRespostaDto> ParaResposta(
this IEnumerable<Cliente> clientes)
=> clientes.Select(c => c.ParaResposta()).ToList();
}Testes de mapeamento
Mapeamento manual é código, e código tem bug. Os testes cobrem normalização, validação, patch e resposta:
using MapeamentoDtos.Clientes;
namespace MapeamentoDtos.Tests;
public class ClienteMapeamentosTests
{
[Fact]
public void Deve_normalizar_nome_e_email()
{
var dto = new CriarClienteDto(
" Ana Souza ", " ANA@Mail.com ");
var cliente = dto.ParaDominio();
Assert.Equal("Ana Souza", cliente.Nome);
Assert.Equal("ana@mail.com", cliente.Email);
}
[Fact]
public void Deve_falhar_sem_nome()
{
var dto = new CriarClienteDto(" ", "ana@mail.com");
Assert.Throws<ArgumentException>(() => dto.ParaDominio());
}
[Fact]
public void Deve_falhar_com_email_invalido()
{
var dto = new CriarClienteDto("Ana", "ana.mail.com");
Assert.Throws<ArgumentException>(() => dto.ParaDominio());
}
[Fact]
public void Patch_com_null_nao_altera()
{
var cliente = Cliente.Criar("Ana", "ana@mail.com");
cliente.Aplicar(new AtualizarClienteDto(null, null));
Assert.Equal("Ana", cliente.Nome);
Assert.Equal("ana@mail.com", cliente.Email);
}
[Fact]
public void Patch_altera_somente_o_que_veio()
{
var cliente = Cliente.Criar("Ana", "ana@mail.com");
cliente.Aplicar(
new AtualizarClienteDto("Ana Souza", null));
Assert.Equal("Ana Souza", cliente.Nome);
Assert.Equal("ana@mail.com", cliente.Email);
}
[Fact]
public void Resposta_espelha_o_dominio()
{
var cliente = Cliente.Criar("Ana", "ana@mail.com");
var resposta = cliente.ParaResposta();
Assert.Equal(cliente.Id, resposta.Id);
Assert.Equal("Ana", resposta.Nome);
}
}Para rodar tudo:
dotnet test
O resultado são 9 testes aprovados: normalização de nome e e-mail, falha sem nome e com e-mail inválido, patch que não altera quando vem null, patch parcial, resposta espelhando o domínio, pedido com itens e total, e falhas de quantidade inválida e pedido sem itens.
Boas práticas no mapeamento manual de DTOs
- O DTO não manda no domínio: ele só fornece dados. Quem garante a integridade é o domínio.
- Não sobrescreva com
null: em atualização parcial, aplique somente os campos informados e mantenha a validação no domínio. - Organize por feature: evite o
Mapeamentos.csgigante; mantenha o mapeamento perto do tipo e do contexto de uso. - Mapeamentos pequenos e explícitos: se um mapeamento cresce demais, é sinal de que há regra de negócio no lugar errado.
- Teste os mapeamentos: normalizações, valores padrão, coleções e patch merecem testes. São baratos e pegam regressões.
- Domínio imutável? O exemplo usa uma entidade mutável por clareza. Com um domínio totalmente imutável, o patch retorna um novo cliente em vez de alterar o existente.
- Listagens grandes: combine o mapeamento de resposta com projeção na consulta (
Select) para não carregar a entidade inteira. - Validação de e-mail: o exemplo usa só
Contains('@')para manter o código pequeno. Em produção, use um validador adequado. - Não misture com autorização: o mapeamento converte formato e valida regra; permissão é outra camada.
Checklist antes de considerar "pronto":
- O DTO não contém regra de negócio (no máximo validações de formato simples).
- O domínio (ou a aplicação) garante as invariantes.
- Os mapeamentos estão organizados por feature ou entidade.
- O update parcial não sobrescreve com
nullsem querer. - Existem testes para normalizações, valores padrão e coleções.
- O código de mapeamento é pequeno e explícito.
Quando usar mapeamento manual de DTOs e quando evitar
Use mapeamento manual quando:
- O domínio tem invariantes que precisam ser preservadas.
- Os DTOs mudam com frequência e você quer ver exatamente o que muda a cada alteração.
- Você valoriza depuração simples e ausência de mágica em tempo de execução.
- O número de conversões é moderado e cada uma tem alguma regra (normalização, validação, agregação).
Avalie uma biblioteca ou geração de código quando:
- Há centenas de tipos com conversão puramente 1 para 1, sem regra alguma: o código manual vira só repetição.
- A equipe prefere configuração a código e tem disciplina para testar os mapeamentos gerados.
Evite, ou revise, quando:
- O mapeamento começa a conter regra de negócio: mova a regra para o domínio.
- Há um
Mapeamentos.csgigante: divida por feature. - Os mapeamentos não têm testes: o código manual sem teste esconde bugs tanto quanto uma biblioteca mal configurada.
Mapeamento manual de DTOs: controle e previsibilidade
Mapeamento manual não é "trabalho a mais": é controle, clareza e previsibilidade. O DTO carrega dados, o domínio garante as invariantes e o mapeamento conecta os dois de forma explícita, em métodos pequenos que você lê e testa.
A reflexão prática: antes de adotar uma biblioteca, pergunte onde as regras vivem e quem as testa. Com objetos aninhados, coleções, PATCH seguro e testes, o mapeamento manual evita magia e permite evoluir o projeto com segurança, principalmente quando os DTOs mudam com frequência e o domínio precisa permanecer sólido.
Referências e links sobre mapeamento de DTOs
- Código do artigo no GitHub: ceseidl/mapeamento-dtos-dotnet
- Microsoft Learn: Métodos de extensão (C#)
- Microsoft Learn: Tipos de record (C#)
- Microsoft Learn: Testes de unidade com xUnit no .NET
- Microsoft Learn: Criar APIs mínimas e DTOs no ASP.NET Core
Leia também
- Records em C#: menos código repetitivo, mais significado!: detalha os records, boa escolha para DTOs imutáveis com igualdade por valor.
- Clean Architecture em .NET: situa DTO e domínio na estrutura de uma solução em camadas.
- Vertical Slices em .NET: mostra a organização por feature que também guia onde ficam os mapeamentos.
Sua API .NET precisa escalar com segurança? Conheça o serviço de backend e arquitetura .NET.
