Messaging

Juice v9 unifies all message types under a single IMessage hierarchy and introduces a MessagingBuilder entry point that composes the outbox, delivery, idempotency, and broker layers with a single services.AddMessaging() call.

IMessage Hierarchy

Every message type in Juice v9 — MediatR commands, domain events, and integration events — implements IMessage, giving them a shared identity (MessageId, CreatedAt, TenantId).

IMessage
├── MessageId : Guid
├── CreatedAt : DateTimeOffset
└── TenantId  : string?

IEvent : IMessage
└── EventName : string

IIntegrationEvent : IEvent          ← required only on the CONSUMING side
└── (marker — enables dispatcher routing)

MessageBase (abstract record) : IMessage
└── MessageId = Guid.NewGuid(), CreatedAt = UtcNow (defaults)

IntegrationEvent (abstract record) : MessageBase, IIntegrationEvent
└── EventName → GetType().Name (default)    ← optional to customizing the routing key on the PRODUCER side

Publish vs. Consume roles

RoleType requiredReason
Publishing (outbox staging)Any IMessageOutbox accepts any message including plain domain events
Consuming (broker dispatch)Must implement IIntegrationEventIntegrationEventDispatcher uses EventName + type registry to deserialize and route from raw bytes

Key insight: A plain domain event (IMessage) can be published and delivered through the outbox without ever implementing IIntegrationEvent if you do not want to customizing the event name. Only services that need to receive it via broker infrastructure must use IIntegrationEvent.


Setup Guides

Choose the guide that matches your service’s needs. Each is independently followable.

GuideWhat it sets up
MediatorMediatR Request/Response, Notification, and Stream Request patterns with handler implementation
Outbox SetupTransactional staging of any IMessage inside a MediatR TransactionBehavior
Delivery SetupBackground DeliveryHostedService that forwards staged messages to the broker
Consumption SetupRabbitMQ consumer engine, IIntegrationEventHandler<T> dispatch, and automatic idempotency
Full SetupAll four sub-builders combined in one AddMessaging() call, with migration table

Internal Messaging Setup

Use this setup when your service only needs in-process MediatR command deduplication — no external broker, no outbox table, no delivery worker.

When to use

  • Service handles IIdempotentRequest commands from an HTTP API or message queue handler
  • No need to publish events to other services from this registration
  • You want idempotent command handling without any infrastructure dependencies

Prerequisites

None. This is the simplest, self-contained setup.

NuGet packages

PackagePurpose
Juice.MessagingMessagingBuilder entry point, IMessage hierarchy
Juice.MediatR.BehaviorsIdempotencyRequestBehavior<,>, TransactionBehavior<,,>
Juice.MediatR.ContractsIIdempotentRequest interface
Juice.Messaging.Idempotency.RedisRedis idempotency backend (recommended for production)
Juice.Messaging.Idempotency.CachingIn-memory or distributed cache backend
Juice.Messaging.Idempotency.EFEF idempotency backend (auditable / SQL-required)

DI Registration

services.AddMessaging(builder =>
{
    builder.AddIdempotencyRedis(opts =>
    {
        opts.Configuration = configuration.GetConnectionString("Redis");
    });
});

services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(MyHandler).Assembly);
    cfg.AddIdempotencyRequestBehavior();
});

IIdempotentRequest (replaces IRequestManager)

IIdempotentRequest is the v9 replacement for the v8.5.0 IRequestManager + IIdentifiedCommand<T> pattern. Mark your command with IIdempotentRequest and the IdempotencyBehavior handles deduplication automatically.

// In Juice.MediatR.Contracts (package: Juice.MediatR.Contracts)
public interface IIdempotentRequest : IBaseRequest
{
    // Caller-supplied unique key — generated by the sender, not the server
    string IdempotencyKey { get; }
}

Usage example — mark your command directly:

public record CreateOrderCommand(string OrderId, string CustomerId)
    : IRequest<IOperationResult>, IIdempotentRequest
{
    // IdempotencyKey is the caller-supplied unique identifier
    // The framework calls IIdempotencyService automatically — no handler changes needed
    public string IdempotencyKey => OrderId;
}

v8.5.0 → v9 comparison:

v8.5.0v9
Wrap command: new IdentifiedCommand<T>(cmd, id)Mark command directly with IIdempotentRequest
Inject IRequestManager into handlerNo injection needed
Call requestManager.ExistAsync(id) manuallyIdempotencyBehavior calls IIdempotencyService automatically
Register IIdentifiedCommand<T> handlerRegister your command handler directly

IdempotencyBehavior Pipeline

IdempotencyRequestBehavior<TRequest, TResponse> intercepts any IRequest<T> that also implements IIdempotentRequest. It runs at pipeline order int.MinValue — before all other behaviors — so duplicate commands are rejected before any handler logic executes.

How it works:

  1. Behavior checks IIdempotencyService.TryCreateRequestAsync(key, typeName)
  2. If the key already exists → returns the cached result immediately (no handler invoked)
  3. If the key is new → proceeds through the pipeline, then stores the result

Shared registration: One IIdempotencyService registration serves both IdempotencyBehavior (MediatR pipeline) and IntegrationEventDispatcher (consumer side). No separate registration is needed for each.


Idempotency Backend Selection

BackendRegistrationPackageUse case
In-memorybuilder.AddIdempotencyInMemory()Juice.Messaging.Idempotency.CachingTests and development only
Distributed cachebuilder.AddIdempotencyDistributedCache()Juice.Messaging.Idempotency.CachingSimple, non-critical workloads
Redisbuilder.AddIdempotencyRedis(opts => { ... })Juice.Messaging.Idempotency.RedisProduction (recommended)
EFbuilder.AddIdempotencyEF(config, opts => { ... })Juice.Messaging.Idempotency.EFAuditable / SQL Server required

See also