dotnet-cqrs/bidirectional-communication-design.md
Mathias Beaulieu-Duncan 668f6be0d6 Add comprehensive event streaming implementation plan
- 6 phases covering workflow abstraction through production monitoring
- Detailed task breakdowns with checkboxes for tracking
- Progressive complexity: simple by default, powerful when needed
- Support for ephemeral & persistent streams
- Cross-service communication via RabbitMQ
- Schema evolution with automatic upcasting
- Exactly-once delivery and read receipts
- ~10+ weeks timeline with clear success criteria
2025-12-09 14:57:10 -05:00

38 KiB

Bidirectional Communication Design for CQRS Framework

This document outlines the design for adding persistent, selective event subscriptions to a CQRS framework with a Flutter frontend and .NET backend.

Table of Contents


Overview

The goal is to extend a CQRS framework to support:

  1. Command-correlated event subscriptions: When executing a command, optionally subscribe to related events
  2. Selective subscriptions: Choose which specific events to receive (not all-or-nothing)
  3. Persistent delivery: Events are stored and delivered even if the user is offline
  4. Catch-up on reconnect: Missed events are delivered when the user comes back online
  5. Per-usage flexibility: Same DataSource can be used with or without events depending on context
┌─────────────────────────────────────────────────────────────────┐
│                         DataSource                               │
│                                                                  │
│  ┌─────────────┐    ┌─────────────┐    ┌───────────────────┐   │
│  │   Query     │    │  Commands   │    │ Event Connection  │   │
│  │             │    │             │    │                   │   │
│  │ UsersQuery  │    │ InviteUser  │    │ subscribe(...)    │   │
│  │             │    │ RemoveUser  │    │ unsubscribe(...)  │   │
│  └─────────────┘    └─────────────┘    └───────────────────┘   │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Core Concepts

Concept Description
DataSource Combines a query, related commands, and optional event connection
EventConnection WebSocket connection that handles subscriptions and event delivery
Correlation ID Links a command to its resulting events
Persistent Subscription Server-stored subscription that survives disconnection
Terminal Event An event that completes/closes a subscription (e.g., InvitationAccepted)
Catch-up Delivering missed events when a client reconnects

Frontend Architecture (Flutter)

DataSource

The DataSource is the central abstraction combining query data, commands, and event subscriptions.

abstract class DataSource<TQuery, TData> {
  /// The query this datasource executes
  final TQuery query;

  /// Stream of query results
  Stream<TData> get dataStream;

  /// Current cached data
  TData? get currentData;

  /// Event connection (lazy - only created when needed)
  EventConnection? _eventConnection;

  /// Getter for event connection (for external listeners)
  EventConnection? get eventConnection => _eventConnection;

  /// Commands attached to this datasource
  final Map<Type, CommandConfig> _commands = {};

  DataSource(this.query);

  /// Register a command this datasource can execute
  void registerCommand<TCommand extends Command>(
    CommandConfig<TCommand> config,
  ) {
    _commands[TCommand] = config;
  }

  /// Execute a command with optional event subscription
  Future<TResult> execute<TCommand extends Command, TResult>(
    TCommand command, {
    EventSubscriptionConfig? subscribeToEvents,
  }) async {
    final result = await _commandSender.send<TResult>(command);

    // If caller wants events, set up subscription
    if (subscribeToEvents != null && subscribeToEvents.eventTypes.isNotEmpty) {
      await _ensureEventConnection();
      await _eventConnection!.subscribe(
        correlationId: result.correlationId,
        config: subscribeToEvents,
      );
    }

    return result;
  }

  /// Explicitly open event connection (triggers catch-up)
  Future<EventConnection> openEventConnection() async {
    _eventConnection ??= EventConnection(
      dataSourceId: id,
      userId: _authService.currentUserId,
    );
    await _eventConnection!.connect();
    return _eventConnection!;
  }

  /// Ensure connection exists
  Future<void> _ensureEventConnection() async {
    if (_eventConnection == null || !_eventConnection!.isConnected) {
      await openEventConnection();
    }
  }

  /// Cleanup resources
  Future<void> dispose() async {
    await _eventConnection?.disconnect();
  }
}

Event Subscription Config

Fine-grained control over what events to subscribe to for a given command execution.

/// Configuration for event subscriptions
class EventSubscriptionConfig {
  /// Which event types to receive
  final Set<Type> eventTypes;

  /// Auto-unsubscribe after this duration (null = no timeout)
  final Duration? timeout;

  /// Should the subscription survive app restart?
  final bool persistent;

  /// How should events be delivered?
  final EventDeliveryMode deliveryMode;

  const EventSubscriptionConfig({
    required this.eventTypes,
    this.timeout,
    this.persistent = true,
    this.deliveryMode = EventDeliveryMode.immediate,
  });

  /// Subscribe to all provided event types
  factory EventSubscriptionConfig.all(List<Type> types) {
    return EventSubscriptionConfig(
      eventTypes: types.toSet(),
      persistent: true,
    );
  }

  /// Subscribe to specific event types only
  factory EventSubscriptionConfig.only(List<Type> types) {
    return EventSubscriptionConfig(
      eventTypes: types.toSet(),
      persistent: true,
    );
  }

  /// No subscription (explicit opt-out)
  factory EventSubscriptionConfig.none() {
    return EventSubscriptionConfig(
      eventTypes: {},
      persistent: false,
    );
  }

  /// Transient subscription (not persisted, immediate delivery)
  factory EventSubscriptionConfig.transient(List<Type> types) {
    return EventSubscriptionConfig(
      eventTypes: types.toSet(),
      persistent: false,
      deliveryMode: EventDeliveryMode.immediate,
    );
  }
}

/// How events should be delivered to the client
enum EventDeliveryMode {
  /// Push immediately when event occurs
  immediate,

  /// Batch events and deliver periodically
  batched,

  /// Only deliver on reconnect (save bandwidth for background updates)
  onReconnect,
}

Event Connection

Manages the WebSocket connection, subscriptions, and event routing.

/// Manages WebSocket connection and event subscriptions
class EventConnection {
  final String dataSourceId;
  final String userId;

  WebSocketChannel? _channel;
  final _eventController = StreamController<CorrelatedEvent>.broadcast();
  final Map<String, EventSubscription> _activeSubscriptions = {};

  bool _isConnected = false;
  bool get isConnected => _isConnected;

  /// Stream of all events
  Stream<CorrelatedEvent> get events => _eventController.stream;

  /// Typed event stream - filters to specific event type
  Stream<T> on<T extends CorrelatedEvent>() {
    return events.whereType<T>();
  }

  /// Events for a specific correlation
  Stream<CorrelatedEvent> forCorrelation(String correlationId) {
    return events.where((e) => e.correlationId == correlationId);
  }

  EventConnection({
    required this.dataSourceId,
    required this.userId,
  });

  /// Connect to the event server
  Future<void> connect() async {
    if (_isConnected) return;

    _channel = WebSocketChannel.connect(
      Uri.parse('wss://api.example.com/events'),
    );

    // Authenticate the connection
    _send({
      'type': 'auth',
      'token': await _authService.getToken(),
      'dataSourceId': dataSourceId,
    });

    // Request catch-up for any existing persistent subscriptions
    _send({
      'type': 'catch_up',
      'subscriptionIds': _activeSubscriptions.keys.toList(),
    });

    // Listen for incoming messages
    _channel!.stream.listen(
      _handleMessage,
      onDone: _handleDisconnect,
      onError: _handleError,
    );

    _isConnected = true;
  }

  /// Subscribe to events for a correlation ID
  Future<EventSubscription> subscribe({
    required String correlationId,
    required EventSubscriptionConfig config,
  }) async {
    final subscription = EventSubscription(
      id: const Uuid().v4(),
      correlationId: correlationId,
      config: config,
    );

    _activeSubscriptions[subscription.id] = subscription;

    // Notify backend about this subscription
    _send({
      'type': 'subscribe',
      'subscriptionId': subscription.id,
      'correlationId': correlationId,
      'eventTypes': config.eventTypes.map((t) => t.toString()).toList(),
      'persistent': config.persistent,
      'timeout': config.timeout?.inSeconds,
      'deliveryMode': config.deliveryMode.name,
    });

    return subscription;
  }

  /// Unsubscribe from a subscription
  Future<void> unsubscribe(String subscriptionId) async {
    _activeSubscriptions.remove(subscriptionId);

    _send({
      'type': 'unsubscribe',
      'subscriptionId': subscriptionId,
    });
  }

  /// Disconnect from the server
  Future<void> disconnect() async {
    _isConnected = false;
    await _channel?.sink.close();
    _channel = null;
  }

  void _send(Map<String, dynamic> message) {
    _channel?.sink.add(jsonEncode(message));
  }

  void _handleMessage(dynamic message) {
    final data = jsonDecode(message as String) as Map<String, dynamic>;

    switch (data['type']) {
      case 'event':
        _handleEventMessage(data);
        break;

      case 'subscription_completed':
        _handleSubscriptionCompleted(data);
        break;

      case 'error':
        _handleErrorMessage(data);
        break;
    }
  }

  void _handleEventMessage(Map<String, dynamic> data) {
    final event = _deserializeEvent(data);
    _eventController.add(event);

    // Notify specific subscription handler if registered
    final subscription = _activeSubscriptions[data['subscriptionId']];
    subscription?.onEvent?.call(event);
  }

  void _handleSubscriptionCompleted(Map<String, dynamic> data) {
    final subscriptionId = data['subscriptionId'] as String;
    final subscription = _activeSubscriptions.remove(subscriptionId);
    subscription?.onCompleted?.call(data['reason'] as String);
  }

  void _handleDisconnect() {
    _isConnected = false;
    // Implement reconnection logic here
  }

  void _handleError(Object error) {
    // Handle connection errors
  }

  void _handleErrorMessage(Map<String, dynamic> data) {
    // Handle protocol-level errors
  }

  CorrelatedEvent _deserializeEvent(Map<String, dynamic> data) {
    // Deserialize based on eventType
    // Implementation depends on your serialization strategy
    throw UnimplementedError();
  }
}

/// Represents an active subscription
class EventSubscription {
  final String id;
  final String correlationId;
  final EventSubscriptionConfig config;

  /// Callback when an event is received
  void Function(CorrelatedEvent)? onEvent;

  /// Callback when subscription is completed (terminal event or timeout)
  void Function(String reason)? onCompleted;

  EventSubscription({
    required this.id,
    required this.correlationId,
    required this.config,
  });
}

Event Base Classes

/// Base class for all events that can be correlated to a command
abstract class CorrelatedEvent {
  final String correlationId;
  final String eventId;
  final DateTime occurredAt;

  CorrelatedEvent({
    required this.correlationId,
    required this.eventId,
    required this.occurredAt,
  });
}

/// Example: User invitation events
abstract class UserInvitationEvent extends CorrelatedEvent {
  UserInvitationEvent({
    required super.correlationId,
    required super.eventId,
    required super.occurredAt,
  });
}

class UserInvitationSentEvent extends UserInvitationEvent {
  final String email;

  UserInvitationSentEvent({
    required this.email,
    required super.correlationId,
    required super.eventId,
    required super.occurredAt,
  });
}

class UserInvitationAcceptedEvent extends UserInvitationEvent {
  final String userId;
  final String email;

  UserInvitationAcceptedEvent({
    required this.userId,
    required this.email,
    required super.correlationId,
    required super.eventId,
    required super.occurredAt,
  });
}

class UserInvitationDeclinedEvent extends UserInvitationEvent {
  final String email;
  final String? reason;

  UserInvitationDeclinedEvent({
    required this.email,
    this.reason,
    required super.correlationId,
    required super.eventId,
    required super.occurredAt,
  });
}

Usage Examples

Example 1: Admin Dashboard (wants all events)

class AdminDashboardPage extends StatefulWidget {
  @override
  State<AdminDashboardPage> createState() => _AdminDashboardPageState();
}

class _AdminDashboardPageState extends State<AdminDashboardPage> {
  late final UsersDataSource _usersDataSource;

  @override
  void initState() {
    super.initState();
    _usersDataSource = UsersDataSource(UsersQuery());

    // Open event connection - triggers catch-up for pending events
    _usersDataSource.openEventConnection();

    // Listen to all invitation events for toast notifications
    _usersDataSource.eventConnection?.on<UserInvitationEvent>().listen(_showEventToast);
  }

  void _showEventToast(UserInvitationEvent event) {
    final message = switch (event) {
      UserInvitationSentEvent e => 'Invitation sent to ${e.email}',
      UserInvitationAcceptedEvent e => '${e.email} has joined!',
      UserInvitationDeclinedEvent e => '${e.email} declined the invitation',
      _ => 'Unknown event',
    };

    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(message)),
    );

    // Refresh list if someone joined
    if (event is UserInvitationAcceptedEvent) {
      _usersDataSource.refresh();
    }
  }

  Future<void> _inviteUser(String email) async {
    await _usersDataSource.inviteUser(
      email,
      // Subscribe to ALL invitation events
      subscribeToEvents: {
        UserInvitationSentEvent,
        UserInvitationAcceptedEvent,
        UserInvitationDeclinedEvent,
      },
    );
  }

  @override
  void dispose() {
    _usersDataSource.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<List<User>>(
      stream: _usersDataSource.dataStream,
      builder: (context, snapshot) {
        // Build UI...
      },
    );
  }
}

Example 2: Simple User List (no events needed)

class UserListPage extends StatefulWidget {
  @override
  State<UserListPage> createState() => _UserListPageState();
}

class _UserListPageState extends State<UserListPage> {
  late final UsersDataSource _usersDataSource;

  @override
  void initState() {
    super.initState();
    _usersDataSource = UsersDataSource(UsersQuery());
    // NOT opening event connection - no real-time updates needed
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<List<User>>(
      stream: _usersDataSource.dataStream,
      builder: (context, snapshot) {
        if (!snapshot.hasData) {
          return const CircularProgressIndicator();
        }

        return ListView.builder(
          itemCount: snapshot.data!.length,
          itemBuilder: (context, index) {
            final user = snapshot.data![index];
            return ListTile(title: Text(user.name));
          },
        );
      },
    );
  }

  @override
  void dispose() {
    _usersDataSource.dispose();
    super.dispose();
  }
}

Example 3: Invite Dialog (only wants sent confirmation)

class InviteUserDialog extends StatefulWidget {
  final UsersDataSource dataSource;

  const InviteUserDialog({required this.dataSource});

  @override
  State<InviteUserDialog> createState() => _InviteUserDialogState();
}

class _InviteUserDialogState extends State<InviteUserDialog> {
  final _emailController = TextEditingController();
  bool _isSending = false;

  Future<void> _sendInvite() async {
    setState(() => _isSending = true);

    try {
      await widget.dataSource.inviteUser(
        _emailController.text,
        // Only care about the "sent" confirmation
        subscribeToEvents: {UserInvitationSentEvent},
        onEvent: (event) {
          if (event is UserInvitationSentEvent) {
            Navigator.of(context).pop();
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(content: Text('Invitation sent!')),
            );
          }
        },
      );
    } finally {
      setState(() => _isSending = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      title: const Text('Invite User'),
      content: TextField(
        controller: _emailController,
        decoration: const InputDecoration(labelText: 'Email'),
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.of(context).pop(),
          child: const Text('Cancel'),
        ),
        ElevatedButton(
          onPressed: _isSending ? null : _sendInvite,
          child: _isSending
              ? const CircularProgressIndicator()
              : const Text('Send'),
        ),
      ],
    );
  }
}

Example 4: Concrete UsersDataSource Implementation

class UsersDataSource extends DataSource<UsersQuery, List<User>> {
  UsersDataSource(UsersQuery query) : super(query);

  /// Invite a user with optional event subscription
  Future<InviteResult> inviteUser(
    String email, {
    Set<Type>? subscribeToEvents,
    void Function(UserInvitationEvent)? onEvent,
  }) async {
    // Build subscription config if events requested
    EventSubscriptionConfig? config;
    if (subscribeToEvents != null && subscribeToEvents.isNotEmpty) {
      config = EventSubscriptionConfig(
        eventTypes: subscribeToEvents,
        persistent: true,
      );
    }

    // Execute the command
    final result = await execute<InviteUserCommand, InviteResult>(
      InviteUserCommand(email: email),
      subscribeToEvents: config,
    );

    // Wire up event handler if provided
    if (config != null && onEvent != null) {
      eventConnection?.events
          .where((e) => e.correlationId == result.correlationId)
          .whereType<UserInvitationEvent>()
          .listen(onEvent);
    }

    return result;
  }

  /// Remove a user
  Future<void> removeUser(String userId) async {
    await execute<RemoveUserCommand, void>(
      RemoveUserCommand(userId: userId),
      // No events for this command
    );
  }
}

Backend Architecture (.NET)

Protocol Messages

Client to Server

Subscribe

{
  "type": "subscribe",
  "subscriptionId": "sub-123-abc",
  "correlationId": "invite-456-def",
  "eventTypes": ["UserInvitationSentEvent", "UserInvitationAcceptedEvent"],
  "persistent": true,
  "timeout": null,
  "deliveryMode": "immediate"
}

Unsubscribe

{
  "type": "unsubscribe",
  "subscriptionId": "sub-123-abc"
}

Catch-up Request

{
  "type": "catch_up",
  "subscriptionIds": ["sub-123-abc", "sub-789-xyz"]
}

Authentication

{
  "type": "auth",
  "token": "jwt-token-here",
  "dataSourceId": "users-datasource"
}

Server to Client

Event Delivery

{
  "type": "event",
  "subscriptionId": "sub-123-abc",
  "correlationId": "invite-456-def",
  "eventType": "UserInvitationAcceptedEvent",
  "eventId": "evt-001",
  "sequence": 42,
  "payload": {
    "userId": "user-789",
    "email": "john@example.com"
  },
  "occurredAt": "2025-01-15T10:30:00Z"
}

Subscription Completed

{
  "type": "subscription_completed",
  "subscriptionId": "sub-123-abc",
  "reason": "terminal_event",
  "terminalEvent": "UserInvitationAcceptedEvent"
}

Error

{
  "type": "error",
  "code": "subscription_not_found",
  "message": "Subscription sub-123-abc not found",
  "subscriptionId": "sub-123-abc"
}

Event Filtering and Delivery

public class EventDeliveryService
{
    private readonly ISubscriptionStore _subscriptionStore;
    private readonly IConnectionTracker _connectionTracker;
    private readonly IClientNotifier _notifier;
    private readonly IEventStore _eventStore;

    public async Task DeliverEventAsync(ICorrelatedEvent @event)
    {
        // Store the event with sequence number
        var sequence = await _eventStore.AppendAsync(@event);

        // Find all subscriptions interested in this correlation
        var subscriptions = await _subscriptionStore
            .FindByCorrelationIdAsync(@event.CorrelationId);

        foreach (var subscription in subscriptions)
        {
            await DeliverToSubscriptionAsync(subscription, @event, sequence);
        }
    }

    private async Task DeliverToSubscriptionAsync(
        PersistentSubscription subscription,
        ICorrelatedEvent @event,
        long sequence)
    {
        // FILTER: Only deliver if subscriber requested this event type
        var eventTypeName = @event.GetType().Name;
        if (!subscription.EventTypes.Contains(eventTypeName))
        {
            // Subscriber didn't ask for this type - skip
            return;
        }

        // Check delivery mode
        if (subscription.DeliveryMode == DeliveryMode.OnReconnect)
        {
            // Don't deliver now, will be caught up on reconnect
            return;
        }

        // Check if subscriber is online
        var connection = _connectionTracker.GetConnection(subscription.SubscriberId);

        if (connection != null)
        {
            // Deliver immediately
            await _notifier.SendAsync(connection.Id, new EventMessage
            {
                Type = "event",
                SubscriptionId = subscription.Id.ToString(),
                CorrelationId = @event.CorrelationId,
                EventType = eventTypeName,
                EventId = @event.EventId.ToString(),
                Sequence = sequence,
                Payload = @event,
                OccurredAt = @event.OccurredAt,
            });

            subscription.MarkDelivered(sequence);
        }
        // If offline, event stays in store for catch-up

        // Check if this is a terminal event
        if (subscription.TerminalEventTypes.Contains(eventTypeName))
        {
            subscription.Complete();

            if (connection != null)
            {
                await _notifier.SendAsync(connection.Id, new SubscriptionCompletedMessage
                {
                    Type = "subscription_completed",
                    SubscriptionId = subscription.Id.ToString(),
                    Reason = "terminal_event",
                    TerminalEvent = eventTypeName,
                });
            }
        }

        await _subscriptionStore.UpdateAsync(subscription);
    }

    public async Task HandleCatchUpRequestAsync(
        string connectionId,
        string userId,
        IEnumerable<string> subscriptionIds)
    {
        // Get all active subscriptions for this user
        var subscriptions = await _subscriptionStore.GetByUserIdAsync(userId);

        foreach (var subscription in subscriptions)
        {
            if (subscription.Status != SubscriptionStatus.Active)
                continue;

            // Get events after the last delivered sequence
            var missedEvents = await _eventStore.GetEventsAsync(
                correlationId: subscription.CorrelationId,
                afterSequence: subscription.LastDeliveredSequence,
                eventTypes: subscription.EventTypes
            );

            foreach (var evt in missedEvents.OrderBy(e => e.Sequence))
            {
                await _notifier.SendAsync(connectionId, new EventMessage
                {
                    Type = "event",
                    SubscriptionId = subscription.Id.ToString(),
                    CorrelationId = evt.CorrelationId,
                    EventType = evt.GetType().Name,
                    EventId = evt.EventId.ToString(),
                    Sequence = evt.Sequence,
                    Payload = evt,
                    OccurredAt = evt.OccurredAt,
                });

                subscription.MarkDelivered(evt.Sequence);

                // Check for terminal event
                if (subscription.TerminalEventTypes.Contains(evt.GetType().Name))
                {
                    subscription.Complete();

                    await _notifier.SendAsync(connectionId, new SubscriptionCompletedMessage
                    {
                        Type = "subscription_completed",
                        SubscriptionId = subscription.Id.ToString(),
                        Reason = "terminal_event",
                        TerminalEvent = evt.GetType().Name,
                    });

                    break;
                }
            }

            await _subscriptionStore.UpdateAsync(subscription);
        }
    }
}

Persistent Subscription Storage

public class PersistentSubscription
{
    public Guid Id { get; init; }
    public Guid SubscriberId { get; init; }
    public string CorrelationId { get; init; } = string.Empty;

    /// <summary>
    /// Event types the subscriber wants to receive
    /// </summary>
    public HashSet<string> EventTypes { get; init; } = new();

    /// <summary>
    /// Events that complete/close the subscription
    /// </summary>
    public HashSet<string> TerminalEventTypes { get; init; } = new();

    public DeliveryMode DeliveryMode { get; init; } = DeliveryMode.Immediate;

    public DateTimeOffset CreatedAt { get; init; }
    public DateTimeOffset? ExpiresAt { get; init; }
    public DateTimeOffset? CompletedAt { get; private set; }

    /// <summary>
    /// Last successfully delivered event sequence (for catch-up)
    /// </summary>
    public long LastDeliveredSequence { get; private set; }

    public SubscriptionStatus Status { get; private set; } = SubscriptionStatus.Active;

    public void MarkDelivered(long sequence)
    {
        if (sequence > LastDeliveredSequence)
        {
            LastDeliveredSequence = sequence;
        }
    }

    public void Complete()
    {
        Status = SubscriptionStatus.Completed;
        CompletedAt = DateTimeOffset.UtcNow;
    }

    public void Cancel()
    {
        Status = SubscriptionStatus.Cancelled;
        CompletedAt = DateTimeOffset.UtcNow;
    }

    public bool IsExpired => ExpiresAt.HasValue && DateTimeOffset.UtcNow > ExpiresAt.Value;
}

public enum SubscriptionStatus
{
    Active,
    Completed,   // Terminal event received
    Expired,     // TTL reached
    Cancelled    // User cancelled
}

public enum DeliveryMode
{
    Immediate,
    Batched,
    OnReconnect
}

public interface ISubscriptionStore
{
    Task<PersistentSubscription> CreateAsync(PersistentSubscription subscription);
    Task<PersistentSubscription?> GetByIdAsync(Guid id);
    Task<IReadOnlyList<PersistentSubscription>> GetByUserIdAsync(Guid userId);
    Task<IReadOnlyList<PersistentSubscription>> FindByCorrelationIdAsync(string correlationId);
    Task UpdateAsync(PersistentSubscription subscription);
    Task DeleteAsync(Guid id);
}

Command Handler Integration

/// <summary>
/// Pipeline behavior that creates subscriptions for commands that request them
/// </summary>
public class CommandSubscriptionBehavior<TCommand, TResult>
    : IPipelineBehavior<TCommand, TResult>
    where TCommand : ICommand<TResult>
{
    private readonly ISubscriptionStore _subscriptions;
    private readonly ICurrentUserService _currentUser;

    public async Task<TResult> Handle(
        TCommand command,
        RequestHandlerDelegate<TResult> next,
        CancellationToken cancellationToken)
    {
        // Execute the command first
        var result = await next();

        // Check if this command has subscription metadata
        if (command is ICommandWithSubscription<TResult> subCommand
            && subCommand.SubscriptionConfig != null
            && result is ICorrelatedResult correlatedResult)
        {
            var config = subCommand.SubscriptionConfig;

            await _subscriptions.CreateAsync(new PersistentSubscription
            {
                Id = Guid.NewGuid(),
                SubscriberId = _currentUser.UserId,
                CorrelationId = correlatedResult.CorrelationId,
                EventTypes = config.EventTypes.ToHashSet(),
                TerminalEventTypes = config.TerminalEventTypes.ToHashSet(),
                DeliveryMode = config.DeliveryMode,
                CreatedAt = DateTimeOffset.UtcNow,
                ExpiresAt = config.Timeout.HasValue
                    ? DateTimeOffset.UtcNow + config.Timeout.Value
                    : null,
            });
        }

        return result;
    }
}

Flow Diagrams

Command Execution with Subscription

┌────────┐     ┌─────────────┐     ┌──────────────┐     ┌────────────┐
│ Client │     │  DataSource │     │   Backend    │     │ Subscription│
│        │     │             │     │              │     │   Store     │
└───┬────┘     └──────┬──────┘     └──────┬───────┘     └─────┬──────┘
    │                 │                   │                   │
    │  inviteUser()   │                   │                   │
    │  + eventTypes   │                   │                   │
    │────────────────>│                   │                   │
    │                 │                   │                   │
    │                 │ POST /command     │                   │
    │                 │ {InviteUser}      │                   │
    │                 │──────────────────>│                   │
    │                 │                   │                   │
    │                 │                   │ Create            │
    │                 │                   │ Subscription      │
    │                 │                   │──────────────────>│
    │                 │                   │                   │
    │                 │  {correlationId}  │                   │
    │                 │<──────────────────│                   │
    │                 │                   │                   │
    │                 │ WS: subscribe     │                   │
    │                 │ {correlationId,   │                   │
    │                 │  eventTypes}      │                   │
    │                 │──────────────────>│                   │
    │                 │                   │                   │
    │  InviteResult   │                   │                   │
    │<────────────────│                   │                   │
    │                 │                   │                   │

Event Delivery (Online)

┌──────────┐     ┌──────────────┐     ┌────────────┐     ┌────────┐
│  Domain  │     │   Event      │     │ Subscription│     │ Client │
│  Event   │     │  Dispatcher  │     │   Store     │     │        │
└────┬─────┘     └──────┬───────┘     └─────┬──────┘     └───┬────┘
     │                  │                   │                │
     │ UserInvitation   │                   │                │
     │ AcceptedEvent    │                   │                │
     │─────────────────>│                   │                │
     │                  │                   │                │
     │                  │ Find subscriptions│                │
     │                  │ by correlationId  │                │
     │                  │──────────────────>│                │
     │                  │                   │                │
     │                  │ [sub-123]         │                │
     │                  │<──────────────────│                │
     │                  │                   │                │
     │                  │ Check: eventType  │                │
     │                  │ in sub.eventTypes?│                │
     │                  │                   │                │
     │                  │ WS: event         │                │
     │                  │──────────────────────────────────>│
     │                  │                   │                │
     │                  │ (terminal event)  │                │
     │                  │ WS: subscription  │                │
     │                  │     _completed    │                │
     │                  │──────────────────────────────────>│
     │                  │                   │                │

Catch-up on Reconnect

┌────────┐     ┌──────────────┐     ┌────────────┐     ┌───────────┐
│ Client │     │   Backend    │     │ Subscription│     │  Event    │
│        │     │              │     │   Store     │     │  Store    │
└───┬────┘     └──────┬───────┘     └─────┬──────┘     └─────┬─────┘
    │                 │                   │                  │
    │ WS: connect     │                   │                  │
    │ + auth          │                   │                  │
    │────────────────>│                   │                  │
    │                 │                   │                  │
    │ WS: catch_up    │                   │                  │
    │────────────────>│                   │                  │
    │                 │                   │                  │
    │                 │ Get active subs   │                  │
    │                 │ for userId        │                  │
    │                 │──────────────────>│                  │
    │                 │                   │                  │
    │                 │ [sub-123,         │                  │
    │                 │  sub-456]         │                  │
    │                 │<──────────────────│                  │
    │                 │                   │                  │
    │                 │ For each sub:     │                  │
    │                 │ Get events after  │                  │
    │                 │ lastDeliveredSeq  │                  │
    │                 │─────────────────────────────────────>│
    │                 │                   │                  │
    │                 │ [evt-1, evt-2]    │                  │
    │                 │<─────────────────────────────────────│
    │                 │                   │                  │
    │ WS: event       │                   │                  │
    │ (evt-1)         │                   │                  │
    │<────────────────│                   │                  │
    │                 │                   │                  │
    │ WS: event       │                   │                  │
    │ (evt-2)         │                   │                  │
    │<────────────────│                   │                  │
    │                 │                   │                  │

Considerations

Cleanup and Maintenance

  • Run a background job to expire old subscriptions
  • Archive or delete events older than a retention period
  • Clean up completed/cancelled subscriptions after a grace period

Scaling

  • For multiple server instances, use a distributed connection tracker (Redis)
  • Consider using SignalR with Azure SignalR Service or Redis backplane
  • Event store should support efficient queries by correlationId + sequence

Ordering Guarantees

  • Events within a correlation are ordered by sequence number
  • Catch-up delivery must respect sequence ordering
  • Consider idempotency keys for duplicate detection on the client

Client-Side Deduplication

class EventConnection {
  final Set<String> _processedEventIds = {};

  void _handleEventMessage(Map<String, dynamic> data) {
    final eventId = data['eventId'] as String;

    // Skip if already processed (duplicate delivery)
    if (_processedEventIds.contains(eventId)) {
      return;
    }

    _processedEventIds.add(eventId);

    // Process the event...
  }
}

Error Handling

  • Handle WebSocket disconnections with exponential backoff reconnection
  • Queue outgoing messages during disconnection
  • Validate subscription requests on the backend (authorization, rate limiting)

Security

  • Validate that users can only subscribe to correlations they own
  • Authenticate WebSocket connections with JWT tokens
  • Rate limit subscription creation to prevent abuse