Why this matters
The Interface Adapters layer contains three types of objects, each translating data across a boundary. Controllers convert an HTTP request into a Use Case input DTO. Presenters convert a Use Case output DTO into a ViewModel for the View. Gateways (Repositories) convert a domain interface call into a SQL query or external API call — and translate the result back into a domain DTO.
The critical rule: whatever crosses a boundary must be the simplest possible data structure. A SQLAlchemy ORM object must never be returned directly from a Use Case. An Express Request object must never be passed into a Use Case. These are framework objects — they carry framework state, lazy-loading proxies, and metadata that has nothing to do with the business rule.
When you pass a plain dataclass or a dict across a boundary, the inner layers are freed from framework upgrades. If SQLAlchemy releases a major version with breaking changes, only the Gateway class needs updating — Entities, Use Cases, and Controllers are untouched because they never saw the ORM object.
The problem
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.
The solution
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.
Key takeaway
Whatever crosses a boundary should be the simplest possible data structure — a dataclass, a dict, a plain object. Never an ORM model. Interface Adapters are translation layers: they know both sides, but they keep those sides from ever meeting directly. Replace the ORM, and only the adapter changes.