Oscar Coleto

Patrón Inbox/Outbox en Laravel: Mensajería Confiable en Sistemas Distribuidos

Introducción

Cuando trabajas con microservicios o sistemas distribuidos, uno de los mayores desafíos es garantizar que los mensajes entre servicios se envíen y procesen de manera confiable. ¿Qué pasa si tu aplicación falla justo después de guardar datos en la base de datos pero antes de enviar el evento? ¿Cómo evitas procesar el mismo mensaje dos veces?

Los patrones Inbox y Outbox resuelven estos problemas de forma elegante, garantizando consistencia eventual y procesamiento idempotente de mensajes.

En este artículo, aprenderás a implementar estos patrones en Laravel con ejemplos prácticos que puedes aplicar inmediatamente en tus proyectos.

El Problema: Inconsistencia en Sistemas Distribuidos

Imagina que tienes un e-commerce con microservicios separados:

class OrderController extends Controller
{
    public function store(Request $request)
    {
        // 1. Guardar el pedido en la base de datos
        $order = Order::create([
            'user_id' => $request->user()->id,
            'total' => $request->total,
            'status' => 'pending'
        ]);

        // 2. Enviar evento al servicio de inventario
        Http::post('https://inventory-service/api/reserve', [
            'order_id' => $order->id,
            'items' => $request->items
        ]);

        // 3. Enviar evento al servicio de notificaciones
        Http::post('https://notification-service/api/send', [
            'user_id' => $order->user_id,
            'type' => 'order_confirmation'
        ]);

        return response()->json($order);
    }
}

Problemas con este código:

Pérdida de mensajes: Si la aplicación falla después del paso 1, los servicios nunca reciben los eventos

Inconsistencia: El pedido está guardado pero el inventario no se reservó

Mensajes duplicados: Si reintentamos, podríamos enviar el evento dos veces

Acoplamiento temporal: El controlador debe esperar las respuestas HTTP

¿Qué es el Patrón Outbox?

El patrón Outbox garantiza que los mensajes se publiquen de manera confiable utilizando la misma transacción de base de datos que el cambio de estado.

Cómo Funciona

  1. En lugar de publicar eventos directamente, los guardas en una tabla “outbox” dentro de la misma transacción
  2. Un proceso separado lee la tabla outbox y publica los eventos a tu sistema de mensajería (RabbitMQ, Kafka, SQS, etc.)
  3. Una vez publicados, los mensajes se marcan como procesados o se eliminan

Beneficio clave: Si la transacción falla, tanto el cambio de estado como los eventos se revierten. Si tiene éxito, los eventos eventualmente se publicarán.

¿Qué es el Patrón Inbox?

El patrón Inbox garantiza que los mensajes se procesen de manera idempotente, evitando duplicados.

Cómo Funciona

  1. Cuando recibes un mensaje, primero lo guardas en una tabla “inbox” con su ID único
  2. Verificas si ya fue procesado anteriormente
  3. Si es nuevo, lo procesas y marcas como procesado
  4. Si ya fue procesado, lo ignoras

Beneficio clave: Puedes recibir el mismo mensaje múltiples veces sin efectos secundarios duplicados.

Implementando el Patrón Outbox en Laravel

Vamos a implementar el patrón Outbox paso a paso.

Paso 1: Crear la Tabla Outbox

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::create('outbox_messages', function (Blueprint $table) {
            $table->id();
            $table->string('aggregate_type'); // Ej: 'order', 'payment'
            $table->string('aggregate_id');   // ID del recurso
            $table->string('event_type');     // Ej: 'OrderCreated'
            $table->json('payload');          // Datos del evento
            $table->timestamp('occurred_at');
            $table->timestamp('processed_at')->nullable();
            $table->timestamps();

            $table->index(['processed_at', 'occurred_at']);
        });
    }

    public function down()
    {
        Schema::dropIfExists('outbox_messages');
    }
};

Paso 2: Crear el Modelo Outbox

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class OutboxMessage extends Model
{
    protected $fillable = [
        'aggregate_type',
        'aggregate_id',
        'event_type',
        'payload',
        'occurred_at',
        'processed_at',
    ];

    protected $casts = [
        'payload' => 'array',
        'occurred_at' => 'datetime',
        'processed_at' => 'datetime',
    ];

    public function scopePending($query)
    {
        return $query->whereNull('processed_at')
            ->orderBy('occurred_at');
    }

    public function markAsProcessed(): void
    {
        $this->update(['processed_at' => now()]);
    }
}

Paso 3: Crear un Servicio para Registrar Eventos

<?php

namespace App\Services;

use App\Models\OutboxMessage;
use Illuminate\Support\Facades\DB;

class OutboxService
{
    /**
     * Registrar un evento en el outbox.
     */
    public function record(
        string $aggregateType,
        string $aggregateId,
        string $eventType,
        array $payload
    ): OutboxMessage {
        return OutboxMessage::create([
            'aggregate_type' => $aggregateType,
            'aggregate_id' => $aggregateId,
            'event_type' => $eventType,
            'payload' => $payload,
            'occurred_at' => now(),
        ]);
    }

    /**
     * Registrar múltiples eventos en una transacción.
     */
    public function recordMany(array $events): void
    {
        DB::transaction(function () use ($events) {
            foreach ($events as $event) {
                $this->record(
                    $event['aggregate_type'],
                    $event['aggregate_id'],
                    $event['event_type'],
                    $event['payload']
                );
            }
        });
    }
}

Paso 4: Usar el Outbox en tu Lógica de Negocio

<?php

namespace App\Http\Controllers;

use App\Models\Order;
use App\Services\OutboxService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class OrderController extends Controller
{
    public function __construct(
        private OutboxService $outboxService
    ) {}

    public function store(Request $request)
    {
        $validated = $request->validate([
            'items' => 'required|array',
            'total' => 'required|numeric',
        ]);

        // Todo en una transacción atómica
        $order = DB::transaction(function () use ($validated, $request) {
            // 1. Crear el pedido
            $order = Order::create([
                'user_id' => $request->user()->id,
                'total' => $validated['total'],
                'status' => 'pending',
            ]);

            // 2. Registrar eventos en el outbox (misma transacción)
            $this->outboxService->record(
                aggregateType: 'order',
                aggregateId: (string) $order->id,
                eventType: 'OrderCreated',
                payload: [
                    'order_id' => $order->id,
                    'user_id' => $order->user_id,
                    'items' => $validated['items'],
                    'total' => $validated['total'],
                ]
            );

            $this->outboxService->record(
                aggregateType: 'order',
                aggregateId: (string) $order->id,
                eventType: 'InventoryReservationRequested',
                payload: [
                    'order_id' => $order->id,
                    'items' => $validated['items'],
                ]
            );

            return $order;
        });

        return response()->json($order);
    }
}

Ventajas de esta implementación:

Atomicidad garantizada: Si falla algo, todo se revierte

Respuesta rápida: El controlador no espera publicar eventos

Sin pérdida de mensajes: Los eventos están persistidos

Paso 5: Crear el Procesador de Outbox

<?php

namespace App\Console\Commands;

use App\Models\OutboxMessage;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Event;

class ProcessOutboxMessages extends Command
{
    protected $signature = 'outbox:process';
    protected $description = 'Procesar mensajes pendientes del outbox';

    public function handle()
    {
        OutboxMessage::pending()
            ->chunk(100, function ($messages) {
                foreach ($messages as $message) {
                    try {
                        // Publicar el evento
                        $this->publishEvent($message);

                        // Marcar como procesado
                        $message->markAsProcessed();

                        $this->info("Procesado: {$message->event_type} #{$message->id}");
                    } catch (\Exception $e) {
                        $this->error("Error procesando mensaje #{$message->id}: {$e->getMessage()}");
                        // Opcionalmente: registrar error, implementar reintento, etc.
                    }
                }
            });
    }

    private function publishEvent(OutboxMessage $message): void
    {
        // Publicar a tu sistema de mensajería
        match ($message->event_type) {
            'OrderCreated' => Event::dispatch(new \App\Events\OrderCreated($message->payload)),
            'InventoryReservationRequested' => Event::dispatch(new \App\Events\InventoryReservationRequested($message->payload)),
            default => throw new \Exception("Tipo de evento desconocido: {$message->event_type}")
        };

        // O publicar a RabbitMQ, Kafka, SQS, etc.
        // RabbitMQ::publish($message->event_type, $message->payload);
    }
}

Paso 6: Programar el Procesador

<?php

namespace App\Console;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;

class Kernel extends ConsoleKernel
{
    protected function schedule(Schedule $schedule)
    {
        // Procesar outbox cada minuto
        $schedule->command('outbox:process')
            ->everyMinute()
            ->withoutOverlapping();
    }
}

Implementando el Patrón Inbox en Laravel

Ahora implementemos el patrón Inbox para procesar mensajes de forma idempotente.

Paso 1: Crear la Tabla Inbox

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::create('inbox_messages', function (Blueprint $table) {
            $table->id();
            $table->string('message_id')->unique(); // ID único del mensaje
            $table->string('event_type');
            $table->json('payload');
            $table->timestamp('received_at');
            $table->timestamp('processed_at')->nullable();
            $table->text('error')->nullable();
            $table->timestamps();

            $table->index('processed_at');
        });
    }

    public function down()
    {
        Schema::dropIfExists('inbox_messages');
    }
};

Paso 2: Crear el Modelo Inbox

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class InboxMessage extends Model
{
    protected $fillable = [
        'message_id',
        'event_type',
        'payload',
        'received_at',
        'processed_at',
        'error',
    ];

    protected $casts = [
        'payload' => 'array',
        'received_at' => 'datetime',
        'processed_at' => 'datetime',
    ];

    public function isProcessed(): bool
    {
        return $this->processed_at !== null;
    }

    public function markAsProcessed(): void
    {
        $this->update(['processed_at' => now()]);
    }

    public function markAsFailed(string $error): void
    {
        $this->update([
            'error' => $error,
            'processed_at' => now(),
        ]);
    }
}

Paso 3: Crear el Servicio Inbox

<?php

namespace App\Services;

use App\Models\InboxMessage;
use Illuminate\Support\Facades\DB;

class InboxService
{
    /**
     * Procesar un mensaje de forma idempotente.
     */
    public function processMessage(
        string $messageId,
        string $eventType,
        array $payload,
        callable $handler
    ): bool {
        return DB::transaction(function () use ($messageId, $eventType, $payload, $handler) {
            // Intentar crear el mensaje en el inbox
            $message = InboxMessage::firstOrCreate(
                ['message_id' => $messageId],
                [
                    'event_type' => $eventType,
                    'payload' => $payload,
                    'received_at' => now(),
                ]
            );

            // Si ya fue procesado, ignorar (idempotencia)
            if ($message->isProcessed()) {
                return true;
            }

            try {
                // Ejecutar el handler
                $handler($payload);

                // Marcar como procesado
                $message->markAsProcessed();

                return true;
            } catch (\Exception $e) {
                $message->markAsFailed($e->getMessage());
                throw $e;
            }
        });
    }
}

Paso 4: Usar el Inbox para Procesar Eventos

<?php

namespace App\Http\Controllers;

use App\Services\InboxService;
use App\Services\InventoryService;
use Illuminate\Http\Request;

class WebhookController extends Controller
{
    public function __construct(
        private InboxService $inboxService,
        private InventoryService $inventoryService
    ) {}

    /**
     * Webhook para recibir eventos de otros servicios.
     */
    public function handle(Request $request)
    {
        $validated = $request->validate([
            'message_id' => 'required|string',
            'event_type' => 'required|string',
            'payload' => 'required|array',
        ]);

        try {
            $this->inboxService->processMessage(
                messageId: $validated['message_id'],
                eventType: $validated['event_type'],
                payload: $validated['payload'],
                handler: function ($payload) use ($validated) {
                    // Procesar según el tipo de evento
                    match ($validated['event_type']) {
                        'InventoryReserved' => $this->handleInventoryReserved($payload),
                        'PaymentCompleted' => $this->handlePaymentCompleted($payload),
                        default => throw new \Exception("Tipo de evento desconocido")
                    };
                }
            );

            return response()->json(['status' => 'processed']);
        } catch (\Exception $e) {
            return response()->json(['error' => $e->getMessage()], 500);
        }
    }

    private function handleInventoryReserved(array $payload): void
    {
        // Actualizar el estado del pedido
        $order = Order::findOrFail($payload['order_id']);
        $order->update(['inventory_status' => 'reserved']);
    }

    private function handlePaymentCompleted(array $payload): void
    {
        $order = Order::findOrFail($payload['order_id']);
        $order->update(['status' => 'paid']);
    }
}

Caso Práctico Completo: E-commerce con Microservicios

Veamos un ejemplo completo de cómo los patrones Inbox/Outbox trabajan juntos:

Servicio de Pedidos (Order Service)

<?php

namespace App\Services;

use App\Models\Order;
use Illuminate\Support\Facades\DB;

class OrderService
{
    public function __construct(
        private OutboxService $outboxService
    ) {}

    public function createOrder(array $data): Order
    {
        return DB::transaction(function () use ($data) {
            // 1. Crear pedido
            $order = Order::create([
                'user_id' => $data['user_id'],
                'total' => $data['total'],
                'status' => 'pending',
            ]);

            // 2. Registrar eventos en outbox
            $this->outboxService->record(
                'order',
                (string) $order->id,
                'OrderCreated',
                [
                    'order_id' => $order->id,
                    'user_id' => $order->user_id,
                    'items' => $data['items'],
                    'total' => $data['total'],
                ]
            );

            return $order;
        });
    }

    public function confirmOrder(int $orderId): void
    {
        DB::transaction(function () use ($orderId) {
            $order = Order::findOrFail($orderId);
            $order->update(['status' => 'confirmed']);

            $this->outboxService->record(
                'order',
                (string) $order->id,
                'OrderConfirmed',
                [
                    'order_id' => $order->id,
                    'user_id' => $order->user_id,
                ]
            );
        });
    }
}

Servicio de Inventario (Inventory Service)

<?php

namespace App\Services;

use App\Models\InventoryReservation;
use Illuminate\Support\Facades\DB;

class InventoryService
{
    public function __construct(
        private OutboxService $outboxService,
        private InboxService $inboxService
    ) {}

    /**
     * Procesar solicitud de reserva (desde OrderCreated).
     */
    public function handleReservationRequest(string $messageId, array $payload): void
    {
        $this->inboxService->processMessage(
            $messageId,
            'OrderCreated',
            $payload,
            function ($data) {
                DB::transaction(function () use ($data) {
                    // Reservar inventario
                    $reservation = InventoryReservation::create([
                        'order_id' => $data['order_id'],
                        'items' => $data['items'],
                        'status' => 'reserved',
                    ]);

                    // Publicar evento de confirmación
                    $this->outboxService->record(
                        'inventory',
                        (string) $reservation->id,
                        'InventoryReserved',
                        [
                            'order_id' => $data['order_id'],
                            'reservation_id' => $reservation->id,
                        ]
                    );
                });
            }
        );
    }
}

Optimizaciones y Mejores Prácticas

1. Limpieza de Mensajes Procesados

<?php

namespace App\Console\Commands;

use App\Models\OutboxMessage;
use App\Models\InboxMessage;
use Illuminate\Console\Command;

class CleanupMessages extends Command
{
    protected $signature = 'messages:cleanup {--days=7}';
    protected $description = 'Limpiar mensajes procesados antiguos';

    public function handle()
    {
        $days = $this->option('days');

        // Limpiar outbox
        $deletedOutbox = OutboxMessage::whereNotNull('processed_at')
            ->where('processed_at', '<', now()->subDays($days))
            ->delete();

        // Limpiar inbox
        $deletedInbox = InboxMessage::whereNotNull('processed_at')
            ->where('processed_at', '<', now()->subDays($days))
            ->delete();

        $this->info("Limpiados {$deletedOutbox} mensajes del outbox y {$deletedInbox} del inbox");
    }
}

2. Reintentos con Backoff Exponencial

<?php

namespace App\Services;

use App\Models\OutboxMessage;

class OutboxProcessor
{
    private const MAX_RETRIES = 5;

    public function processMessage(OutboxMessage $message): void
    {
        $attempt = $message->retry_count ?? 0;

        try {
            $this->publish($message);
            $message->markAsProcessed();
        } catch (\Exception $e) {
            if ($attempt >= self::MAX_RETRIES) {
                $message->update([
                    'error' => $e->getMessage(),
                    'failed_at' => now(),
                ]);
                return;
            }

            // Backoff exponencial: 1s, 2s, 4s, 8s, 16s
            $delay = pow(2, $attempt);

            $message->update([
                'retry_count' => $attempt + 1,
                'next_retry_at' => now()->addSeconds($delay),
            ]);
        }
    }
}

3. Monitoreo y Alertas

<?php

namespace App\Console\Commands;

use App\Models\OutboxMessage;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;

class MonitorOutbox extends Command
{
    protected $signature = 'outbox:monitor';
    protected $description = 'Monitorear mensajes atascados en el outbox';

    public function handle()
    {
        // Alertar si hay mensajes muy antiguos sin procesar
        $stuckMessages = OutboxMessage::whereNull('processed_at')
            ->where('occurred_at', '<', now()->subHours(1))
            ->count();

        if ($stuckMessages > 0) {
            Log::warning("Hay {$stuckMessages} mensajes atascados en el outbox");
            // Enviar alerta a Slack, email, etc.
        }

        $this->info("Monitoreo completado: {$stuckMessages} mensajes atascados");
    }
}

4. Particionamiento de Tablas

Para aplicaciones de alto volumen, considera particionar las tablas por fecha:

// En tu migración
Schema::create('outbox_messages', function (Blueprint $table) {
    $table->id();
    // ... otros campos
    $table->timestamp('occurred_at');

    // Índice para particionamiento
    $table->index('occurred_at');
});

// Crear particiones mensuales (depende de tu DBMS)
// PostgreSQL ejemplo:
DB::statement("
    CREATE TABLE outbox_messages_2026_01 PARTITION OF outbox_messages
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01')
");

Cuándo Usar los Patrones Inbox/Outbox

Usa Inbox/Outbox cuando:

  • Trabajas con arquitectura de microservicios
  • Necesitas consistencia eventual entre servicios
  • Requieres mensajería confiable sin pérdida de datos
  • Implementas Event Sourcing o CQRS
  • Tienes comunicación asíncrona entre servicios
  • Necesitas garantizar idempotencia en el procesamiento

No uses Inbox/Outbox cuando:

  • Tienes una aplicación monolítica simple
  • No necesitas consistencia eventual
  • La comunicación síncrona es suficiente
  • El volumen de mensajes es muy bajo
  • La complejidad adicional no aporta valor

Ventajas del Patrón Inbox/Outbox

Atomicidad: Cambios de estado y eventos en la misma transacción

Confiabilidad: Sin pérdida de mensajes

Idempotencia: Procesamiento seguro de duplicados

Desacoplamiento: Servicios independientes

Resiliencia: Tolerancia a fallos temporales

Auditabilidad: Registro completo de eventos

Desventajas y Consideraciones

Complejidad adicional: Más tablas y lógica que mantener

Latencia: Los eventos no se publican instantáneamente

Almacenamiento: Crecimiento de tablas que requiere limpieza

⚠️ Cuidado con: Mensajes atascados, falta de monitoreo, falta de limpieza

Alternativas y Herramientas

Paquetes Laravel

Conclusión

Los patrones Inbox y Outbox son herramientas esenciales para construir sistemas distribuidos confiables. Aunque añaden complejidad, los beneficios en términos de consistencia, confiabilidad e idempotencia son invaluables en arquitecturas de microservicios.

Recapitulación:

Outbox garantiza que los eventos se publiquen de manera confiable

Inbox garantiza procesamiento idempotente sin duplicados

Ambos patrones trabajan juntos para consistencia eventual

Se implementan fácilmente en Laravel con tablas y servicios

Requieren monitoreo, limpieza y manejo de reintentos

Si estás construyendo microservicios o sistemas distribuidos con Laravel, estos patrones te ayudarán a crear una arquitectura más robusta y confiable.


Artículos Relacionados

Si te gustó este artículo, te recomiendo leer:

Happy coding!

C O M E N T A R I O S

Deja un comentario

0/2000 caracteres

Tu email no será publicado. Los campos marcados con * son obligatorios.

Cargando comentarios...

☕ ¿Te ha sido útil este artículo?

Apóyame con un café mientras sigo creando contenido técnico

☕ Invítame un café