Architecture

Implementando CQRS con Laravel y Vue3

Descubre cómo implementar el patrón CQRS en aplicaciones Laravel con frontend Vue3 para mejorar el rendimiento y escalabilidad.

FC

Fernando Caravaca

FullStack Developer

28 de noviembre de 2024
15 min de lectura
Diagrama CQRS

Implementando CQRS con Laravel y Vue3

CQRS Architecture 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

CQRS Flow 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:

  1. Separa write y read models físicamente
  2. Usa eventos para proyectar datos al read model
  3. Implementa caché agresivo en consultas
  4. Maneja eventual consistency en el frontend
  5. Testea comandos y consultas por separado

Recursos adicionales


¿Implementas CQRS en tus proyectos? ¿Qué desafíos has encontrado? Comparte tu experiencia en LinkedIn.

#Laravel#Vue3#CQRS#ElasticSearch#Redis

¿Te gustó este artículo?

Comparte tus pensamientos en LinkedIn o contáctame si quieres discutir estos temas.

Fernando Caravaca - FullStack Developer