¿Por qué cambiar tu código te sale tan caro? SOLID y Clean Architecture en Laravel 13 y Vue 3
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.
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:
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 Meyer1988Una 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 Liskov1987Un 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 90Nació 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 2012 —The 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:
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.
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.
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.
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.
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.
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
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—:
// 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:
// 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:
// 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:
// 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:
// 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.
);
}
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:
// 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:
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
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—:
// 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
}
// 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:
// 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.
// 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:
// 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:
<!-- 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.
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.
// 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:
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.
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 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.
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
- Martin, R. C. (2012). The Clean Architecture. Clean Coder Blog. — El artículo original y la Regla de la Dependencia.
- Martin, R. C. (2017). Clean Architecture: A Craftsman's Guide to Software Structure and Design. Pearson. ISBN 978-0-13-449416-6.
- Cockburn, A. (2005). Hexagonal Architecture (Ports and Adapters). — Puertos y adaptadores.
- Palermo, J. (2008). The Onion Architecture: part 1. — Las capas concéntricas.
- Evans, E. (2003). Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley. — El dominio como corazón.
- Jacobson, I. (1992). Object-Oriented Software Engineering (OOSE). ACM Press. — El patrón Boundary-Control-Entity, predecesor citado por Martin.
Documentación oficial · Laravel y Vue
- Service Container — Laravel 13.x · Authorization (Policies) · Eloquent: API Resources
- Vue 3 — Composition API · Pinia (Eduardo San Martin Morote)
- Vuetify 3 (John Leider) · Vite (Evan You) · CASL y @casl/vue (Sergii Stotskyi)
Artículos relacionados
Ingeniería de la confianza: TDD, CI/CD y calidad medible en Laravel 13
Una guía completa y práctica para construir software en el que puedas confiar con Laravel 13: la historia y el porqué de TDD, los indicadores de calidad que sí se miden (deuda técnica, complejidad, cobertura, seguridad), el ciclo rojo-verde-refactor con Pest 4, cómo armar escenarios con migraciones, factories y seeds, mocking y fakes, un entorno local con Docker y Makefile, y un pipeline de GitLab CI/CD que encaja con SonarQube, DevSecOps, las métricas DORA y el trabajo de un SRE. Con sustento académico y basado en un caso real de producción.
Novedades de Laravel 13: atributos PHP, AI SDK y passkeys
Qué trae Laravel 13 (17 de marzo de 2026) y por qué es el upgrade más suave de la historia del framework: atributos PHP 8 para declarar modelos, jobs y comandos; el AI SDK ya de primera clase y agnóstico de proveedor; login sin contraseña con passkeys; Reverb con driver de base de datos para tiempo real sin Redis; Cache::touch(); y una guía de migración con cero cambios que rompen.
