Skip to main content
Clean Architecture 70 XP · 7 min

Interface Adapters and Data Passing

Interface Adapters convert data between the format use cases understand and the format external systems need — boundaries are always crossed with simple DTOs, never raw entities or DB rows.

Showing
Ad (728×90)

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.

Done with this lesson?

Mark it complete to earn XP and track your progress.