DEV Community

Cover image for Clean Architecture en Frontend (React / Next.js + TypeScript)
Edgar Joaquin
Edgar Joaquin

Posted on

Clean Architecture en Frontend (React / Next.js + TypeScript)

Clean Architecture en Frontend (React / Next.js + TypeScript)

Guía práctica con la estructura de carpetas/archivos que suele usarse al aplicar Clean Architecture en un frontend. La idea central es separar el código en capas con responsabilidades claras, donde las capas internas (dominio) no dependen de las externas (UI, frameworks, APIs).

Las capas, de adentro hacia afuera

  • Domain (Entidades) — Reglas de negocio puras. No depende de nada externo (ni de React, ni de Axios, ni de Next).

  • Application (Casos de uso) — Orquesta la lógica de negocio usando el dominio. Define interfaces (contratos) que la infraestructura implementará.

  • Infrastructure (Adaptadores) — Implementaciones concretas: llamadas HTTP, localStorage, SDKs de terceros, repositorios.

  • Presentation (UI) — Componentes, páginas, hooks, estado de UI. Depende de application, nunca al revés.
    La regla de oro: las dependencias siempre apuntan hacia adentro (presentation → application → domain), nunca al revés.

Ejemplo 1: React (Vite) + TypeScript

src/
├── domain/
│   ├── entities/
│   │   ├── User.ts
│   │   ├── Product.ts
│   │   └── Order.ts
│   ├── value-objects/
│   │   ├── Email.ts
│   │   └── Money.ts
│   ├── errors/
│   │   └── DomainError.ts
│   └── repositories/                 # Interfaces (contratos), no implementación
│       ├── IUserRepository.ts
│       └── IProductRepository.ts
│
├── application/
│   ├── use-cases/
│   │   ├── user/
│   │   │   ├── GetUserById.ts
│   │   │   ├── UpdateUserProfile.ts
│   │   │   └── DeleteUser.ts
│   │   └── product/
│   │       ├── GetProductList.ts
│   │       └── CreateProduct.ts
│   ├── dtos/
│   │   ├── UserDTO.ts
│   │   └── ProductDTO.ts
│   └── mappers/
│       ├── UserMapper.ts             # Entity <-> DTO
│       └── ProductMapper.ts
│
├── infrastructure/
│   ├── http/
│   │   ├── axiosClient.ts
│   │   └── endpoints.ts
│   ├── repositories/                 # Implementan las interfaces del domain
│   │   ├── HttpUserRepository.ts
│   │   └── HttpProductRepository.ts
│   ├── storage/
│   │   └── LocalStorageService.ts
│   └── config/
│       └── env.ts
│
├── presentation/
│   ├── components/
│   │   ├── common/
│   │   │   ├── Button.tsx
│   │   │   └── Input.tsx
│   │   └── user/
│   │       ├── UserCard.tsx
│   │       └── UserForm.tsx
│   ├── pages/
│   │   ├── UserProfilePage.tsx
│   │   └── ProductListPage.tsx
│   ├── hooks/
│   │   ├── useUser.ts                # Consume los use-cases
│   │   └── useProducts.ts
│   ├── context/
│   │   └── AuthContext.tsx
│   ├── routes/
│   │   └── AppRouter.tsx
│   └── styles/
│       └── globals.css
│
├── shared/
│   ├── utils/
│   │   ├── formatDate.ts
│   │   └── validators.ts
│   ├── constants/
│   │   └── index.ts
│   └── types/
│       └── common.types.ts
│
├── di/                                # Inyección de dependencias (composition root)
│   └── container.ts
│
├── App.tsx
└── main.tsx
Enter fullscreen mode Exit fullscreen mode

Cómo fluye una petición (ejemplo: obtener un usuario)

UserProfilePage.tsx
  → useUser.ts (hook)
    → GetUserById.ts (use-case, application)
      → IUserRepository.ts (interfaz, domain)
        ← HttpUserRepository.ts (implementación real, infrastructure)
Enter fullscreen mode Exit fullscreen mode

El hook y la página no saben si el dato viene de una API REST, GraphQL o localStorage. Eso vive solo en infrastructure/.

Ejemplo 2: Next.js (App Router) + TypeScript

Next.js añade la carpeta app/ como capa de ruteo/presentación, pero el resto de capas se mantienen igual, normalmente dentro de src/.

src/
├── app/                               # Solo ruteo, layouts y páginas (Next App Router)
│   ├── layout.tsx
│   ├── page.tsx
│   ├── users/
│   │   ├── page.tsx
│   │   └── [id]/
│   │       └── page.tsx
│   ├── products/
│   │   ├── page.tsx
│   │   └── loading.tsx
│   └── api/                           # Route handlers (si se usan como BFF)
│       └── users/
│           └── route.ts
│
├── domain/
│   ├── entities/
│   │   ├── User.ts
│   │   └── Product.ts
│   ├── value-objects/
│   │   └── Email.ts
│   ├── errors/
│   │   └── DomainError.ts
│   └── repositories/
│       ├── IUserRepository.ts
│       └── IProductRepository.ts
│
├── application/
│   ├── use-cases/
│   │   ├── user/
│   │   │   └── GetUserById.ts
│   │   └── product/
│   │       └── GetProductList.ts
│   ├── dtos/
│   │   └── UserDTO.ts
│   └── mappers/
│       └── UserMapper.ts
│
├── infrastructure/
│   ├── http/
│   │   └── fetchClient.ts
│   ├── repositories/
│   │   ├── HttpUserRepository.ts
│   │   └── HttpProductRepository.ts
│   ├── server/                        # Repos que corren en Server Components/Actions
│   │   └── ServerUserRepository.ts
│   └── config/
│       └── env.ts
│
├── presentation/
│   ├── components/
│   │   ├── common/
│   │   │   └── Button.tsx
│   │   └── user/
│   │       └── UserCard.tsx
│   ├── hooks/
│   │   └── useUser.ts                 # Para Client Components
│   └── view-models/
│       └── UserViewModel.ts           # Formatea DTOs para la vista
│
├── shared/
│   ├── utils/
│   │   └── formatDate.ts
│   └── types/
│       └── common.types.ts
│
├── di/
│   └── container.ts
│
└── middleware.ts
Enter fullscreen mode Exit fullscreen mode

Diferencias clave respecto a React puro

  • app/page.tsx (Server Component) puede llamar directamente a un use-case desde application/, sin pasar por un hook, ya que corre en el servidor.

  • Los Client Components siguen usando hooks de presentation/hooks/ para invocar use-cases (a través de una API route o Server Action).

  • infrastructure/server/ separa repositorios que solo pueden ejecutarse en servidor (con acceso directo a BD, secretos, etc.) de los que corren en cliente.

Reglas prácticas para no romper la arquitectura

  • domain/ nunca importa nada de application/, infrastructure/ o presentation/.

  • application/ solo importa de domain/ (entidades e interfaces), nunca de infrastructure/ directamente — recibe las implementaciones por inyección de dependencias.

  • infrastructure/ implementa las interfaces definidas en domain/repositories/.

  • presentation/ (componentes, páginas, hooks) solo llama a application/use-cases, nunca a infrastructure/ directamente.

El "cableado" (qué implementación concreta se usa en cada caso) se centraliza en di/container.ts.

Ejemplo mínimo de código (para ver las capas en acción)

// domain/repositories/IUserRepository.ts
export interface IUserRepository {
  getById(id: string): Promise<User>;
}

// application/use-cases/user/GetUserById.ts
export class GetUserById {
  constructor(private userRepository: IUserRepository) {}
  execute(id: string) {
    return this.userRepository.getById(id);
  }
}

// infrastructure/repositories/HttpUserRepository.ts
export class HttpUserRepository implements IUserRepository {
  async getById(id: string) {
    const res = await fetch(`/api/users/${id}`);
    return res.json();
  }
}

// presentation/hooks/useUser.ts
const getUserById = new GetUserById(new HttpUserRepository());
export function useUser(id: string) {
  const [user, setUser] = useState<User | null>(null);
  useEffect(() => { getUserById.execute(id).then(setUser); }, [id]);
  return user;
}
Enter fullscreen mode Exit fullscreen mode

Con esto puedes, por ejemplo, cambiar HttpUserRepository por FakeUserRepository en tus tests sin tocar ni el use-case ni el componente.

La diferencia principal en Next.js es que app/ queda como capa de ruteo pura, y aparece una carpeta infrastructure/server/ para separar los repositorios que solo pueden correr en el servidor (Server Components, Server Actions) de los que se usan desde el cliente.

Top comments (0)