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 kinds —
Channel(named, Slack-style),DirectMessage(1:1, idempotent via sortedDirectKey),GroupMessage(3+ users). - Channel membership with two roles —
Admin,Member. - Lossless archive + restore —
DELETE /channels/{id}archives (soft-deletes) a channel by flipping the flag instead of an EFRemove(), soChannelMemberrows survive;POST /channels/{id}/restorebrings it back with memberships intact. - Messages with attachments, mentions, reactions; soft-deletable to a tombstone (
[deleted]) so threads stay coherent. - Thread replies via
ParentMessageId;ReplyCounttracked on the parent for UI badges. - Pinning — at most one pin per pin call, idempotent;
IsPinned+PinnedByUserId+PinnedAtUtcon 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 aMentionedInChannelIntegrationEventfor the Notifications module to consume. - Full-text search across messages via PostgreSQL
tsvectorand a GIN index on a generatedBodyTsvcolumn. - Typing indicator with a 3-second per (channel, user) throttle in distributed cache.
- SignalR realtime over a Valkey backplane (
AppHubinBuildingBlocks/Web/Realtime); on connect every user joinsuser:{id},tenant:{tenantId}, and every channel they belong to aschannel:{id}. Channels created or joined after connect are joined on demand via the hub’sJoinChannelmethod. - Presence — an in-memory
IPresenceTrackerbroadcastsPresenceChangedto 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 permissions —
Chat.ChannelsView / Create / ManageAll andChat.MessagesSend / 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 eventsThe 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
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 tenantchannel:{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:
| Verb | Route | What it does |
|---|---|---|
| GET | /channels | List my channels |
| GET | /channels/discover | Public channels I’m not in |
| GET | /channels/{id} | Channel details + members |
| POST | /channels | Create named channel |
| PUT | /channels/{id} | Rename / change privacy |
| DELETE | /channels/{id} | Archive (soft delete, members preserved) |
| POST | /channels/{id}/restore | Restore archived channel (lossless) |
| POST | /dms | Find or create DM / group DM (idempotent for 1:1) |
| POST | /channels/{id}/members | Add members |
| DELETE | /channels/{id}/members/{userId} | Remove a member |
| POST | /channels/{id}/read | Update read watermark |
| GET | /channels/{id}/messages | Cursor-paged message list |
| GET | /channels/{id}/pinned | Pinned messages |
| POST | /channels/{id}/messages | Send message (+ mentions + attachments) |
| GET | /messages/{id}/replies | Thread replies |
| PUT | /messages/{id} | Edit message |
| DELETE | /messages/{id} | Soft delete to tombstone |
| POST | /messages/{id}/pin | Pin |
| DELETE | /messages/{id}/pin | Unpin |
| POST | /messages/{id}/reactions | Add 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:
MentionParser.Parse(body)— a[GeneratedRegex(@"(?<!\w)@([A-Za-z0-9._-]+)")]pulls@usernametokens with their positions.IMentionResolver.ResolveUserIdsAsync(usernames)— maps usernames to active user ids via the Identity contracts’IUserService(case-insensitive; unknown names are dropped).- Each resolved other user becomes a position-tracked
MessageMentionrow on the message, and the handler publishes aMentionedInChannelIntegrationEventper mention viaIEventBus:
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:Redisto point at your Valkey. TheAddHeroRealtimeregistration callsAddStackExchangeRedis(...)automatically (channel prefixfsh-signalr); leave it empty and the hub runs in single-host mode, which is what tests and bare-bones dev use. - Files attachments —
ChatChannelFileAccessPolicyis registered automatically duringConfigureServices. Uploads go through/api/v1/files/upload-urlwithOwnerType=ChatChannelandOwnerId={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.
Related
- Notifications module — receives
MentionedInChannelIntegrationEvent. - Files module — chat attachments through
ChatChannelFileAccessPolicy. - Cross-cutting concerns — SignalR, Valkey backplane, Hangfire.