Skip to content
fullstackhero

Reference

Chat module

Slack-style messaging — named channels, 1:1 + group DMs, @mentions, thread replies, reactions, message pinning, full-text search, typing indicators, and SignalR realtime over Valkey.

views 0 Last updated

The Chat module turns fullstackhero into a working messaging app. Named channels (Slack-style), 1:1 direct messages (idempotent via a sorted key), group DMs, file attachments via the Files module, @mentions that publish integration events to Notifications, thread replies, emoji reactions, message pinning, full-text search, typing indicators with a Valkey-backed throttle, and SignalR realtime over a Valkey backplane (Valkey is a Redis-compatible, BSD-licensed Redis fork). Around 3,400 lines of code across the runtime project, plus contracts. The most feature-dense module in v10.

What ships in v10

  • Three channel kindsChannel (named, Slack-style), DirectMessage (1:1, idempotent via sorted DirectKey), GroupMessage (3+ users).
  • Channel membership with two roles — Admin, Member.
  • Lossless archive + restoreDELETE /channels/{id} archives (soft-deletes) a channel by flipping the flag instead of an EF Remove(), so ChannelMember rows survive; POST /channels/{id}/restore brings it back with memberships intact.
  • Messages with attachments, mentions, reactions; soft-deletable to a tombstone ([deleted]) so threads stay coherent.
  • Thread replies via ParentMessageId; ReplyCount tracked on the parent for UI badges.
  • Pinning — at most one pin per pin call, idempotent; IsPinned + PinnedByUserId + PinnedAtUtc on the message.
  • Emoji reactions with a unique (MessageId, UserId, Emoji) constraint preventing duplicates.
  • @mentions parsed server-side by IMentionResolver; each resolved user is published as a MentionedInChannelIntegrationEvent for the Notifications module to consume.
  • Full-text search across messages via PostgreSQL tsvector and a GIN index on a generated BodyTsv column.
  • Typing indicator with a 3-second per (channel, user) throttle in distributed cache.
  • SignalR realtime over a Valkey backplane (AppHub in BuildingBlocks/Web/Realtime); on connect every user joins user:{id}, tenant:{tenantId}, and every channel they belong to as channel:{id}. Channels created or joined after connect are joined on demand via the hub’s JoinChannel method.
  • Presence — an in-memory IPresenceTracker broadcasts PresenceChanged to the tenant group on first connect / last disconnect; GET /api/v1/realtime/presence?userIds= serves the initial snapshot.
  • 22 endpoints under /api/v1/chat/... plus the realtime hub at /api/v1/realtime/hub.
  • File attachments through the Files module’s ChatChannelFileAccessPolicy (channel members can attach and read; only the uploader can delete).
  • 7 permissionsChat.Channels View / Create / ManageAll and Chat.Messages Send / EditOwn / DeleteOwn / DeleteAny.

Architecture at a glance

src/Modules/Chat/
├── Modules.Chat/ ~3,400 LoC
│ ├── ChatModule.cs IModule — order 800 (after Notifications 750)
│ ├── Domain/
│ │ ├── ChatChannel.cs AggregateRoot, ISoftDeletable (archive/restore)
│ │ ├── ChannelMember.cs Membership row, Admin | Member
│ │ ├── Message.cs AggregateRoot, tombstone delete
│ │ ├── MessageAttachment.cs Owned by Message
│ │ ├── MessageMention.cs Owned, position-tracked
│ │ └── MessageReaction.cs Owned, unique (msg, user, emoji)
│ ├── Data/ChatDbContext.cs Schema: chat (BodyTsv tsvector + GIN via raw migration)
│ ├── Services/
│ │ ├── MentionParser.cs @username regex extraction
│ │ ├── MentionResolver.cs username → user-id resolution
│ │ ├── ChannelMembershipChecker.cs AppHub uses this on Typing()/JoinChannel()
│ │ └── UserChannelLookup.cs Hub group join list on connect
│ ├── Authorization/
│ │ └── ChatChannelFileAccessPolicy.cs IFileAccessPolicy (OwnerType=ChatChannel)
│ └── Features/v1/ 22 endpoints in 4 areas
└── Modules.Chat.Contracts/ Commands, queries, integration events

The module loads at order 800, after Notifications (750) — the consumer of Chat’s mention events sits at a lower order than the producer. (Handler discovery itself is order-independent: integration-event handlers are assembly-scanned into DI at startup; the ordering keeps the dependency direction obvious.)

The channel + message aggregates

src/Modules/Chat/Modules.Chat/Domain/ChatChannel.cs
public sealed class ChatChannel : AggregateRoot<Guid>, ISoftDeletable
{
public ChannelType Type { get; private set; } // Channel | DirectMessage | GroupMessage
public string? DirectKey { get; private set; } // sorted "{userA}:{userB}" for DMs
public bool IsPrivate { get; private set; }
private readonly List<ChannelMember> _members = [];
public IReadOnlyList<ChannelMember> Members => _members;
public static ChatChannel CreateDirect(string userAId, string userBId)
{
var (lo, hi) = string.CompareOrdinal(userAId, userBId) < 0
? (userAId, userBId) : (userBId, userAId);
var c = new ChatChannel
{
Id = Guid.CreateVersion7(),
Type = ChannelType.DirectMessage,
IsPrivate = true,
DirectKey = $"{lo}:{hi}",
CreatedByUserId = userAId,
CreatedAtUtc = DateTime.UtcNow,
};
c._members.Add(ChannelMember.Create(c.Id, userAId, ChannelMemberRole.Member));
c._members.Add(ChannelMember.Create(c.Id, userBId, ChannelMemberRole.Member));
c.AddDomainEvent(DomainEvent.Create((id, ts) =>
new ChannelCreatedDomainEvent(c.Id, c.Type, null, userAId, id, ts)));
return c;
}
}

DirectKey is the idempotency trick: a unique index on DirectKey makes “find or create a DM between A and B” a single round trip with no race window. (User ids are strings throughout Chat — they come straight off the JWT.)

Archiving is an explicit state flip, not an EF Remove() — removing the aggregate would cascade-delete the ChannelMember rows, so a later restore would come back memberless. Archive(deletedByUserId) just sets the soft-delete flag and Restore() clears it, which is what makes channel restore lossless.

Message is similar but does not implement ISoftDeletable — deleted messages are tombstones so reply chains keep working:

public void SoftDelete(string deletingUserId, bool isModerator)
{
if (DeletedAtUtc.HasValue) return;
if (!isModerator && !string.Equals(AuthorUserId, deletingUserId, StringComparison.Ordinal))
{
throw new InvalidOperationException("Only the author or a moderator can delete.");
}
DeletedAtUtc = DateTime.UtcNow;
Body = null; // UI renders "[deleted]"
AddDomainEvent(DomainEvent.Create((id, ts) =>
new MessageDeletedDomainEvent(ChannelId, Id, AuthorUserId, id, ts)));
}

Realtime

The kit’s AppHub is in BuildingBlocks/Web/Realtime/AppHub.cs, mapped at /api/v1/realtime/hub. On connect it joins the user to:

  • user:{userId} — for personal events (notification pushes, channel-added)
  • tenant:{tenantId} — scopes presence broadcasts to one tenant
  • channel:{channelId} for every channel the user belongs to at connect time

The hub exposes two client-callable methods. Typing(channelId) broadcasts a typing indicator, throttled to once per 3 s per (channel, user) via the distributed cache:

public async Task Typing(Guid channelId)
{
var userId = GetUserId();
if (string.IsNullOrEmpty(userId)) return;
if (!await _membership.IsMemberAsync(channelId, userId, Context.ConnectionAborted).ConfigureAwait(false)) return;
var key = $"typing:{channelId}:{userId}";
if (!string.IsNullOrEmpty(await _cache.GetStringAsync(key, ...).ConfigureAwait(false))) return;
await _cache.SetStringAsync(key, "1",
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TypingThrottle }, ...);
await Clients.OthersInGroup($"channel:{channelId}")
.SendAsync("ChatTypingStarted", new { channelId, userId }, Context.ConnectionAborted);
}

JoinChannel(channelId) joins the channel:{id} group on demand, gated by the same membership check. Connect-time pre-join only covers channels that existed (and that the user was a member of) when the socket opened — a DM created or a membership granted after connect would never receive broadcasts until a page reload. Clients call JoinChannel when they open a conversation and on reconnect; re-joining is a no-op.

The throttle prevents the chatty per-keystroke pattern from saturating channels. Other realtime events (ChatMessageCreated, ChatMessageEdited, ChatMessageDeleted, ChatMessagePinned/ChatMessageUnpinned, ChatReactionChanged, ChatChannelAdded/ChatChannelRemoved, ChatChannelMemberAdded/ChatChannelMemberRemoved, ChatChannelRead, NotificationCreated, PresenceChanged) are broadcast from handlers post-SaveChanges.

Public API

Full surface in Modules.Chat.Contracts/. Highlights:

Channels

CreateChannelCommand(name, description, isPrivate), UpdateChannelCommand, ArchiveChannelCommand, RestoreChannelCommand, AddChannelMembersCommand, RemoveChannelMemberCommand, MarkChannelReadCommand(channelId, messageId) (read watermark up to a message), and FindOrCreateDmCommand(userIds) → channel id. One other user id finds-or-creates the 1:1 DM via DirectKey; two or more creates a fresh group DM. Queries: ListMyChannelsQuery, DiscoverChannelsQuery, GetChannelByIdQuery.

Messages

SendMessageCommand(channelId, body?, parentMessageId?, attachments)body is optional because attachment-only messages are allowed. EditMessageCommand, DeleteMessageCommand, PinMessageCommand, UnpinMessageCommand, plus ListChannelMessagesQuery, ListMessageRepliesQuery, GetPinnedMessagesQuery, SearchMessagesQuery.

Reactions

AddReactionCommand(messageId, emoji), RemoveReactionCommand(messageId, emoji).

Endpoints

22 endpoints under /api/v1/chat. Major routes:

VerbRouteWhat it does
GET/channelsList my channels
GET/channels/discoverPublic channels I’m not in
GET/channels/{id}Channel details + members
POST/channelsCreate named channel
PUT/channels/{id}Rename / change privacy
DELETE/channels/{id}Archive (soft delete, members preserved)
POST/channels/{id}/restoreRestore archived channel (lossless)
POST/dmsFind or create DM / group DM (idempotent for 1:1)
POST/channels/{id}/membersAdd members
DELETE/channels/{id}/members/{userId}Remove a member
POST/channels/{id}/readUpdate read watermark
GET/channels/{id}/messagesCursor-paged message list
GET/channels/{id}/pinnedPinned messages
POST/channels/{id}/messagesSend message (+ mentions + attachments)
GET/messages/{id}/repliesThread replies
PUT/messages/{id}Edit message
DELETE/messages/{id}Soft delete to tombstone
POST/messages/{id}/pinPin
DELETE/messages/{id}/pinUnpin
POST/messages/{id}/reactionsAdd reaction
DELETE/messages/{id}/reactions/{emoji}Remove reaction
GET/search?q=&channelId=Full-text search

@mention → notification bridge

The flow lives in SendMessageCommandHandler and is split across two small services:

  1. MentionParser.Parse(body) — a [GeneratedRegex(@"(?<!\w)@([A-Za-z0-9._-]+)")] pulls @username tokens with their positions.
  2. IMentionResolver.ResolveUserIdsAsync(usernames) — maps usernames to active user ids via the Identity contracts’ IUserService (case-insensitive; unknown names are dropped).
  3. Each resolved other user becomes a position-tracked MessageMention row on the message, and the handler publishes a MentionedInChannelIntegrationEvent per mention via IEventBus:
src/Modules/Chat/Modules.Chat.Contracts/Events/MentionedInChannelIntegrationEvent.cs
public sealed record MentionedInChannelIntegrationEvent(
Guid Id, DateTime OccurredOnUtc, string? TenantId, string CorrelationId, string Source,
Guid ChannelId, string? ChannelName, Guid MessageId,
string AuthorUserId, string MentionedUserId, string BodyPreview) : IIntegrationEvent;

The Notifications module’s MentionedInChannelIntegrationEventHandler writes a Notification row and pushes NotificationCreated to the recipient’s user:{id} group via SignalR.

Configuration

The module reads no IOptions<T> directly. Configuration that matters lives in adjacent blocks:

  • SignalR / Valkey backplane — set CachingOptions:Redis to point at your Valkey. The AddHeroRealtime registration calls AddStackExchangeRedis(...) automatically (channel prefix fsh-signalr); leave it empty and the hub runs in single-host mode, which is what tests and bare-bones dev use.
  • Files attachmentsChatChannelFileAccessPolicy is registered automatically during ConfigureServices. Uploads go through /api/v1/files/upload-url with OwnerType=ChatChannel and OwnerId={channelId}.

How to extend

Add a new realtime event

Define a payload, broadcast post-SaveChanges from the handler:

await _hub.Clients.Group($"channel:{channelId}").SendAsync(
"ChatReactionChanged",
new { channelId, messageId, userId, emoji, kind = "added" },
ct).ConfigureAwait(false);

Restrict mentions to channel members only

MentionResolver resolves any username globally. To restrict to channel membership, look up IUserChannelLookup for each candidate and drop non-members.

Add an emoji deny-list

Add the rule to AddReactionCommandValidator (every command handler already has a FluentValidation validator) and reject unwanted emoji codes before the handler touches the aggregate.

Tests

  • Domain tests at src/Tests/Chat.Tests/ — channel invariants (DM membership, DirectKey), message state (edit by author, soft-delete tombstone), pin idempotency, reaction toggling.
  • Integration tests under src/Tests/Integration.Tests/Tests/Chat/ exercise REST + SignalR via Testcontainers (long-polling forced since TestServer has no WebSockets): channels, messages, threads + reactions, pinning, search, typing indicators, JoinChannel, presence, realtime event broadcasts, mention→notification, channel file access, and tenant isolation.