Implementando CQRS con Laravel y Vue3
Arquitectura CQRS: Separación clara entre comandos (escritura) y consultas (lectura)
Introducción
CQRS (Command Query Responsibility Segregation) es un patrón arquitectónico que separa las operaciones de lectura y escritura en modelos diferentes. En aplicaciones web modernas con Laravel y Vue3, este patrón nos permite optimizar cada lado de manera independiente, mejorando significativamente el rendimiento y la escalabilidad.
¿Por qué CQRS? Imagina una aplicación de e-commerce: el 95% de las peticiones son consultas (listar productos, ver detalles), pero solo el 5% son comandos (crear pedidos, actualizar inventario). Con CQRS, podemos optimizar las lecturas con ElasticSearch y cachear con Redis sin afectar la integridad de las escrituras.
Conceptos fundamentales de CQRS
Commands (Comandos)
Son las operaciones de escritura que cambian el estado del sistema:
- CreateProductCommand
- UpdateInventoryCommand
- PlaceOrderCommand
Características:
- Representan intenciones de negocio
- Tienen efectos secundarios
- Pueden fallar y lanzar excepciones
- Se validan exhaustivamente
Queries (Consultas)
Son las operaciones de lectura que no modifican el estado:
- GetProductsQuery
- FindOrderByIdQuery
- SearchProductsQuery
Características:
- Solo retornan datos
- No tienen efectos secundarios
- Pueden estar cacheadas
- Se optimizan para performance
Separación de modelos
┌─────────────────────┐ ┌──────────────────────┐
│ WRITE MODEL │ │ READ MODEL │
│ │ │ │
│ Commands │ │ Queries │
│ Domain Logic │ │ DTOs │
│ MySQL/PostgreSQL │ ──▶ │ ElasticSearch │
│ Normalized │ Events │ Denormalized │
│ Strong Consistency │ │ Eventually Consistent│
└─────────────────────┘ └──────────────────────┘
Arquitectura de la aplicación
Backend (Laravel)
app/
├── Domain/
│ ├── Product/
│ │ ├── Commands/
│ │ │ ├── CreateProductCommand.php
│ │ │ └── UpdateProductCommand.php
│ │ ├── Handlers/
│ │ │ ├── CreateProductHandler.php
│ │ │ └── UpdateProductHandler.php
│ │ ├── Queries/
│ │ │ └── GetProductsQuery.php
│ │ └── Projections/
│ │ └── ProductProjection.php
├── Infrastructure/
│ ├── Persistence/
│ │ ├── ProductRepository.php
│ │ └── ProductReadRepository.php
│ ├── Search/
│ │ └── ElasticProductRepository.php
│ └── Cache/
│ └── RedisProductCache.php
└── Http/
└── Controllers/
├── ProductCommandController.php
└── ProductQueryController.php
Frontend (Vue3)
src/
├── modules/
│ └── products/
│ ├── commands/
│ │ └── useCreateProduct.ts
│ ├── queries/
│ │ └── useGetProducts.ts
│ └── components/
│ ├── ProductList.vue
│ └── ProductForm.vue
Flujo de datos en CQRS: Comandos modifican, eventos proyectan, consultas leen
Implementación paso a paso
Paso 1: Configurar Laravel para CQRS
Instalar dependencias:
composer require ramsey/uuid
composer require elasticsearch/elasticsearch
composer require predis/predis
Command Bus básico:
<?php
// app/Bus/CommandBus.php
namespace App\\Bus;
interface CommandBus
{
public function dispatch(Command $command): mixed;
}
// app/Bus/SimpleCommandBus.php
class SimpleCommandBus implements CommandBus
{
private array $handlers = [];
public function register(string $commandClass, string $handlerClass): void
{
$this->handlers[$commandClass] = $handlerClass;
}
public function dispatch(Command $command): mixed
{
$commandClass = get_class($command);
if (!isset($this->handlers[$commandClass])) {
throw new \RuntimeException("No handler for {$commandClass}");
}
$handler = app($this->handlers[$commandClass]);
return $handler->handle($command);
}
}
Paso 2: Crear Commands y Handlers
Command de creación de producto:
<?php
// app/Domain/Product/Commands/CreateProductCommand.php
namespace App\\Domain\\Product\\Commands;
use App\\Bus\\Command;
final class CreateProductCommand implements Command
{
public function __construct(
public readonly string $name,
public readonly string $description,
public readonly float $price,
public readonly int $stock,
public readonly ?string $categoryId = null
) {}
}
Handler del comando:
<?php
// app/Domain/Product/Handlers/CreateProductHandler.php
namespace App\\Domain\\Product\\Handlers;
use App\\Domain\\Product\\Commands\\CreateProductCommand;
use App\\Domain\\Product\\Product;
use App\\Domain\\Product\\ProductRepository;
use App\\Domain\\Product\\Events\\ProductCreated;
use Illuminate\\Support\\Str;
final class CreateProductHandler
{
public function __construct(
private ProductRepository $repository,
private EventDispatcher $eventDispatcher
) {}
public function handle(CreateProductCommand $command): string
{
$product = new Product(
id: Str::uuid()->toString(),
name: $command->name,
description: $command->description,
price: $command->price,
stock: $command->stock,
categoryId: $command->categoryId
);
$this->repository->save($product);
// Dispatch domain event for projection
$this->eventDispatcher->dispatch(
new ProductCreated(
$product->id,
$product->name,
$product->price,
$product->stock
)
);
return $product->id;
}
}
Paso 3: Implementar el Read Model con ElasticSearch
Configurar ElasticSearch:
<?php
// app/Infrastructure/Search/ElasticSearchClient.php
namespace App\\Infrastructure\\Search;
use Elasticsearch\\ClientBuilder;
class ElasticSearchClient
{
private $client;
public function __construct()
{
$this->client = ClientBuilder::create()
->setHosts([config('elasticsearch.host')])
->build();
}
public function index(string $index, string $id, array $body): void
{
$this->client->index([
'index' => $index,
'id' => $id,
'body' => $body
]);
}
public function search(string $index, array $query): array
{
return $this->client->search([
'index' => $index,
'body' => $query
]);
}
}
Repositorio de lectura:
<?php
// app/Infrastructure/Search/ElasticProductRepository.php
namespace App\\Infrastructure\\Search;
use App\\Domain\\Product\\Queries\\ProductReadRepository;
use App\\Domain\\Product\\Projections\\ProductProjection;
final class ElasticProductRepository implements ProductReadRepository
{
private const INDEX = 'products';
public function __construct(
private ElasticSearchClient $client
) {}
public function findAll(int $page = 1, int $perPage = 20): array
{
$from = ($page - 1) * $perPage;
$result = $this->client->search(self::INDEX, [
'from' => $from,
'size' => $perPage,
'query' => ['match_all' => new \stdClass()],
'sort' => [['created_at' => ['order' => 'desc']]]
]);
return array_map(
fn($hit) => ProductProjection::fromArray($hit['_source']),
$result['hits']['hits']
);
}
public function search(string $term): array
{
$result = $this->client->search(self::INDEX, [
'query' => [
'multi_match' => [
'query' => $term,
'fields' => ['name^3', 'description', 'category.name^2'],
'fuzziness' => 'AUTO'
]
]
]);
return array_map(
fn($hit) => ProductProjection::fromArray($hit['_source']),
$result['hits']['hits']
);
}
public function findById(string $id): ?ProductProjection
{
try {
$result = $this->client->get(self::INDEX, $id);
return ProductProjection::fromArray($result['_source']);
} catch (\Exception $e) {
return null;
}
}
}
Paso 4: Event Handlers para proyecciones
Listener de eventos:
<?php
// app/Domain/Product/Listeners/ProjectProductToElastic.php
namespace App\\Domain\\Product\\Listeners;
use App\\Domain\\Product\\Events\\ProductCreated;
use App\\Infrastructure\\Search\\ElasticSearchClient;
final class ProjectProductToElastic
{
public function __construct(
private ElasticSearchClient $client
) {}
public function handle(ProductCreated $event): void
{
$this->client->index('products', $event->productId, [
'id' => $event->productId,
'name' => $event->name,
'price' => $event->price,
'stock' => $event->stock,
'created_at' => now()->toIso8601String(),
'updated_at' => now()->toIso8601String()
]);
}
}
Paso 5: Implementar Query Handlers
Query:
<?php
// app/Domain/Product/Queries/GetProductsQuery.php
namespace App\\Domain\\Product\\Queries;
final class GetProductsQuery
{
public function __construct(
public readonly int $page = 1,
public readonly int $perPage = 20,
public readonly ?string $search = null,
public readonly ?string $category = null
) {}
}
Query Handler:
<?php
// app/Domain/Product/Handlers/GetProductsHandler.php
namespace App\\Domain\\Product\\Handlers;
use App\\Domain\\Product\\Queries\\GetProductsQuery;
use App\\Infrastructure\\Search\\ElasticProductRepository;
use App\\Infrastructure\\Cache\\RedisProductCache;
final class GetProductsHandler
{
public function __construct(
private ElasticProductRepository $readRepository,
private RedisProductCache $cache
) {}
public function handle(GetProductsQuery $query): array
{
$cacheKey = $this->getCacheKey($query);
// Try cache first
$cached = $this->cache->get($cacheKey);
if ($cached !== null) {
return $cached;
}
// Query ElasticSearch
$products = $query->search
? $this->readRepository->search($query->search)
: $this->readRepository->findAll($query->page, $query->perPage);
// Cache for 5 minutes
$this->cache->set($cacheKey, $products, 300);
return $products;
}
private function getCacheKey(GetProductsQuery $query): string
{
return sprintf(
'products:%s:%d:%d',
$query->search ?? 'all',
$query->page,
$query->perPage
);
}
}
Paso 6: Controllers
Command Controller:
<?php
// app/Http/Controllers/ProductCommandController.php
namespace App\\Http\\Controllers;
use App\\Domain\\Product\\Commands\\CreateProductCommand;
use App\\Bus\\CommandBus;
use Illuminate\\Http\\Request;
class ProductCommandController extends Controller
{
public function __construct(
private CommandBus $commandBus
) {}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'required|string',
'price' => 'required|numeric|min:0',
'stock' => 'required|integer|min:0',
'category_id' => 'nullable|uuid'
]);
$command = new CreateProductCommand(
name: $validated['name'],
description: $validated['description'],
price: $validated['price'],
stock: $validated['stock'],
categoryId: $validated['category_id'] ?? null
);
$productId = $this->commandBus->dispatch($command);
return response()->json([
'id' => $productId,
'message' => 'Product created successfully'
], 201);
}
}
Query Controller:
<?php
// app/Http/Controllers/ProductQueryController.php
namespace App\\Http\\Controllers;
use App\\Domain\\Product\\Queries\\GetProductsQuery;
use App\\Domain\\Product\\Handlers\\GetProductsHandler;
use Illuminate\\Http\\Request;
class ProductQueryController extends Controller
{
public function __construct(
private GetProductsHandler $handler
) {}
public function index(Request $request)
{
$query = new GetProductsQuery(
page: (int) $request->get('page', 1),
perPage: (int) $request->get('per_page', 20),
search: $request->get('search')
);
$products = $this->handler->handle($query);
return response()->json([
'data' => $products,
'meta' => [
'page' => $query->page,
'per_page' => $query->perPage
]
]);
}
}
Paso 7: Frontend Vue3 con Composables
Composable para consultas:
// src/modules/products/queries/useGetProducts.ts
import { ref, computed } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import axios from 'axios'
interface Product {
id: string
name: string
description: string
price: number
stock: number
createdAt: string
}
interface ProductsResponse {
data: Product[]
meta: {
page: number
perPage: number
}
}
export function useGetProducts(page = ref(1), search = ref('')) {
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['products', page, search],
queryFn: async () => {
const response = await axios.get<ProductsResponse>('/api/products', {
params: {
page: page.value,
search: search.value || undefined
}
})
return response.data
},
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000 // 10 minutes
})
const products = computed(() => data.value?.data ?? [])
const meta = computed(() => data.value?.meta)
return {
products,
meta,
isLoading,
isError,
refetch
}
}
Composable para comandos:
// src/modules/products/commands/useCreateProduct.ts
import { ref } from 'vue'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import axios from 'axios'
import { toast } from 'vue-sonner'
interface CreateProductDto {
name: string
description: string
price: number
stock: number
categoryId?: string
}
interface CreateProductResponse {
id: string
message: string
}
export function useCreateProduct() {
const queryClient = useQueryClient()
const isSubmitting = ref(false)
const { mutate, mutateAsync } = useMutation({
mutationFn: async (data: CreateProductDto) => {
const response = await axios.post<CreateProductResponse>(
'/api/products',
data
)
return response.data
},
onMutate: () => {
isSubmitting.value = true
},
onSuccess: (data) => {
// Invalidate queries to refetch
queryClient.invalidateQueries({ queryKey: ['products'] })
toast.success('Product created successfully', {
description: `Product ID: ${data.id}`
})
isSubmitting.value = false
},
onError: (error: any) => {
toast.error('Failed to create product', {
description: error.response?.data?.message || 'Unknown error'
})
isSubmitting.value = false
}
})
return {
createProduct: mutate,
createProductAsync: mutateAsync,
isSubmitting
}
}
Componente Vue3:
<!-- src/modules/products/components/ProductList.vue -->
<template>
<div class="product-list">
<div class="mb-6">
<input
v-model="searchTerm"
type="search"
placeholder="Search products..."
class="w-full px-4 py-2 border rounded-lg"
@input="debouncedSearch"
/>
</div>
<div v-if="isLoading" class="text-center py-8">
<LoadingSpinner />
</div>
<div v-else-if="isError" class="text-red-600">
Error loading products
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
<ProductCard
v-for="product in products"
:key="product.id"
:product="product"
/>
</div>
<div class="mt-6 flex justify-center">
<Pagination
:current-page="page"
@update:page="page = $event"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { useGetProducts } from '../queries/useGetProducts'
import ProductCard from './ProductCard.vue'
import Pagination from '@/components/Pagination.vue'
import LoadingSpinner from '@/components/LoadingSpinner.vue'
const page = ref(1)
const searchTerm = ref('')
const search = ref('')
const { products, isLoading, isError, refetch } = useGetProducts(page, search)
const debouncedSearch = useDebounceFn(() => {
search.value = searchTerm.value
page.value = 1
}, 300)
</script>
Ventajas de CQRS
1. Optimización Independiente
- Lecturas con ElasticSearch (búsquedas rápidas, full-text)
- Escrituras con PostgreSQL (integridad transaccional)
- Cache agresivo en lecturas sin afectar escrituras
2. Escalabilidad Horizontal
- Múltiples réplicas de read models
- Write model en un solo servidor con alta consistencia
- Balanceo de carga en consultas
3. Modelos Especializados
- Write model: Normalizado, validaciones estrictas
- Read model: Denormalizado, optimizado para UI
4. Auditoría y Event Sourcing
- Todos los comandos son rastreables
- Historial completo de cambios
- Posibilidad de replay de eventos
Consideraciones y mejores prácticas
1. Eventual Consistency
El read model no se actualiza instantáneamente. Soluciones:
// Mostrar estado optimista en el frontend
const { mutate } = useCreateProduct()
const handleCreate = async (data) => {
// Agregar optimísticamente a la UI
queryClient.setQueryData(['products'], (old) => ({
...old,
data: [newProduct, ...old.data]
}))
await mutate(data)
}
2. Invalidación de caché
Invalidar cache cuando se ejecutan comandos:
<?php
// En el EventListener
public function handle(ProductCreated $event): void
{
// Proyectar a Elastic
$this->elastic->index(...);
// Invalidar cache
$this->cache->invalidatePattern('products:*');
}
3. Manejo de errores
Implementar retry logic y dead letter queues:
<?php
// config/queue.php
'connections' => [
'rabbitmq' => [
'driver' => 'rabbitmq',
'retry_after' => 90,
'max_tries' => 3,
'failed' => [
'driver' => 'database',
'table' => 'failed_jobs',
],
],
],
4. Testing
Test de comandos:
<?php
class CreateProductHandlerTest extends TestCase
{
public function test_it_creates_product()
{
$command = new CreateProductCommand(
name: 'Test Product',
description: 'Description',
price: 99.99,
stock: 10
);
$productId = $this->handler->handle($command);
$this->assertNotNull($productId);
$this->assertDatabaseHas('products', [
'id' => $productId,
'name' => 'Test Product'
]);
}
}
Test de consultas:
// useGetProducts.test.ts
import { describe, it, expect } from 'vitest'
import { useGetProducts } from './useGetProducts'
describe('useGetProducts', () => {
it('fetches products successfully', async () => {
const { products, isLoading } = useGetProducts()
await waitFor(() => {
expect(isLoading.value).toBe(false)
})
expect(products.value).toHaveLength(20)
})
})
Errores comunes
❌ Consultas en el write model
<?php
// ❌ MAL: No uses el write model para consultas complejas
$products = Product::with('category', 'reviews')
->where('active', true)
->get();
✅ Usa el read model
<?php
// ✅ BIEN: Usa ElasticSearch para consultas
$products = $this->readRepository->findAll();
❌ Lógica de negocio en queries
<?php
// ❌ MAL
public function handle(GetProductsQuery $query)
{
// No valides ni modifiques en queries
if ($price > 100) {
$price = $price * 0.9; // ❌ Nunca!
}
}
Conclusión
CQRS con Laravel y Vue3 nos permite construir aplicaciones altamente escalables y performantes. La separación de responsabilidades entre comandos y consultas, combinada con ElasticSearch para búsquedas y Redis para caché, resulta en una arquitectura robusta y mantenible.
Puntos clave:
- Separa write y read models físicamente
- Usa eventos para proyectar datos al read model
- Implementa caché agresivo en consultas
- Maneja eventual consistency en el frontend
- Testea comandos y consultas por separado
Recursos adicionales
- Martin Fowler - CQRS
- Laravel Events Documentation
- ElasticSearch PHP Client
- TanStack Query (Vue Query)
¿Implementas CQRS en tus proyectos? ¿Qué desafíos has encontrado? Comparte tu experiencia en LinkedIn.