[CS] CARLOS SEIDL EN
Artigos

Como Mapear DTOs Manualmente em .NET — Parte 3

Mapeamento manual de DTOs com invariantes, objetos aninhados, PATCH seguro, organização por feature e testes xUnit.

Por 8 min de leitura
  • .NET
  • DTO
  • Testes
  • C#
Capa do artigo: Como Mapear DTOs Manualmente em .NET — Parte 3

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

Cópia cega ou mapeamento explícito: quem garante a integridade do domínio
Cópia cega ou mapeamento explícito: quem garante a integridade do domínio

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

Escrevi 10 testes e achei bugs no meu mapeamento de DTO
Vídeo no canal ForgeTechPit: Escrevi 10 testes e achei bugs no meu mapeamento de DTO

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.

Do JSON ao domínio e de volta: o caminho de uma criação de cliente
Do JSON ao domínio e de volta: o caminho de uma criação de cliente

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

Atualização parcial: null significa "não altera"
Atualização parcial: null significa "não altera"

Arquitetura do mapeamento manual de DTOs por feature

A abordagem se resume a três regras:

  1. O DTO representa o que veio ou voltou da borda e não decide nada.
  2. O domínio mantém as invariantes e normaliza os valores.
  3. 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.

Organizado por feature: o mapeamento fica perto do tipo e do contexto de uso
Organizado por feature: o mapeamento fica perto do tipo e do contexto de uso

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:

C#
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:

C#
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:

C#
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:

C#
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:

C#
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:

C#
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":

C#
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:

C#
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:

C#
namespace MapeamentoDtos.Clientes;





public sealed record ClienteRespostaDto(


    Guid Id, string Nome, string Email);
C#
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:

C#
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:

Bash
dotnet test
Resultado do exemplo: nove testes aprovados
Resultado do exemplo: nove testes aprovados

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.cs gigante; 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 null sem 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.cs gigante: 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

Leia também

Sua API .NET precisa escalar com segurança? Conheça o serviço de backend e arquitetura .NET.

← todos os artigos