Volver al blog

¿Por qué cambiar tu código te sale tan caro? SOLID y Clean Architecture en Laravel 13 y Vue 3

4 sep 2026 35 min de lecturaArquitectura

Cambiar código se vuelve caro cuando la arquitectura está mal: SOLID y la arquitectura limpia existen justamente para bajar ese costo. Aquí va su historia y su porqué —los principios de Martin, Meyer y Liskov, y la arquitectura limpia de Martin (2012) con su linaje: hexagonal de Cockburn, onion de Palermo, DDD de Evans y BCE de Jacobson— y, sobre todo, cómo llevarlos a código real: una API Laravel 13 con dominio, casos de uso, repositorios e inyección de dependencias, y un frontend Vue 3 (Composition API, Vuetify, Pinia, CASL, Vite) con la misma forma. El cierre es el espejo full-stack, donde CASL refleja las Policies y un contrato tipado une los dos extremos. Con sustento académico y diagramas.

¿Por qué cambiar tu código te sale tan caro? SOLID y Clean Architecture en Laravel 13 y Vue 3

He visto envejecer proyectos de las dos maneras posibles. En unos, cada funcionalidad nueva cuesta un poco más que la anterior; llega un día en que nadie quiere tocar el módulo de facturación y proponer "cambiemos la librería de HTTP" suena a broma pesada. En otros, el código aguanta el cambio sin dramas: pruebas una pieza por separado, y pasar de una base de datos a otra (o de un framework de UI a otro) es reescribir un adaptador y poco más. Lo que separa a unos de otros casi nunca es el talento del equipo ni el stack que eligieron. Es cómo está ordenado el código por dentro. Y esa parte ya está resuelta desde hace décadas; solo hay que conocerla.

En este artículo repaso dos ideas que se apoyan la una en la otra —SOLID y la arquitectura limpia— desde quién las escribió y por qué, hasta el código del día a día. Con una vuelta de tuerca que no se ve tan seguido: voy a aplicar la misma arquitectura en los dos extremos de una app, una API en Laravel 13 y un frontend en Vue 3 (Composition API, Vuetify, Pinia, CASL y Vite). La idea es que compruebes que el principio es el mismo aunque cambie el lenguaje.

Este post es el cimiento del anterior

Si leíste Confianza por diseño: TDD, calidad medible y CI/CD con Laravel 13, recordarás que cerraba con una idea: el código difícil de probar casi siempre es código mal diseñado. Este artículo es la otra mitad de esa frase —qué es un código bien diseñado y cómo se ve, capa por capa, en el backend y en el frontend.

De dónde viene todo esto

Vale la pena saber de dónde salió todo esto, aunque sea para quitarle el aire de moda pasajera. Nada de esto se inventó ayer: es lo que fue quedando después de que mucha gente tropezara con los mismos problemas durante medio siglo.

SOLID: cinco principios con nombre y apellido

En el año 2000, Robert C. Martin —"Uncle Bob"— publicó un artículo llamado Design Principles and Design Patterns donde reunía cinco principios de diseño orientado a objetos que había ido formulando (y tomando prestados) durante los noventa. El artículo, ojo, no usaba la palabra "SOLID": fue Michael Feathers quien, hacia 2004, se dio cuenta de que reordenando las iniciales salía un acrónimo fácil de recordar. De ahí viene la palabra que hoy usamos sin pensarlo.

Antes de ir una por una, la foto de conjunto. Son cinco reglas distintas, pero todas empujan hacia el mismo sitio:

Diagrama de síntesis de SOLID sobre fondo claro. A la izquierda, las cinco iniciales en fichas de color con su nombre corto: S de Responsabilidad única (ámbar), O de Abierto/Cerrado (azul), L de Sustitución de Liskov (verde), I de Segregación de interfaces (violeta) y D de Inversión de dependencias (rojo). Cinco líneas parten de las fichas y convergen en un único punto del que sale una flecha verde hacia un panel a la derecha titulado El objetivo común: que el cambio salga barato, con la aclaración de que es código que absorbe requisitos nuevos sin romperse. El encabezado recuerda que Michael Feathers acuñó el acrónimo hacia 2004 sobre principios que Robert C. Martin reunió en los noventa

Y aquí va cada una con su historia —quién la escribió y cuándo—:

Responsabilidad únicaSRP

Robert C. Martin2000

"Una clase debe tener una sola razón para cambiar." Martin lo afinó años después en términos de actores: un módulo debe responder a un único actor (un grupo de usuarios o interesados que pide cambios por la misma razón). Su linaje intelectual es la vieja idea de cohesión de Constantine, DeMarco, Page-Jones y Parnas.

Abierto / CerradoOCP

Bertrand Meyer1988

Una entidad debe estar abierta a la extensión pero cerrada a la modificación. Meyer lo pensaba con herencia; Martin lo reinterpretó en 1996 con polimorfismo: extiendes implementando una interfaz, sin tocar el código que ya funciona. Es la versión que usamos hoy.

Sustitución de LiskovLSP

Barbara Liskov1987

Un subtipo debe poder reemplazar a su tipo base sin que el programa se entere: si PatoDeGoma hereda de Pato pero no nada, rompe Liskov. Lo presentó en su conferencia de OOPSLA 1987 y lo formalizó con Jeannette Wing en 1994. (El nombre "LSP" lo popularizó Martin; Liskov nunca lo llamó así.)

Segregación de interfacesISP

Robert C. Martinaños 90

Nació de un dolor real consultando para Xerox: una interfaz gigante obligaba a todos a depender de métodos que no usaban. La cura: muchas interfaces pequeñas y específicas en lugar de una enorme. Nadie carga con lo que no necesita.

Inversión de dependenciasDIP

Robert C. Martin1996

"Los módulos de alto nivel no deben depender de los de bajo nivel; ambos deben depender de abstracciones." Es la letra más poderosa de las cinco y el puente natural hacia la arquitectura limpia: si tu lógica de negocio depende de una interfaz en vez de Eloquent, Eloquent se vuelve un detalle intercambiable.

Los cinco apuntan a lo mismo

Más que las siglas, quédate con el objetivo común. SOLID existe para que el cambio salga barato. Cada principio ataca una forma distinta de rigidez: clases que hacen demasiado, código que hay que modificar para poder extenderlo, jerarquías que mienten, interfaces obesas, dependencias apuntando hacia los detalles. Cuando los cumples, el código absorbe requisitos nuevos sin quejarse; cuando los ignoras, cada cambio termina agrietando tres cosas que ni tocaste.

Arquitectura limpia: ordenar las dependencias

Si DIP dice "depende de abstracciones", la arquitectura limpia responde a la pregunta natural: ¿en qué dirección deben apuntar todas las dependencias de mi sistema? La respondió Robert C. Martin en un artículo de 2012The Clean Architecture— que luego expandió en su libro homónimo de 2017.

Pero Martin fue explícito en que no inventaba nada: su diagrama era la síntesis de varias ideas anteriores que decían lo mismo con distinta forma. Vale la pena conocer el linaje, porque cada una aporta un matiz:

Arquitectura hexagonal

Alistair Cockburn, formalizada en 2005. También llamada Ports and Adapters: tu aplicación expone puertos (interfaces) y el mundo exterior se conecta con adaptadores. La misma app funciona con una UI web, una CLI o una prueba, cambiando solo el adaptador.

Arquitectura cebolla

Jeffrey Palermo, 2008. Capas concéntricas con el dominio en el centro; las dependencias apuntan hacia adentro. Es, visualmente, el ancestro directo del diagrama de Martin.

DDD

Eric Evans, 2003 (el libro azul). Aportó el vocabulario del dominio como corazón del software: entidades, objetos de valor, agregados, un lenguaje ubicuo. La arquitectura limpia es el envase; DDD es a menudo lo que va dentro.

BCE

Ivar Jacobson, 1992: Boundary-Control-Entity. El abuelo de todos. Separaba lo que toca el exterior (boundary), la lógica de coordinación (control) y las reglas del negocio (entity). Martin lo cita como predecesor directo.

De todas ellas, Martin extrajo una sola regla, la que hace que todo funcione. La llamó la Regla de la Dependencia, y en el artículo de 2012 la enunció así de simple: las dependencias del código fuente solo pueden apuntar hacia adentro.

Diagrama de la arquitectura limpia como cuatro círculos concéntricos sobre fondo claro. De fuera hacia dentro: Frameworks y Drivers (Vue, Vuetify, Vite, Laravel, PostgreSQL, nginx), Adaptadores de interfaz (Controllers, Resources, Pinia, Services, Presenters), Casos de uso (CreateInvoice, useCreateInvoice) y, en el corazón, las Entidades (Invoice, Money, reglas de negocio). Una flecha discontinua sube desde el borde hacia el centro indicando que las dependencias del código fuente solo apuntan hacia adentro. A la derecha, un panel resume la regla de la dependencia con la cita de Robert C. Martin de 2012 y una leyenda de las cuatro capas, de detalles volátiles en el borde a políticas estables en el centro

Diagrama vertical para móvil de la arquitectura limpia como cuatro círculos concéntricos: de fuera hacia dentro, Frameworks y Drivers (Vue, Vuetify, Laravel, PostgreSQL), Adaptadores (Controllers, Pinia, Services), Casos de uso (CreateInvoice) y las Entidades en el centro (Invoice, Money). Una flecha discontinua apunta hacia adentro con la etiqueta dependencias hacia adentro. Debajo, la regla de la dependencia con la cita de R. C. Martin de 2012 y la leyenda de las cuatro capas

Conviene detenerse aquí, porque en esa frase está casi todo. El círculo de afuera son los detalles volátiles: el framework, la base de datos, la web, todo lo que cambias cada pocos años. El de adentro son las políticas estables: las reglas de tu negocio, que casi no se mueven. La regla dice que los detalles conocen a las políticas, y nunca al revés. Tu entidad Invoice no sabe que existe Laravel; tu caso de uso CreateInvoice no tiene idea de si los datos llegan por HTTP, por una cola o por una prueba. Y esa ignorancia es justo lo que te deja las manos libres para cambiar lo de afuera.

Antes de que te emociones: esto no es gratis

La arquitectura limpia cuesta —más archivos, más interfaces, más indirección—. Para un CRUD de tres tablas que nadie va a mantener en seis meses, es sobreingeniería, y un controlador con Eloquent adentro es la respuesta correcta. El valor aparece cuando hay reglas de negocio de verdad y el proyecto va a vivir años. Aplícala por dosis, no como dogma. Volveremos a esto al final.

Una API Laravel 13 con arquitectura limpia

Bajemos al código. La idea es organizar la app en capas que respeten la Regla de la Dependencia. Laravel 13 trae un esqueleto slim —su app/ viene casi vacío—, así que estas carpetas las creas tú; no son magia del framework, son una convención que hace visible la arquitectura.

txt
app/
├── Domain/                       # el corazón: NO importa nada de Laravel
│   └── Invoices/
│       ├── Invoice.php           # entidad (reglas de negocio)
│       ├── Money.php             # objeto de valor (inmutable)
│       ├── InvoiceId.php         # objeto de valor
│       └── InvoiceRepository.php # CONTRATO (interface) — el puerto
├── Application/                  # casos de uso: la lógica de TU app
│   └── Invoices/
│       ├── CreateInvoice.php     # la acción, orquesta el dominio
│       └── NewInvoiceData.php    # DTO de entrada
├── Infrastructure/               # detalles: implementaciones concretas
│   └── Invoices/
│       ├── EloquentInvoiceRepository.php   # implementa el contrato
│       └── InvoiceModel.php                # el modelo Eloquent
├── Http/                         # adaptadores de entrada (la web)
│   ├── Controllers/StoreInvoiceController.php   # fino: recibe y responde
│   ├── Requests/StoreInvoiceRequest.php         # validación
│   ├── Resources/InvoiceResource.php            # forma del JSON de salida
│   └── Policies/InvoicePolicy.php               # autorización
└── Providers/
    └── DomainServiceProvider.php  # enlaza interfaces -> implementaciones

Diagrama de paquetes que mapea las cuatro capas de la arquitectura limpia a las carpetas de una API Laravel 13. En el centro, app/Domain con las entidades (Invoice, Money, InvoiceId) y los contratos (InvoiceRepository como interfaz). Alrededor, app/Application con los casos de uso (CreateInvoice, PayInvoice) y los DTOs; app/Http con controladores finos, form requests, resources y policies; y app/Infrastructure con el repositorio Eloquent y los modelos. En el borde, Framework y Drivers (Laravel, PostgreSQL, Redis, nginx). Todas las flechas de dependencia, en rojo, apuntan hacia adentro, hacia el dominio; una nota recuerda la regla de la dependencia de Martin de 2012

Recorramos las piezas de adentro hacia afuera, que es como manda la regla.

El dominio: reglas puras, sin framework

En el centro viven las entidades y los objetos de valor: PHP plano, sin heredar de Model, sin saber que Laravel existe. Un objeto de valor como Money es pequeño, inmutable y encapsula una regla —el dinero no se representa con float, se guarda en céntimos para no perder precisión—:

php
// app/Domain/Invoices/Money.php
namespace App\Domain\Invoices;

final readonly class Money
{
    public function __construct(
        public int $cents,
        public string $currency = 'PEN',
    ) {
        if ($cents < 0) {
            throw new \InvalidArgumentException('El monto no puede ser negativo.');
        }
    }

    public static function fromDecimal(string $amount, string $currency = 'PEN'): self
    {
        return new self((int) round(((float) $amount) * 100), $currency);
    }

    public function toDecimal(): string
    {
        return number_format($this->cents / 100, 2, '.', '');
    }
}

Junto a las entidades vive el contrato del repositorio: una interfaz que declara qué necesitas hacer con las facturas, sin decir cómo. Este es el puerto de la arquitectura hexagonal, y la clave de todo el diseño:

php
// app/Domain/Invoices/InvoiceRepository.php
namespace App\Domain\Invoices;

interface InvoiceRepository
{
    public function save(Invoice $invoice): void;

    public function findById(InvoiceId $id): ?Invoice;
}

Fíjate en lo que NO hay aquí

No hay use Illuminate\.... No hay Eloquent, ni DB, ni Request. El dominio es una isla que podrías copiar a otro proyecto PHP —o compilar sin Laravel— y seguiría teniendo sentido. Eso es DIP en acción: la política de alto nivel (facturar) no depende del detalle de bajo nivel (PostgreSQL).

El caso de uso: orquestar sin ensuciarse

La capa de aplicación contiene los casos de uso —lo que tu app hace—. CreateInvoice recibe datos ya validados (un DTO), construye la entidad y la manda guardar a través de la interfaz. No sabe que detrás hay Eloquent:

php
// app/Application/Invoices/CreateInvoice.php
namespace App\Application\Invoices;

use App\Domain\Invoices\{Invoice, Money, InvoiceRepository};

final readonly class CreateInvoice
{
    // DIP: depende de la ABSTRACCIÓN, no de la implementación concreta.
    public function __construct(private InvoiceRepository $invoices) {}

    public function handle(NewInvoiceData $data): Invoice
    {
        $invoice = Invoice::issue(
            client: $data->client,
            total: Money::fromDecimal($data->amount, $data->currency),
        );

        $this->invoices->save($invoice);

        return $invoice;
    }
}

Ese constructor es el corazón del asunto. CreateInvoice pide un InvoiceRepository —la interfaz— y Laravel se lo entrega resuelto. En una prueba unitaria le pasas un doble en memoria; en producción, el que habla con PostgreSQL. El caso de uso ni se entera, y por eso se prueba en milisegundos sin tocar la base de datos.

La infraestructura: el detalle intercambiable

Aquí, y solo aquí, aparece Eloquent. EloquentInvoiceRepository implementa el contrato del dominio y traduce entre tu entidad pura y el modelo de la base de datos:

php
// app/Infrastructure/Invoices/EloquentInvoiceRepository.php
namespace App\Infrastructure\Invoices;

use App\Domain\Invoices\{Invoice, InvoiceId, InvoiceRepository};

final class EloquentInvoiceRepository implements InvoiceRepository
{
    public function save(Invoice $invoice): void
    {
        InvoiceModel::updateOrCreate(
            ['id' => $invoice->id()->value],
            ['client' => $invoice->client(), 'cents' => $invoice->total()->cents],
        );
    }

    public function findById(InvoiceId $id): ?Invoice
    {
        return InvoiceModel::find($id->value)?->toDomain();
    }
}

El puente entre el mundo abstracto y el concreto lo tiende el contenedor de servicios de Laravel, en un service provider. Esta única línea es la que hace realidad la Inversión de Dependencias:

php
// app/Providers/DomainServiceProvider.php
public function register(): void
{
    $this->app->bind(
        \App\Domain\Invoices\InvoiceRepository::class,       // cuando alguien pida esto...
        \App\Infrastructure\Invoices\EloquentInvoiceRepository::class, // ...dale esto.
    );
}

Diagrama de clases de la inversión de dependencias con el patrón repositorio en Laravel 13. En el paquete Application, la clase CreateInvoice recibe en su constructor un InvoiceRepository y expone handle. En el paquete Domain están la interfaz InvoiceRepository (con save y nextId) y la entidad Invoice. En Infrastructure, la clase EloquentInvoiceRepository implementa la interfaz y persiste con un modelo Eloquent. En Bootstrap, un RepositoryServiceProvider enlaza la interfaz con su implementación. Las flechas muestran que tanto el caso de uso de alto nivel como Eloquent de bajo nivel dependen de la misma abstracción, la interfaz, cumpliendo la regla de la dependencia

Este diagrama es, en una imagen, por qué DIP se llama inversión: la flecha que en un diseño ingenuo iría de CreateInvoice a EloquentInvoiceRepository (alto nivel dependiendo del bajo nivel) queda invertida —ambos apuntan a la interfaz en el medio—. Cambiar PostgreSQL por una API externa, por Redis o por un doble de prueba es escribir una clase nueva y cambiar un bind. Nada más se mueve.

El adaptador de entrada: un controlador que casi no piensa

Con el dominio y el caso de uso resolviendo lo difícil, el controlador queda anémico —y eso es una virtud, no un defecto—. Recibe la petición ya validada por el Form Request, comprueba permisos con la Policy, delega en el caso de uso y devuelve un Resource:

php
// app/Http/Controllers/StoreInvoiceController.php
final class StoreInvoiceController
{
    public function __construct(private CreateInvoice $createInvoice) {}

    public function __invoke(StoreInvoiceRequest $request): JsonResponse
    {
        $this->authorize('create', Invoice::class);   // -> InvoicePolicy

        $invoice = $this->createInvoice->handle(
            NewInvoiceData::fromRequest($request),
        );

        return InvoiceResource::make($invoice)
            ->response()
            ->setStatusCode(201);
    }
}

Cada clase que tocamos hace una sola cosa (SRP): el Form Request valida, la Policy autoriza, el Resource da forma al JSON, el caso de uso orquesta, el repositorio persiste. Si mañana cambia la regla de validación, sabes exactamente qué archivo abrir —y solo ese—.

El truco mental para ubicar cada cosa

Ante una clase nueva, pregúntate: ¿esto cambiaría si cambiara el framework? Si la respuesta es "sí" (un controlador, un modelo Eloquent, un Resource), va en el borde. Si es "no, esto cambiaría solo si cambiara el negocio" (cómo se calcula un total, cuándo una factura está vencida), va en el centro. Esa pregunta sola te resuelve el 90 % de las dudas de dónde poner el código.

El mismo plano, ahora en Vue 3

Y aquí viene lo que casi nadie se molesta en hacer, que es justo donde está el mayor rédito: la arquitectura limpia no es solo cosa del backend. Un frontend moderno arrastra los mismos problemas (lógica de negocio enredada con la UI, componentes que saben demasiado y que no hay por dónde probar) y le sirve la misma cura. Vamos a armar el lado Vue 3 con la misma forma que la API.

La organización que mejor funciona en Vue es por funcionalidad (feature-based), no por tipo de archivo. Cada feature es una rebanada vertical con sus propias capas:

txt
src/
├── features/
│   └── invoices/
│       ├── domain/
│       │   ├── invoice.ts            # tipos/entidades (sin Vue)
│       │   └── invoice.gateway.ts    # PUERTO: interface del acceso a datos
│       ├── application/
│       │   └── useCreateInvoice.ts   # caso de uso (composable)
│       ├── infrastructure/
│       │   └── invoice.api.ts        # implementa el puerto con HTTP
│       ├── stores/
│       │   └── invoices.store.ts     # estado (Pinia)
│       └── components/
│           └── InvoiceForm.vue       # UI (Vuetify) — el borde
├── shared/
│   └── auth/ability.ts               # abilities de CASL (autorización)
└── main.ts                           # arranque: Vue + Vuetify + Pinia + router

Diagrama de paquetes de la arquitectura limpia aplicada al frontend Vue 3. En el centro, el paquete domain con los tipos y entidades (Invoice, Money), las abilities de CASL y los puertos (InvoiceGateway como interfaz). Alrededor, application con los composables que actúan de casos de uso (useCreateInvoice) y las stores de Pinia; infrastructure con los services HTTP (InvoiceApi que implementa InvoiceGateway) y detalles como Axios, localStorage y WebSocket; y presentation con los componentes de Vuetify y el router con guards. En el borde, Frameworks y Drivers: Vue 3, Vuetify, Pinia y Vite. Las flechas de dependencia, en verde, apuntan hacia adentro; una nota explica que los composables dependen de un puerto, no de Axios, y que las abilities de CASL son el espejo de las Policies de Laravel

El dominio y el puerto: TypeScript puro

Igual que en Laravel, el centro no sabe que Vue existe. Son tipos y una interfaz —el puerto—:

ts
// features/invoices/domain/invoice.ts
export interface Invoice {
  id: string
  client: string
  amount: string     // decimal como string, para no perder precisión
  currency: string
}

export interface NewInvoice {
  client: string
  amount: string
  currency: string
}
ts
// features/invoices/domain/invoice.gateway.ts
import type { Invoice, NewInvoice } from './invoice'

// El PUERTO: qué necesitamos, no cómo. El caso de uso dependerá de esto.
export interface InvoiceGateway {
  create(data: NewInvoice): Promise<Invoice>
  list(): Promise<Invoice[]>
}

La infraestructura: el adaptador HTTP

El adaptador concreto implementa el puerto usando la librería HTTP que sea. Aquí es el único lugar donde aparece Axios; si mañana migras a fetch, a ky o a WebSockets, cambias esta clase y nada más:

ts
// features/invoices/infrastructure/invoice.api.ts
import axios from 'axios'
import type { InvoiceGateway } from '../domain/invoice.gateway'
import type { Invoice, NewInvoice } from '../domain/invoice'

export class InvoiceApi implements InvoiceGateway {
  async create(data: NewInvoice): Promise<Invoice> {
    const { data: json } = await axios.post('/api/invoices', data)
    return json.data          // el InvoiceResource de Laravel, ya tipado
  }

  async list(): Promise<Invoice[]> {
    const { data: json } = await axios.get('/api/invoices')
    return json.data
  }
}

El caso de uso: un composable que orquesta

En Vue, el equivalente natural a un caso de uso es un composable. useCreateInvoice recibe el gateway (el puerto), maneja el estado reactivo de la operación —cargando, error, resultado— y expone una acción. No conoce Axios ni a Vuetify: solo el puerto y el store.

ts
// features/invoices/application/useCreateInvoice.ts
import { ref } from 'vue'
import type { InvoiceGateway } from '../domain/invoice.gateway'
import type { NewInvoice } from '../domain/invoice'
import { useInvoicesStore } from '../stores/invoices.store'

// DIP: recibe la abstracción por parámetro, no la crea adentro.
export function useCreateInvoice(gateway: InvoiceGateway) {
  const store = useInvoicesStore()
  const loading = ref(false)
  const error = ref<string | null>(null)

  async function submit(data: NewInvoice) {
    loading.value = true
    error.value = null
    try {
      const invoice = await gateway.create(data)
      store.add(invoice)               // actualiza el estado global
      return invoice
    } catch (e) {
      error.value = 'No se pudo crear la factura.'
      throw e
    } finally {
      loading.value = false
    }
  }

  return { submit, loading, error }
}

Por qué el gateway entra por parámetro

Recibir la dependencia desde fuera (en vez de hacer new InvoiceApi() dentro) es DIP en TypeScript. En producción, el componente le pasa un InvoiceApi real; en una prueba de Vitest, le pasas un objeto falso que devuelve datos de mentira. El composable se prueba sin red y sin servidor, en milisegundos. ¿Te suena? Es exactamente lo que logramos en el backend con el contenedor de Laravel.

El estado: Pinia como adaptador

Pinia —el store oficial de Vue, sucesor de Vuex— es un adaptador más: guarda el estado de la aplicación y lo ofrece reactivo a la UI. Con la Composition API se lee como un composable:

ts
// features/invoices/stores/invoices.store.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Invoice } from '../domain/invoice'

export const useInvoicesStore = defineStore('invoices', () => {
  const items = ref<Invoice[]>([])
  const add = (invoice: Invoice) => items.value.unshift(invoice)
  return { items, add }
})

El borde: un componente Vuetify que solo muestra

Finalmente, el componente. Con Vuetify para la UI y la Composition API (<script setup>), su trabajo es mínimo: recoger datos, llamar al caso de uso y reaccionar. Toda la lógica difícil ya está resuelta en las capas de adentro, así que el componente es delgado y legible:

vue
<!-- features/invoices/components/InvoiceForm.vue -->
<script setup lang="ts">
import { reactive } from 'vue'
import { useCreateInvoice } from '../application/useCreateInvoice'
import { InvoiceApi } from '../infrastructure/invoice.api'

// se inyecta el adaptador concreto; el composable solo ve el puerto
const { submit, loading, error } = useCreateInvoice(new InvoiceApi())

const form = reactive({ client: '', amount: '', currency: 'PEN' })
</script>

<template>
  <v-form @submit.prevent="submit(form)">
    <v-text-field v-model="form.client" label="Cliente" />
    <v-text-field v-model="form.amount" label="Monto" type="number" />
    <v-alert v-if="error" type="error" :text="error" />
    <v-btn type="submit" :loading="loading" color="primary">
      Crear factura
    </v-btn>
  </v-form>
</template>

SOLID también vive en el frontend

Míralo capa por capa: cada composable hace una cosa (SRP); añades un nuevo gateway sin tocar el caso de uso (OCP); cualquier implementación del puerto es intercambiable (LSP); los puertos son pequeños y específicos (ISP); y el caso de uso depende del puerto, no de Axios (DIP). Los mismos cinco principios de 1996, ahora en TypeScript. La arquitectura no distingue de lenguajes.

La sorpresa: el espejo full-stack

Y aquí las dos mitades encajan. Cuando pones el frontend y el backend uno al lado del otro, salta a la vista: son la misma arquitectura reflejada en un espejo. Framework afuera, dominio adentro, a los dos lados. Y en medio, una sola frontera: un contrato tipado.

Diagrama del espejo full-stack: la misma arquitectura limpia en Vue y en Laravel, una frente a la otra. A la izquierda, el frontend Vue 3 con cuatro capas apiladas de fuera hacia dentro: Frameworks y Drivers (Vuetify, Vue Router, Vite), Adaptadores (Pinia stores, InvoiceApi), Casos de uso (useCreateInvoice) y Dominio (Invoice type, CASL ability). A la derecha, el backend Laravel 13 con las mismas cuatro capas espejadas: Frameworks y Drivers (Laravel, nginx, PostgreSQL), Adaptadores (Controller, Resource, EloquentRepo), Casos de uso (CreateInvoice Action) y Dominio (Invoice entity, InvoicePolicy). Cada capa tiene el mismo color a ambos lados. En el centro, una frontera HTTP JSON con la petición POST /api/invoices y la respuesta 201 tipada; a la altura del dominio, un nodo de igualdad conecta la CASL ability con la InvoicePolicy bajo el lema misma regla, dos lados: CASL decide qué mostrar y Policy decide qué permitir. Flechas laterales indican que en ambos lados las dependencias apuntan hacia el dominio

Diagrama vertical para móvil del espejo full-stack. Arriba, el frontend Vue 3 con cuatro capas apiladas de fuera hacia dentro: Frameworks y Drivers (Vuetify, Vue Router, Vite), Adaptadores (Pinia stores, InvoiceApi), Casos de uso (useCreateInvoice) y Dominio (Invoice type, CASL ability). En el medio, la frontera HTTP JSON con la petición POST /api/invoices y la respuesta. Abajo, el backend Laravel 13 en espejo, con el dominio pegado a la frontera y el framework al fondo: Dominio (Invoice entity, InvoicePolicy), Casos de uso (CreateInvoice), Adaptadores (Controller, Resource, EloquentRepo) y Frameworks y Drivers (Laravel, nginx, PostgreSQL). Al pie, un recuadro recuerda que es la misma regla en dos lados: CASL decide qué mostrar y Policy decide qué permitir

Ese reflejo se nota, sobre todo, en la autorización. En el backend, Laravel decide quién puede qué con Policies. En el frontend, CASL (una librería de autorización isomórfica de Sergii Stotskyi) hace lo mismo con abilities. Y como los dos hablan de las mismas acciones y los mismos sujetos, puedes definir las reglas una vez y reflejarlas a los dos lados:

En el servidor, la Policy es la puerta real: decide qué se permite.

php
// app/Http/Policies/InvoicePolicy.php
final class InvoicePolicy
{
    public function create(User $user): bool
    {
        return $user->hasPermission('invoices.create');
    }

    public function delete(User $user, Invoice $invoice): bool
    {
        // solo el dueño o un admin
        return $user->id === $invoice->ownerId
            || $user->hasRole('admin');
    }
}

La regla de oro de la autorización full-stack

CASL en el cliente hace que la UI sea amable —no muestra botones que llevarían a un 403—, pero no protege nada. Cualquiera puede abrir la consola y lanzar la petición a mano. La única puerta de verdad es la Policy en el servidor. Regla: el frontend decide qué mostrar; el backend decide qué permitir. Nunca confíes la seguridad al cliente; confíale solo la experiencia.

Para cerrar el círculo, sigamos una petición de punta a punta y veamos cómo cruza las dos arquitecturas en espejo:

Diagrama de secuencia de la creación de una factura de punta a punta, cruzando el frontend Vue y el backend Laravel. El usuario completa y envía el formulario en InvoiceForm.vue de Vuetify; el componente consulta a la CASL Ability si puede crear una factura y, con el visto bueno, mantiene el botón activo y llama al composable useCreateInvoice, que pide al adaptador InvoiceApi un POST a la API. En el servidor, el StoreInvoiceController valida el payload con el StoreInvoiceRequest, autoriza con la InvoicePolicy, invoca el caso de uso CreateInvoice, que guarda mediante el EloquentInvoiceRepository en PostgreSQL; la respuesta 201 con el InvoiceResource vuelve tipada al composable, que actualiza el estado en Pinia y refresca la UI. Una nota central subraya que CASL en el cliente decide qué mostrar y la Policy en el servidor decide qué permitir: la puerta real está en Laravel

Fíjate en la simetría: la petición sale del dominio de Vue (donde CASL ya dijo "sí, muéstralo"), cruza la frontera HTTP como un JSON tipado, y entra al dominio de Laravel (donde la Policy dice "sí, permítelo"). Dos comprobaciones de la misma regla, cada una en su lado, cada una con su propósito. Cuando tu equipo de frontend y tu equipo de backend comparten este mapa mental, dejan de hablar idiomas distintos: hablan de casos de uso, entidades y puertos, y esas palabras significan lo mismo en .ts y en .php.

El costo de hacerlo de más (y de menos)

No quiero venderte esto sin la letra pequeña, porque la tiene. Hay dos formas de equivocarse, y las dos duelen.

El pecado de hacerlo de menos

Es el más común: toda la lógica en el controlador o en el componente, Eloquent y Axios por todas partes, cero interfaces. Funciona el primer mes. Al año, cada cambio rompe tres cosas, nadie prueba nada porque todo está acoplado, y el equipo le tiene miedo al código. Es la deuda técnica invisible del post anterior.

El pecado de hacerlo de más

El opuesto, y también real: interfaces para todo, cinco capas para guardar un registro, un repositorio que solo llama a save. Cada feature nueva toca ocho archivos. Para un CRUD sencillo, la arquitectura limpia completa es un traje de gala para ir a la tienda. Sobreingeniería es deuda técnica con corbata.

Acertar es cuestión de criterio, no de reglas fijas. Un par de guías que a mí me sirven:

Empieza por el caso de uso, no por las capas

No abras ocho carpetas para el primer endpoint. Escribe el caso de uso y, cuando una dependencia concreta (la base de datos, una API externa) empiece a estorbarte para probar, entonces extrae la interfaz. La arquitectura emerge de la necesidad, no del dogma.

Reserva la ceremonia para donde hay negocio

El módulo de facturación con reglas de impuestos, descuentos y estados merece dominio, casos de uso y repositorios. La tabla de países para un <select> no: un modelo Eloquent y a otra cosa. No toda parte de la app merece la misma inversión.

Deja que las pruebas te digan la verdad

Si probar algo es un dolor, tu diseño te está hablando: hay acoplamiento de más. Si extraer una interfaz no hace ninguna prueba más fácil, probablemente no la necesitas todavía. La testabilidad es el mejor detector de arquitectura, en los dos sentidos.

Cierre

Ninguna de estas dos ideas salió de un aula ni de un comité de arquitectos. Son lo que la industria fue respondiendo, a golpes, a una sola pregunta: ¿cómo hago que el cambio siga saliendo barato dentro de tres años? Y la respuesta cabe en una frase que Barbara Liskov, Bertrand Meyer y Robert C. Martin fueron escribiendo por partes a lo largo de treinta años: pon las reglas del negocio en el centro, empuja los detalles al borde y haz que las dependencias siempre apunten hacia adentro.

Lo demás es aplicar esa idea con cabeza. En Laravel 13 toma la forma de dominio, casos de uso, repositorios e inyección de dependencias. En Vue 3, la de features, composables, puertos y Pinia. Y cuando los pones uno frente al otro, ves que estabas construyendo la misma cosa dos veces, en espejo: CASL y las Policies acaban siendo la misma regla, mirándose desde los dos lados del cristal. Esa coherencia —que un equipo hable un mismo idioma de dominio del .vue al .php— es lo que de verdad hace que un sistema envejezca bien.

¿Tu app pide esta claridad?

Si tienes una API de Laravel o un frontend de Vue que crecieron sin plan y hoy cada cambio da miedo, o quieres montar una arquitectura limpia y pragmática desde el arranque —sin caer en la sobreingeniería—, hablemos. Puedes ver cómo trabajo en la página de servicios.


Fuentes

SOLID · principios y sus autores

  • Martin, R. C. (2000). Design Principles and Design Patterns. Object Mentor. — El texto que reunió los cinco principios (aún sin el nombre "SOLID").
  • Martin, R. C. (2014). The Single Responsibility Principle y The Open Closed Principle. Clean Coder Blog. — La reformulación del SRP por "actor".
  • Meyer, B. (1988). Object-Oriented Software Construction. Prentice Hall. — El origen del principio Abierto/Cerrado (versión por herencia).
  • Liskov, B. (1987). Data Abstraction and Hierarchy. Conferencia de OOPSLA (impresa en SIGPLAN Notices 23(5), 1988). — El origen del LSP.
  • Liskov, B., & Wing, J. (1994). A Behavioral Notion of Subtyping. ACM TOPLAS 16(6). — La formalización del principio.
  • Wikipedia. SOLID — Sobre el acrónimo de Michael Feathers (circa 2004) y el conjunto.

Arquitectura limpia y su linaje

Documentación oficial · Laravel y Vue

Foto de Marco Torres

Escrito por

Marco Torres

Desarrollador Full-Stack senior con DevOps y arquitectura cloud, y Bachiller en Ingeniería de Sistemas. Escribo sobre arquitectura escalable y las lecciones de llevar sistemas a producción — desde la trinchera.