Skip to main content

Inicia sesión en CleanKata

Sigue tu progreso, gana XP y desbloquea todas las lecciones.

Al iniciar sesión aceptas nuestros Términos de uso y Política de privacidad.

Arquitectura Limpia70 XP7 min

Adaptadores de Interfaz y Paso de Datos

Los Adaptadores de Interfaz convierten datos entre el formato que entienden los casos de uso y el que necesitan los sistemas externos — los límites siempre se cruzan con DTOs simples, nunca con entidades crudas o filas de BD.

Por qué importa

La capa de Adaptadores de Interfaz contiene tres tipos de objetos, cada uno traduciendo datos a través de una frontera. Los Controladores convierten una solicitud HTTP en un DTO de entrada del Caso de Uso. Los Presentadores convierten un DTO de salida del Caso de Uso en un ViewModel para la Vista. Los Gateways (Repositorios) convierten una llamada de interfaz de dominio en una consulta SQL o llamada a API externa — y traducen el resultado de vuelta a un DTO de dominio.

La regla crítica: lo que cruza una frontera debe ser la estructura de datos más simple posible. Un objeto ORM de SQLAlchemy nunca debe retornarse directamente desde un Caso de Uso. Un objeto Request de Express nunca debe pasarse a un Caso de Uso. Estos son objetos del framework — llevan estado del framework, proxies de carga perezosa y metadatos que nada tienen que ver con la regla de negocio.

Cuando pasas un dataclass simple o un diccionario a través de una frontera, las capas internas quedan liberadas de las actualizaciones del framework. Si SQLAlchemy publica una versión mayor con cambios disruptivos, solo la clase Gateway necesita actualizarse — Entidades, Casos de Uso y Controladores no se tocan porque nunca vieron el objeto ORM.

✗El problema

A Use Case returns a raw ORM object that crosses all boundaries — coupling the controller, the API response, and the use case to SQLAlchemy or TypeORM simultaneously.

Bad

from sqlalchemy.orm import Session
from models import UserModel  # SQLAlchemy ORM model

class GetUserUseCase:
    def __init__(self, session: Session):
        self._session = session

    def execute(self, user_id: str) -> UserModel:  # returns raw ORM object!
        return self._session.query(UserModel).filter_by(id=user_id).first()

# In the controller:
def get_user_endpoint(user_id):
    use_case = GetUserUseCase(db_session)
    user     = use_case.execute(user_id)
    return jsonify(user.__dict__)  # leaks _sa_instance_state

# Use Case depends on SQLAlchemy. Controller depends on SQLAlchemy.
# Replacing SQLAlchemy: changes required in Use Case AND Controller.
import { UserEntity } from "./User.entity";  // TypeORM entity

export class GetUserUseCase {
  constructor(private repo: Repository<UserEntity>) {}

  async execute(userId: string): Promise<UserEntity> {  // raw TypeORM entity!
    return this.repo.findOneBy({ id: userId });
  }
}

// Controller:
const user = await useCase.execute(id);
res.json(user);  // leaks TypeORM lazy-load proxies and __entity__ metadata

// UseCase and Controller are both coupled to TypeORM.
// Replace TypeORM: changes needed everywhere UserEntity is referenced.

✓La solución

Boundaries are crossed with plain DTOs. The Gateway (Repository adapter) translates ORM to DTO — only the adapter knows about the ORM.

Good

from dataclasses import dataclass
from abc import ABC, abstractmethod

@dataclass
class UserOutputDTO:
    id: str
    name: str
    email: str

class UserRepository(ABC):
    @abstractmethod
    def find_by_id(self, user_id: str) -> UserOutputDTO | None: ...

class GetUserUseCase:
    def __init__(self, repo: UserRepository):
        self._repo = repo

    def execute(self, user_id: str) -> UserOutputDTO | None:
        return self._repo.find_by_id(user_id)  # plain dataclass, not ORM model

# Gateway (Interface Adapter) — only this class knows SQLAlchemy:
class SqlUserRepository(UserRepository):
    def find_by_id(self, user_id: str) -> UserOutputDTO | None:
        row = self._session.query(UserModel).filter_by(id=user_id).first()
        if row is None: return None
        return UserOutputDTO(id=str(row.id), name=row.name, email=row.email)

# Replace SQLAlchemy: only SqlUserRepository changes. Everything else untouched.
export interface UserOutputDTO { id: string; name: string; email: string; }

export interface UserRepository {
  findById(userId: string): Promise<UserOutputDTO | null>;
}

export class GetUserUseCase {
  constructor(private readonly repo: UserRepository) {}

  async execute(userId: string): Promise<UserOutputDTO | null> {
    return this.repo.findById(userId);  // plain DTO, not ORM entity
  }
}

// Interface Adapter — only this class knows TypeORM:
export class TypeOrmUserRepository implements UserRepository {
  async findById(userId: string): Promise<UserOutputDTO | null> {
    const entity = await this.ormRepo.findOneBy({ id: userId });
    if (!entity) return null;
    return { id: entity.id, name: entity.name, email: entity.email };
  }
}

// Replace TypeORM with Prisma: only TypeOrmUserRepository changes.

💡Conclusión clave

Lo que cruza una frontera debe ser la estructura de datos más simple posible — un dataclass, un diccionario, un objeto simple. Nunca un modelo ORM. Los Adaptadores de Interfaz son capas de traducción: conocen ambos lados, pero evitan que esos lados se encuentren directamente. Reemplaza el ORM, y solo cambia el adaptador.

🔧 Algunos ejercicios pueden tener errores. Si algo parece incorrecto, usa el botón Feedback (abajo a la derecha) para reportarlo — nos ayuda a corregirlo rápido.

Pista: Lo que cruce un límite debe ser la estructura de datos más simple posible — un dataclass, un dict, un objeto plano. Nunca un modelo ORM.

✗ Tu versión