.NET y backend

DTOs: por qué no exponer tus entidades de dominio en la API

Devolver la entidad de EF Core en la API ahorra un archivo hoy y trae overposting, ciclos y contratos frágiles mañana. Cuándo usar DTOs y cómo mapear.

Por Leyton Manuel Espitia Diaz 5 min de lectura

Fragmento de código C# con un record DTO y su mapeo desde una entidad

Al empezar un proyecto, crear un DTO por cada entidad parece burocracia. La entidad Customer ya tiene los campos que necesita el frontend, así que el controlador la devuelve directamente. Funciona y ahorra tiempo.

Meses después, alguien agrega a Customer la columna PasswordHash, o InternalNotes, o una colección de Orders con su navegación de vuelta al cliente. Y la API empieza a devolver cosas que nunca debió devolver.

Problema 1: el contrato queda atado a la base de datos

Cuando la API devuelve la entidad, cada cambio en el modelo de datos es un cambio en el contrato público. Renombrar una columna rompe a los clientes. Agregar una columna sensible la expone. Separar una tabla en dos cambia la forma del JSON.

Con un DTO, el modelo interno puede evolucionar mientras el contrato se mantiene:

public record CustomerResponse(
    int Id,
    string Name,
    string Email,
    DateOnly CustomerSince);

Este tipo dice exactamente qué promete la API. Nada más se filtra por accidente.

Problema 2: overposting

El problema inverso, en los datos de entrada, es más grave:

// ❌ El cliente puede enviar cualquier propiedad de la entidad
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, Customer customer)
{
    db.Customers.Update(customer);
    await db.SaveChangesAsync();
    return NoContent();
}

Si Customer tiene IsAdmin, CreditLimit o Discount, un cliente malicioso solo tiene que agregarlos al JSON. El model binding los asigna y EF Core los guarda. A esto se le llama overposting o mass assignment.

Con un DTO de entrada, solo se aceptan los campos que el caso de uso permite:

public record UpdateCustomerRequest(string Name, string Email);

[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, UpdateCustomerRequest request, CancellationToken ct)
{
    var customer = await db.Customers.FindAsync([id], ct);
    if (customer is null) return NotFound();

    customer.Name = request.Name;
    customer.Email = request.Email;
    await db.SaveChangesAsync(ct);
    return NoContent();
}

Problema 3: ciclos y consultas inesperadas

Una entidad con navegaciones bidireccionales (Order.Customer y Customer.Orders) produce un ciclo al serializarla. System.Text.Json lanza una excepción, y la "solución" rápida de ReferenceHandler.IgnoreCycles esconde el problema sin resolverlo.

Con lazy loading activo es peor: el serializador recorre cada propiedad de navegación y dispara una consulta por cada una. Un GET que debía hacer una consulta termina haciendo decenas.

Problema 4: una entidad no sirve para todas las pantallas

El listado de clientes necesita nombre y ciudad. El detalle necesita todo. La exportación necesita el total comprado. Devolver la misma entidad a los tres obliga a cargar siempre lo máximo. Con DTOs, cada respuesta tiene la forma que necesita y la consulta trae solo eso:

var customers = await db.Customers
    .Select(c => new CustomerListItem(c.Id, c.Name, c.City))
    .ToListAsync(ct);

Esta proyección directa al DTO es, además, la forma más eficiente de consultar con EF Core.

¿Cómo mapear?

A mano

Para la mayoría de proyectos, el mapeo manual es la opción más simple de mantener: es explícito, lo encuentra el "Buscar referencias" del IDE y un error de compilación avisa cuando cambia una propiedad.

public static class CustomerMappings
{
    public static CustomerResponse ToResponse(this Customer c) =>
        new(c.Id, c.Name, c.Email, DateOnly.FromDateTime(c.CreatedAt));
}

Con una biblioteca

AutoMapper fue durante años la opción por defecto. Desde 2025 tiene licencia comercial para empresas por encima de cierto tamaño, lo que obliga a revisar si el proyecto puede usarlo. Además, sus errores aparecen en ejecución: si una propiedad cambia de nombre, el mapeo falla o queda en null sin que el compilador avise.

Si el volumen de mapeos lo justifica, Mapperly genera el código de mapeo en tiempo de compilación con un source generator. Es tan rápido como el mapeo manual y avisa de los errores al compilar.

El trade-off

Los DTOs agregan archivos y código de mapeo. En un CRUD de 30 entidades son 60 o 90 tipos más. Ese costo es real, y es la razón por la que tantos proyectos los omiten.

A cambio, ganas un contrato estable, protección contra overposting y consultas que traen solo lo necesario. En una API que consumen otros, ese intercambio casi siempre vale la pena.

Cuándo se puede prescindir de DTOs

  • Herramientas internas pequeñas donde la API y el frontend los mantiene la misma persona y se despliegan juntos.
  • Prototipos que se van a reescribir.
  • Datos de catálogo de solo lectura sin campos sensibles ni navegaciones (por ejemplo, una lista de países).

Aun en esos casos, para los datos de entrada conviene usar siempre un tipo propio. El overposting no depende del tamaño del proyecto.

Conclusión

Una entidad describe cómo guardas los datos; un DTO describe qué prometes a quien consume la API. Cuando son lo mismo, cualquier cambio interno se vuelve un cambio público. Separarlos cuesta unos cuantos archivos y evita incidentes de seguridad y contratos rotos que cuestan mucho más. Esto encaja con lo que vimos sobre diseñar una API REST mantenible.

Leyton Manuel Espitia Diaz

Escrito por

Leyton Manuel Espitia Diaz

CEO y fundador de Nakanaba

Desarrollador de software y fundador de Nakanaba. Lidera la arquitectura de Vitrin: backend en .NET, aplicaciones en Angular y .NET MAUI, SQL Server y servicios en Cloudflare.

¿Necesitas un equipo .NET para tu proyecto?

Construimos y modernizamos backends en .NET y SQL Server: APIs, integraciones y sistemas empresariales a medida.

Ver desarrollo a medida