DTOs y Mappers

Intermedio
FastAPI
FastAPI
Actualizado: 23/09/2025

Data Transfer Objects

Los Data Transfer Objects (DTOs) son objetos que transportan datos entre diferentes capas de una aplicación, actuando como un puente entre la capa de presentación y la lógica de negocio. En el contexto de FastAPI, los DTOs nos permiten separar claramente lo que exponemos en nuestra API de la estructura interna de nuestros modelos de base de datos.

La necesidad de DTOs surge cuando queremos controlar exactamente qué información se envía y recibe a través de nuestra API, sin estar limitados por la estructura de nuestros modelos SQLAlchemy. Esto nos proporciona flexibilidad, seguridad y una mejor arquitectura.

¿Por qué usar DTOs?

Imagina que tienes un modelo User en SQLAlchemy que contiene información sensible como contraseñas hash, tokens de sesión, o datos internos que no deberían ser expuestos públicamente. Los DTOs nos permiten filtrar y transformar esta información antes de enviarla al cliente.

from sqlalchemy import Column, Integer, String, DateTime, Boolean
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()

class User(Base):
    __tablename__ = "users"
    
    id = Column(Integer, primary_key=True)
    email = Column(String, unique=True, nullable=False)
    password_hash = Column(String, nullable=False)  # Información sensible
    is_active = Column(Boolean, default=True)
    created_at = Column(DateTime)
    last_login = Column(DateTime)
    session_token = Column(String)  # Información interna

Para este modelo, no queremos exponer el password_hash ni el session_token en nuestras respuestas API. Aquí es donde entran los DTOs.

Creando DTOs con Pydantic

En FastAPI, los DTOs se implementan típicamente usando Pydantic models. Estos modelos definen exactamente qué campos queremos incluir en nuestras transferencias de datos y sus tipos correspondientes.

from pydantic import BaseModel, EmailStr
from datetime import datetime
from typing import Optional

class UserResponseDTO(BaseModel):
    id: int
    email: EmailStr
    is_active: bool
    created_at: datetime
    last_login: Optional[datetime] = None
    
    class Config:
        from_attributes = True

El UserResponseDTO incluye solo los campos que queremos exponer públicamente, omitiendo información sensible como contraseñas y tokens.

DTOs para diferentes operaciones

Una práctica común es crear DTOs específicos para diferentes operaciones CRUD, cada uno adaptado a su propósito específico.

DTO para creación de usuarios:

class UserCreateDTO(BaseModel):
    email: EmailStr
    password: str  # Contraseña en texto plano que será procesada
    
    class Config:
        # Ejemplo de validación personalizada
        schema_extra = {
            "example": {
                "email": "usuario@ejemplo.com",
                "password": "mi_contraseña_segura"
            }
        }

DTO para actualización de usuarios:

class UserUpdateDTO(BaseModel):
    email: Optional[EmailStr] = None
    password: Optional[str] = None
    is_active: Optional[bool] = None

DTO para respuestas de listado:

class UserListItemDTO(BaseModel):
    id: int
    email: EmailStr
    is_active: bool
    
    class Config:
        from_attributes = True

Validación y transformación en DTOs

Los DTOs no solo transportan datos, también pueden validar y transformar información antes de procesarla. Pydantic nos permite agregar validadores personalizados que se ejecutan automáticamente.

from pydantic import field_validator
import re

class UserCreateDTO(BaseModel):
    email: EmailStr
    password: str
    
    @field_validator('password')
    def validate_password_strength(cls, password, info):
        if len(password) < 8:
            raise ValueError('La contraseña debe tener al menos 8 caracteres')
        
        if not re.search(r'[A-Z]', password):
            raise ValueError('La contraseña debe contener al menos una mayúscula')
            
        if not re.search(r'[0-9]', password):
            raise ValueError('La contraseña debe contener al menos un número')
            
        return password

DTOs anidados para relaciones

Cuando trabajamos con modelos que tienen relaciones, podemos crear DTOs anidados que representen estas asociaciones de manera controlada.

class PostSummaryDTO(BaseModel):
    id: int
    title: str
    created_at: datetime
    
    class Config:
        from_attributes = True

class UserWithPostsDTO(BaseModel):
    id: int
    email: EmailStr
    is_active: bool
    posts: List[PostSummaryDTO] = []
    
    class Config:
        from_attributes = True

Este enfoque nos permite controlar la profundidad de los datos relacionados que incluimos en nuestras respuestas, evitando problemas como la serialización circular o la exposición de demasiada información.

Usando DTOs en endpoints

Los DTOs se integran naturalmente en los endpoints de FastAPI, proporcionando validación automática de entrada y garantizando la consistencia de las respuestas.

from fastapi import FastAPI, HTTPException
from typing import List

app = FastAPI()

@app.post("/users", response_model=UserResponseDTO)
async def create_user(user_data: UserCreateDTO):
    # La validación del DTO ocurre automáticamente
    # user_data.password ya está validado según nuestras reglas
    
    # Aquí iría la lógica de creación del usuario
    # (que veremos en la siguiente sección sobre mapeo)
    pass

@app.get("/users", response_model=List[UserListItemDTO])
async def list_users():
    # Devolver lista de usuarios usando el DTO de listado
    pass

@app.get("/users/{user_id}", response_model=UserResponseDTO)
async def get_user(user_id: int):
    # Devolver un usuario específico
    pass

Beneficios de los DTOs

El uso de DTOs en FastAPI nos proporciona múltiples ventajas:

  • Seguridad: Control total sobre qué información se expone
  • Flexibilidad: Diferentes representaciones para diferentes casos de uso
  • Validación: Reglas de negocio aplicadas automáticamente
  • Documentación: FastAPI genera automáticamente la documentación OpenAPI basada en los DTOs
  • Evolución: Capacidad de cambiar la API sin afectar los modelos internos

Los DTOs actúan como un contrato bien definido entre nuestra API y los clientes que la consumen, proporcionando estabilidad y claridad en las comunicaciones. En la siguiente sección veremos cómo transformar eficientemente entre nuestros modelos SQLAlchemy y estos DTOs usando técnicas de mapeo.

Mapeo entre modelos

El mapeo entre modelos es el proceso de convertir datos entre diferentes representaciones, específicamente entre nuestros modelos SQLAlchemy y los DTOs que hemos definido. Esta transformación es esencial para mantener la separación entre la capa de datos y la capa de presentación en nuestras aplicaciones FastAPI.

La necesidad del mapeo surge porque los modelos SQLAlchemy están diseñados para representar la estructura de la base de datos, mientras que los DTOs están optimizados para la comunicación a través de la API. Cada uno tiene un propósito específico y requiere estructuras diferentes.

Mapeo básico modelo a DTO

La forma más directa de mapear un modelo SQLAlchemy a un DTO es utilizar la configuración from_attributes = True en Pydantic, que permite crear instancias del DTO directamente desde objetos con atributos.

# Modelo SQLAlchemy
class User(Base):
    __tablename__ = "users"
    
    id = Column(Integer, primary_key=True)
    email = Column(String, unique=True, nullable=False)
    full_name = Column(String, nullable=False)
    is_active = Column(Boolean, default=True)
    created_at = Column(DateTime, default=datetime.utcnow)

# DTO
class UserResponseDTO(BaseModel):
    id: int
    email: str
    full_name: str
    is_active: bool
    created_at: datetime
    
    class Config:
        from_attributes = True

# Mapeo directo
def map_user_to_dto(user: User) -> UserResponseDTO:
    return UserResponseDTO.model_validate(user)

Este enfoque funciona cuando los nombres de los campos coinciden entre el modelo y el DTO. Pydantic automáticamente extrae los valores de los atributos del objeto SQLAlchemy.

Funciones mapper personalizadas

Cuando necesitamos más control sobre la transformación, podemos crear funciones mapper personalizadas que manejen la conversión de manera explícita.

def map_user_to_response_dto(user: User) -> UserResponseDTO:
    """
    Convierte un modelo User de SQLAlchemy a UserResponseDTO.
    Incluye transformaciones personalizadas si son necesarias.
    """
    return UserResponseDTO(
        id=user.id,
        email=user.email,
        full_name=user.full_name,
        is_active=user.is_active,
        created_at=user.created_at
    )

def map_user_to_list_item_dto(user: User) -> UserListItemDTO:
    """
    Convierte un modelo User a una versión resumida para listados.
    """
    return UserListItemDTO(
        id=user.id,
        email=user.email,
        is_active=user.is_active
    )

Mapeo DTO a modelo

Para el mapeo inverso, desde DTO hacia modelo SQLAlchemy, necesitamos ser más explícitos ya que estamos creando instancias de clases que interactuarán con la base de datos.

def map_create_dto_to_user(user_dto: UserCreateDTO) -> User:
    """
    Convierte un UserCreateDTO en un modelo User de SQLAlchemy.
    Excluye campos que se generan automáticamente como ID y timestamps.
    """
    return User(
        email=user_dto.email,
        full_name=user_dto.full_name,
        # password_hash se calcularía aquí usando un hash seguro
        # created_at se asigna automáticamente por el modelo
    )

def update_user_from_dto(user: User, update_dto: UserUpdateDTO) -> User:
    """
    Actualiza un modelo User existente con datos del DTO de actualización.
    Solo modifica los campos que están presentes en el DTO.
    """
    if update_dto.email is not None:
        user.email = update_dto.email
    
    if update_dto.full_name is not None:
        user.full_name = update_dto.full_name
        
    if update_dto.is_active is not None:
        user.is_active = update_dto.is_active
        
    return user

Mapeo con transformaciones de datos

A menudo necesitamos aplicar transformaciones durante el mapeo, como calcular campos derivados, formatear datos o aplicar lógica de negocio específica.

from datetime import datetime, timezone

def map_user_with_computed_fields(user: User) -> UserResponseDTO:
    """
    Mapea un usuario incluyendo campos calculados y transformaciones.
    """
    # Calcular tiempo desde la creación
    time_since_creation = datetime.utcnow() - user.created_at
    days_active = time_since_creation.days
    
    return UserResponseDTO(
        id=user.id,
        email=user.email,
        full_name=user.full_name.title(),  # Formatear nombre
        is_active=user.is_active,
        created_at=user.created_at,
        days_since_registration=days_active  # Campo calculado
    )

Mapeo de listas y colecciones

Cuando trabajamos con múltiples registros, necesitamos mapear colecciones de manera eficiente. Python nos proporciona varias herramientas para esto.

from typing import List

def map_users_to_list_dto(users: List[User]) -> List[UserListItemDTO]:
    """
    Convierte una lista de usuarios a DTOs de listado.
    """
    return [map_user_to_list_item_dto(user) for user in users]

# Alternativa usando map()
def map_users_using_builtin_map(users: List[User]) -> List[UserListItemDTO]:
    """
    Utiliza la función map() incorporada para mayor eficiencia.
    """
    return list(map(map_user_to_list_item_dto, users))

Mapeo de relaciones complejas

Cuando nuestros modelos tienen relaciones con otros modelos, el mapeo se vuelve más complejo. Necesitamos decidir qué nivel de profundidad incluir y cómo representar estas relaciones.

# Modelos con relación
class Post(Base):
    __tablename__ = "posts"
    
    id = Column(Integer, primary_key=True)
    title = Column(String, nullable=False)
    content = Column(Text)
    user_id = Column(Integer, ForeignKey("users.id"))
    created_at = Column(DateTime, default=datetime.utcnow)
    
    # Relación
    author = relationship("User", back_populates="posts")

# DTOs para la relación
class PostSummaryDTO(BaseModel):
    id: int
    title: str
    created_at: datetime
    
    class Config:
        from_attributes = True

class UserWithPostsDTO(BaseModel):
    id: int
    email: str
    full_name: str
    posts: List[PostSummaryDTO] = []
    
    class Config:
        from_attributes = True

# Mapper para relaciones
def map_user_with_posts(user: User) -> UserWithPostsDTO:
    """
    Mapea un usuario incluyendo sus posts relacionados.
    """
    return UserWithPostsDTO(
        id=user.id,
        email=user.email,
        full_name=user.full_name,
        posts=[PostSummaryDTO.model_validate(post) for post in user.posts]
    )

Clase Mapper para organización

Para proyectos más grandes, es útil organizar los mappers en una clase dedicada que agrupe todas las transformaciones relacionadas.

class UserMapper:
    """
    Clase que agrupa todas las operaciones de mapeo para el modelo User.
    """
    
    @staticmethod
    def to_response_dto(user: User) -> UserResponseDTO:
        return UserResponseDTO.model_validate(user)
    
    @staticmethod
    def to_list_item_dto(user: User) -> UserListItemDTO:
        return UserListItemDTO.model_validate(user)
    
    @staticmethod
    def from_create_dto(dto: UserCreateDTO) -> User:
        return User(
            email=dto.email,
            full_name=dto.full_name
        )
    
    @staticmethod
    def update_from_dto(user: User, dto: UserUpdateDTO) -> User:
        if dto.email is not None:
            user.email = dto.email
        if dto.full_name is not None:
            user.full_name = dto.full_name
        if dto.is_active is not None:
            user.is_active = dto.is_active
        return user
    
    @staticmethod
    def to_dto_list(users: List[User]) -> List[UserListItemDTO]:
        return [UserMapper.to_list_item_dto(user) for user in users]

Integración en endpoints

Los mappers se integran de forma natural en nuestros endpoints, proporcionando una capa de abstracción limpia entre los datos de la base de datos y las respuestas de la API.

from fastapi import FastAPI, HTTPException, Depends
from sqlalchemy.orm import Session

app = FastAPI()

@app.post("/users", response_model=UserResponseDTO)
async def create_user(
    user_data: UserCreateDTO,
    db: Session = Depends(get_database_session)
):
    # Mapear DTO a modelo
    new_user = UserMapper.from_create_dto(user_data)
    
    # Guardar en base de datos
    db.add(new_user)
    db.commit()
    db.refresh(new_user)
    
    # Mapear modelo a DTO de respuesta
    return UserMapper.to_response_dto(new_user)

@app.get("/users", response_model=List[UserListItemDTO])
async def list_users(db: Session = Depends(get_database_session)):
    users = db.query(User).all()
    return UserMapper.to_dto_list(users)

@app.put("/users/{user_id}", response_model=UserResponseDTO)
async def update_user(
    user_id: int,
    update_data: UserUpdateDTO,
    db: Session = Depends(get_database_session)
):
    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise HTTPException(status_code=404, detail="Usuario no encontrado")
    
    # Actualizar usando el mapper
    updated_user = UserMapper.update_from_dto(user, update_data)
    db.commit()
    
    return UserMapper.to_response_dto(updated_user)

Consideraciones de rendimiento

Al trabajar con mapeo, especialmente con grandes volúmenes de datos, es importante considerar el rendimiento:

  • Mapeo por lotes: Para listas grandes, considera procesar en lotes
  • Carga selectiva: Solo carga las relaciones que realmente necesitas
  • Caché de mappers: Para transformaciones complejas que se repiten frecuentemente
  • Validación condicional: Usa model_validate() solo cuando sea necesario
def map_users_efficiently(users: List[User], include_posts: bool = False) -> List[UserResponseDTO]:
    """
    Mapeo eficiente que permite controlar qué datos incluir.
    """
    if include_posts:
        return [UserMapper.to_response_with_posts(user) for user in users]
    else:
        return [UserMapper.to_response_dto(user) for user in users]

El mapeo entre modelos es una pieza fundamental en la arquitectura de aplicaciones FastAPI que usan SQLAlchemy. Proporciona la flexibilidad necesaria para mantener separadas las preocupaciones de datos y presentación, mientras permite evolucionar tanto la API como el modelo de datos de manera independiente.

Fuentes y referencias

Documentación oficial y recursos externos para profundizar en FastAPI

Documentación oficial de FastAPI
Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, FastAPI es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de FastAPI

Explora más contenido relacionado con FastAPI y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

  • Comprender qué son los Data Transfer Objects (DTOs) y su función en la arquitectura de una API.
  • Aprender a definir DTOs con Pydantic para controlar la información expuesta y validada.
  • Implementar funciones y clases mapper para transformar datos entre modelos SQLAlchemy y DTOs.
  • Gestionar relaciones y colecciones mediante DTOs anidados y mapeo adecuado.
  • Integrar DTOs y mappers en endpoints de FastAPI para mejorar seguridad, flexibilidad y mantenimiento.