Introducción

Cuando hablamos de patrones arquitectónicos en desarrollo de software, Clean Architecture suele asociarse principalmente con aplicaciones backend. Sin embargo, su aplicación en el frontend puede ser igualmente valiosa, especialmente en proyectos complejos o de larga duración.

En este artículo vamos a analizar cómo he implementado Clean Architecture en un proyecto real: una plataforma de turismo desarrollada con Next.js y Strapi.

¿Qué es Clean Architecture?

![[diagram-export-10-3-2025-20_22_51.png]]

Clean Architecture, propuesta por Robert C. Martin (Uncle Bob), se basa en un principio fundamental: la separación de responsabilidades mediante capas concéntricas donde las dependencias siempre apuntan hacia el centro. En el núcleo están las entidades y reglas de negocio, mientras que los detalles técnicos quedan en las capas exteriores.

La estructura por capas incluye:

  1. Dominio: Modelos (Zona, Grupo, Arranque...) y errores
  2. Aplicación: Casos de uso e interfaces de infraestructura (getLatestPostsUseCase, PostsRepository Interface)
  3. Adaptadores: Conexiones entre la lógica de negocio y el mundo exterior (getLatestPostsController, StrapiZonaAdapter)
  4. Frameworks e infraestructura: Implementaciones concretas (Next.js, APIs externas, bases de datos)

Estructura de nuestro proyecto

Nuestro proyecto sigue una clara separación en capas que combina Clean Architecture con Screaming Architecture y Vertical Slicing:

estructura
/src
/modules
/[feature]
/entities
/models # Modelos del dominio
/errors # Validación y errores
/application
/repositories # Interfaces de repositorios
/use-cases # Lógica de aplicación
/infrastructure
/repositories # Repositorios de implementaciones concretas
/services # Servicios
/adapters
/controllers # Controladores que conectan con la UI
/adapters # Adaptadores entre infraestructura y dominio
/app # Componentes Next.js (UI)
/di # Inyección de dependencias

La regla de dependencia: el corazón de Clean Architecture

En mi config de ESLint he creado esta regla que obliga a seguir la ley de dependencia de Clean Architecture:

eslint.config.mjs
"import/no-restricted-paths": [
"error",
{
zones: [
{
target: "./src/**/domain/**/*",
from: [
"./src/**/application/**/*",
"./src/**/infrastructure/**/*",
"./src/**/controllers/**/*",
],
message: "Domain cannot import from outer layers",
},
{
target: "./src/**/application/**/*",
from: [
"./src/**/infrastructure/**/*",
"./src/**/controllers/**/*"
],
message: "Application cannot import from outer layers",
},
{
target: "./src/**/controllers/**/*",
from: "./src/**/infrastructure/**/*",
message: "Controllers cannot import from outer layers",
},
],
},
],

Esto garantiza que ninguna capa pueda importar nada de una capa externa:

Dominio: el núcleo de nuestra aplicación.

En esta capa definimos los modelos, cada una de las entidades que existirán en nuestra aplicación.

src/modules/zonas/entities/models/Zona.ts
export class Zona extends Entity {
readonly slug: string
readonly url: string

constructor(
readonly id: number,
readonly title: string,
readonly description: string,
readonly image: Image,
readonly geojson: GeoJSON.FeatureCollection | null,
readonly bgPosition: number,
readonly SEOTitle: string,
readonly SEODescription: string,
readonly grupos: Grupo[]
) {
super()
this.slug = this.createSlug(this.SEOTitle)
this.url = this.getFullUrl(`zonas/${this.id}/${this.slug}`)
// ...
}

toPrimitives(): ZonaPrimitives {
// Transformación a objetos simples
}
}

Las entidades contienen lógica de dominio pero no dependencias externas, lo que las hace independientes de frameworks o infraestructura.

Casos de uso: orquestando la lógica de negocio

Los casos de uso invocan a las implementaciones concretas a través de una interfaz:

src/modules/zonas/application/use-cases/getAllZonas.use-case.ts
export function getAllZonasUseCase(zonasRepository: IZonasRepository) {
return async () => await zonasRepository.getAll()
}
}

Observa que los casos de uso:

  • Reciben interfaces como dependencias (inversión de dependencias)
  • No conocen implementaciones concretas, solo abstracciones
  • Representan operaciones específicas de la aplicación

Infraestructura: implementaciones concretas

La capa de infraestructura implementa las interfaces definidas en la capa de aplicación:

src/modules/zonas/infrastructure/repositories/zonas.repository.ts
export class ZonasRepositoryStrapiImpl extends StrapiRepository implements IZonasRepository {
  constructor(private readonly instrumentationServiceIInstrumentationService) {
    super()
  }
  async getById(idnumber): Promise<Zona> {
    // Implementación que obtiene datos de Strapi
  }

  async getAll(): Promise<Zona[]> {
    // Implementación que obtiene datos de Strapi
  }

  private toDomain(itemAPIZona): Zona {
    return StrapiZonaAdapter.toDomain(item)
  }

}

Aquí hay detalles técnicos como llamadas HTTP, pero con una clara separación que permite cambiar de Strapi a cualquier otra fuente de datos sin afectar las capas internas.

Controladores: conectando con la UI

Los controladores son el puente entre la lógica de aplicación y la interfaz de usuario:

src/modules/zonas/controllers/getZonaByParams.controller.ts
export function getZonaByParamsController(getZonaByIdUseCaseIGetZonaByIdUseCase) {

  return async (paramsPromise<{ zona: [numberstring] }>) => {
    try {
      const { zonazonaParams } = await params
      const [zonaIdslugParam] = zonaParams
      const zona = await getZonaByIdUseCase(zonaId)
      if (slugParam !== zona.slug) {
        permanentRedirect(zona.url)
      }
      return {
        zona: zona.toPrimitives(),
        // Más datos procesados para la UI...
      }
    } catch (e) {
      if (instanceof NotFoundError) {
        notFound()
      } else {
        throw e
      }
    }
  }
}

Los controladores:

  • Reciben parámetros de la UI
  • Llaman a casos de uso
  • Transforman datos para la presentación
  • Manejan errores específicos de la UI (como redirecciones o páginas 404)

Esto también va a ayudar a que vuestros componentes de React queden mucho más limpios!

Inyección de dependencias: uniendo todas las capas

Para unir todo, utilizamos un sistema de inyección de dependencias. En este caso yo estoy usando ioctopus.

// src/di/modules/zonas.module.ts
export function createZonasModule() {
const zonasModule = createModule()

if (process.env.NODE_ENV === "test") {
zonasModule.bind(DI_SYMBOLS.IZonasRepository).toClass(MockZonasRepository, [])
} else {
zonasModule
.bind(DI_SYMBOLS.IZonasRepository)
.toClass(ZonasRepositoryStrapiImpl, [DI_SYMBOLS.IInstrumentationService])
}

// Use Cases
zonasModule
.bind(DI_SYMBOLS.IGetAllZonasUseCase)
.toHigherOrderFunction(getAllZonasUseCase, [
DI_SYMBOLS.IInstrumentationService,
DI_SYMBOLS.IZonasRepository,
])

zonasModule
.bind(DI_SYMBOLS.IGetZonaByIdUseCase)
.toHigherOrderFunction(getZonaByIdUseCase, [DI_SYMBOLS.IZonasRepository])

// Controllers
zonasModule
.bind(DI_SYMBOLS.IGetAllZonasController)
.toHigherOrderFunction(getAllZonasController, [
DI_SYMBOLS.IInstrumentationService,
DI_SYMBOLS.IGetAllZonasUseCase,
])

zonasModule
.bind(DI_SYMBOLS.IGetZonaByParamsController)
.toHigherOrderFunction(getZonaByParamsController, [DI_SYMBOLS.IGetZonaByIdUseCase])

return zonasModule
}

Este sistema nos permite:

  • Inyectar implementaciones concretas en tiempo de ejecución
  • Cambiar implementaciones fácilmente (por ejemplo, para testing)
  • Mantener un gráfico de dependencias claro

Integrando con Next.js App Router

Una parte interesante de nuestro proyecto es cómo integramos Clean Architecture con Next.js App Router y sus React Server Components:

src/app/_components/home/HomeZonas.server.tsx
import { getInjection } from "@/di/container"

export default async function HomeZonasServer() {
const getAllZonasController = getInjection("IGetAllZonasController")
const zonas = await getAllZonasController()

return <ZonasGrid zonas={zonas} />
}

Este componente:

  • Obtiene el controlador del contenedor de inyección
  • Ejecuta la llamada para obtener los datos
  • Renderiza un componente de UI con los datos

Testing: la gran ventaja de Clean Architecture

La arquitectura facilita enormemente las pruebas:

tests/unit/application/use-cases/zonas/get-all-zonas.use-case.test.ts
describe("getAllZonasUseCase", () => {
let zonas: Zona[]

beforeAll(async () => {
zonas = await getAllZonasUseCase()
})

it("should return an array of zones", async () => {
expect(Array.isArray(zonas)).toBe(true)
expect(zonas.length).toBeGreaterThan(0)
})

it("should return zones with required properties", async () => {
const firstZone = zonas[0]
expect(firstZone).toHaveProperty("id")
expect(firstZone).toHaveProperty("title")
expect(firstZone).toHaveProperty("description")
expect(firstZone).toHaveProperty("grupos")
})
})

Para testing, podemos reemplazar fácilmente las implementaciones reales con mocks:

src/modules/zonas/infrastructure/repositories/zonas.repository.mock.ts
export class MockZonasRepository extends MockStrapiRepository implements IZonasRepository {
async getAll(): Promise<Zona[]> {
return [this.toDomain(this.createMockZona())]
}

async getById(): Promise<Zona> {
return this.toDomain(this.createMockZona())
}

// ...
}

Beneficios observados

  1. Mantenibilidad: Cambiar una implementación (como pasar de REST a GraphQL) solo requiere modificar la capa de infraestructura, sin afectar a entidades o casos de uso.

  2. Testabilidad: Podemos probar cada capa de forma aislada, con mocks para las dependencias.

  3. Separación clara de responsabilidades: Cada desarrollador puede trabajar en una capa específica sin interferir con otros.

  4. Adaptabilidad a cambios de framework: Si Next.js evoluciona o queremos migrar a otro framework, solo necesitamos modificar la capa de UI/controladores.

  5. Dominio centralizado: Las reglas de negocio están claramente definidas en las entidades, lo que facilita su comprensión.

Desafíos y soluciones

  1. Curva de aprendizaje: Para desarrolladores que no estén familiarizados con arquitecturas limpias puede llevar un tiempo adaptarse. 

  2. Más código inicial: Clean Architecture implica más ficheros y más código. Más boilerplate en general.

Conclusión

Implementar Clean Architecture en un proyecto frontend con Next.js requiere esfuerzo inicial, pero los beneficios a medio y largo plazo compensan con creces:

  • Código mantenible y escalable
  • Tests más sencillos
  • Menor acoplamiento entre componentes
  • Mayor claridad en la separación de responsabilidades

En este proyecto, esta arquitectura me ha permitido evolucionar la aplicación de forma controlada, añadir nuevas características sin romper las existentes y facilitar la futura incorporación de nuevos desarrolladores al equipo.

Si estás construyendo una aplicación frontend compleja o que prevés mantener durante mucho tiempo, te animo a considerar Clean Architecture como un patrón que te ayudará a mantener tu código organizado, testeable y adaptable a los cambios futuros.