Sensor metálico oxidado sobre un poste en un campo nocturno, con haces de luz púrpura que se dirigen hacia receptores lejanos

Microservicios con DDD cuando los eventos vienen de dispositivos físicos, no de un navegador

Un navegador que llama a tu API tiene sesión, tiene un usuario detrás que puede reintentar si algo falla, y normalmente tiene buena conectividad. Un dispositivo de campo no tiene nada de eso. Tiene una conexión que se cae en el peor momento, un reloj interno que no siempre coincide con el del servidor, y una lógica de reintento programada en firmware que va a reenviar el mismo evento las veces que haga falta hasta recibir una confirmación, sin preguntarte si ya lo procesaste antes.

Diseñar un backend con microservicios y DDD sobre ASP.NET Core Web API y EF Core, para un sistema con dispositivos de campo como productores de eventos, significó aceptar desde el principio que ese productor no era de fiar de la misma forma en que lo es un cliente web. No es que los dispositivos fallen más: es que fallan de otra manera, y el diseño tiene que asumirlo en vez de descubrirlo en producción.

El evento de dominio no es el paquete que llega por cable

Lo primero que separé fue el evento tal y como llega del dispositivo —un payload con el formato que decida el firmware de turno— del evento de dominio que el resto del sistema entiende. Mezclar los dos hace que cualquier cambio de firmware, o cualquier dispositivo con una peculiaridad, se filtre hasta el corazón del dominio.

public sealed record DeviceEventReceived(
    Guid DeviceId,
    string EventId,
    DeviceEventType Type,
    DateTimeOffset OccurredAtDevice,
    DateTimeOffset ReceivedAt) : IDomainEvent;

public sealed class DeviceSession
{
    private readonly List<IDomainEvent> _domainEvents = [];

    public Guid Id { get; private init; }
    public string DeviceId { get; private init; } = default!;
    public DateTimeOffset EntryAt { get; private set; }
    public DateTimeOffset? ExitAt { get; private set; }
    public DeviceSessionStatus Status { get; private set; }

    public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    public static DeviceSession Open(string deviceId, DateTimeOffset entryAt) => new()
    {
        Id = Guid.NewGuid(),
        DeviceId = deviceId,
        EntryAt = entryAt,
        Status = DeviceSessionStatus.Open
    };

    public void Close(DateTimeOffset exitAt)
    {
        if (Status is not DeviceSessionStatus.Open)
            throw new InvalidOperationException($"No se puede cerrar una sesión en estado {Status}");

        if (exitAt < EntryAt)
            throw new InvalidOperationException("La salida no puede ser anterior a la entrada");

        ExitAt = exitAt;
        Status = DeviceSessionStatus.Closed;
    }
}

El agregado DeviceSession protege sus propias invariantes: no se cierra una sesión que no está abierta, y no se acepta una salida anterior a la entrada, aunque el dispositivo mande el timestamp que mande. Esa validación vive en el dominio, no en el controlador que recibe el webhook, porque el dominio es el único sitio donde tiene que vivir una regla de negocio de este tipo.

El problema que un navegador nunca te da: el mismo evento, dos veces

Un dispositivo de campo con conectividad intermitente reintenta. Eso es correcto desde su punto de vista: si no recibió confirmación, asume que el envío falló y lo repite. El resultado, desde el lado del servidor, es que el mismo evento puede llegar dos o tres veces, con minutos de diferencia, y el sistema tiene que reconocerlo como el mismo evento, no como tres eventos distintos.

La solución no es optimista: cada evento trae un identificador propio generado por el dispositivo, y esa idempotencia se comprueba antes de aceptar nada, en el mismo endpoint que recibe el webhook.

app.MapPost("/api/v1/devices/{deviceId}/events", async (
    string deviceId,
    DeviceEventPayload payload,
    EventsDbContext db,
    IPublishEndpoint bus,
    CancellationToken cancellationToken) =>
{
    var alreadyProcessed = await db.ProcessedDeviceEvents
        .AnyAsync(e => e.EventId == payload.EventId, cancellationToken);

    if (alreadyProcessed)
        return Results.Ok(new { status = "duplicado-ignorado" });

    db.ProcessedDeviceEvents.Add(new ProcessedDeviceEvent
    {
        EventId = payload.EventId,
        DeviceId = deviceId,
        ReceivedAt = DateTimeOffset.UtcNow
    });

    await db.SaveChangesAsync(cancellationToken);

    await bus.Publish(new DeviceEventReceived(
        Guid.Parse(deviceId),
        payload.EventId,
        payload.Type,
        payload.OccurredAtDevice,
        DateTimeOffset.UtcNow), cancellationToken);

    return Results.Accepted();
});

Esto responde 200 con duplicado-ignorado al segundo y tercer envío del mismo evento, en vez de tratarlos como dos aperturas distintas del mismo dispositivo. Es una diferencia pequeña de código y una diferencia enorme de correctitud: sin esa comprobación, cada reintento del dispositivo generaría un evento de dominio nuevo, y el agregado DeviceSession recibiría una segunda apertura que su propia lógica de invariantes rechazaría con una excepción, en el mejor de los casos, o duplicaría datos, en el peor.

Microservicios con DDD: desacoplar la llegada del evento

Con volumen alto de eventos de campo, procesar cada webhook de forma síncrona dentro de la misma petición HTTP —validar, aplicar la regla de negocio, escribir en base de datos, todo antes de responder al dispositivo— acopla la disponibilidad del backend a la de cada paso intermedio. Si el paso de negocio tarda o falla, el dispositivo ve un timeout y reintenta, generando más carga justo cuando el sistema ya estaba bajo presión.

Separar la recepción —confirmar que el evento es válido y quedó registrado— del procesamiento del dominio, usando una cola, evita ese círculo. RabbitMQ hace de intermediario: el endpoint publica el evento y responde rápido; un consumidor aparte lo procesa con su propio ritmo.

public sealed class DeviceEventConsumer(
    IServiceScopeFactory scopeFactory,
    IDeviceEventChannel channel,
    ILogger<DeviceEventConsumer> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var message in channel.Reader.ReadAllAsync(stoppingToken))
        {
            using var scope = scopeFactory.CreateScope();
            var sessions = scope.ServiceProvider.GetRequiredService<IDeviceSessionRepository>();

            try
            {
                await HandleAsync(message, sessions, stoppingToken);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Fallo procesando evento {EventId} del dispositivo {DeviceId}",
                    message.EventId, message.DeviceId);
            }
        }
    }

    private static async Task HandleAsync(
        DeviceEventReceived message,
        IDeviceSessionRepository sessions,
        CancellationToken cancellationToken)
    {
        switch (message.Type)
        {
            case DeviceEventType.Started:
                var opened = DeviceSession.Open(message.DeviceId.ToString(), message.OccurredAtDevice);
                await sessions.SaveAsync(opened, cancellationToken);
                break;

            case DeviceEventType.Finished:
                var open = await sessions.GetOpenByDeviceAsync(message.DeviceId.ToString(), cancellationToken)
                    ?? throw new InvalidOperationException($"No hay sesión abierta para {message.DeviceId}");
                open.Close(message.OccurredAtDevice);
                await sessions.SaveAsync(open, cancellationToken);
                break;

            default:
                throw new NotSupportedException($"Tipo de evento no soportado: {message.Type}");
        }
    }
}

El catch de ese bucle es deliberado: un fallo procesando el evento de un dispositivo no puede tumbar el consumidor entero ni bloquear el procesamiento de los eventos de todos los demás. Se registra, se aísla, y el consumidor sigue vivo para el siguiente mensaje. Con un navegador, un fallo aislado normalmente lo ve un usuario y lo reintenta él mismo pulsando un botón; con un dispositivo de campo desatendido, si el consumidor se cae, nadie hay al otro lado para darse cuenta y volver a intentarlo salvo el propio reintento del firmware, que puede tardar.

Qué cambia realmente

La diferencia de fondo no es técnica, es de confianza en el origen del dato. Con un cliente web asumes que hay una persona que puede corregir un error de uso, cerrar sesión y volver a entrar, o simplemente esperar. Con un dispositivo de campo asumes lo contrario: que el evento puede llegar tarde, duplicado, o con una marca de tiempo que no coincide exactamente con la del servidor, y que nadie va a intervenir manualmente si algo se queda a medias. Diseñar el dominio con agregados que protegen sus propias invariantes, la idempotencia como parte del contrato del endpoint y no como un añadido posterior, y el procesamiento desacoplado de la recepción, no son elecciones de sobre-ingeniería: son la respuesta directa a que el productor del evento es un dispositivo de campo, no un formulario web con un usuario detrás pendiente del resultado. Es el mismo criterio que aplico en modernización de sistemas legacy cuando el sistema ya está en producción y no se puede permitir perder ni duplicar un evento.