system-design56 min read

How to Design Enterprise Messaging like Slack — A Senior+ Guide | Ayodhyya

How to Design Enterprise Messaging like Slack

Building channels, threads, integrations, and real-time collaboration at 750K+ enterprise customer scale

Published: July 14, 2026 • By Ayodhyya • 25 min read

1. Introduction — Why Enterprise Messaging?

Slack has fundamentally transformed how enterprises communicate. With over 750,000 paying enterprise customers and serving organizations ranging from Fortune 500 companies to fast-growing startups, Slack processes billions of messages daily across millions of active channels. The platform supports over 2,400 integrations, has redefined the concept of channel-based communication, and has become the central nervous system for modern workplace collaboration.

Enterprise messaging is not simply "chat with channels." It is a sophisticated distributed system that must guarantee message ordering within channels, support real-time delivery with sub-second latency, handle file sharing and rich media, provide robust search across billions of historical messages, enforce enterprise compliance and security policies, integrate with thousands of third-party applications, and maintain five-nines availability across global regions.

In this comprehensive system design guide, we will architect an enterprise messaging platform from the ground up. We will cover every major component — from the message send pipeline to the search index, from WebSocket connections to multi-region failover, from bot platforms to enterprise SSO. Whether you are preparing for a senior+ system design interview or building such a system in production, this guide provides the depth and breadth you need.

Key Scale Numbers (Slack at ~2025-2026):
  • 750,000+ paying enterprise customers
  • 65+ million daily active users
  • Billions of messages sent per day
  • 10+ billion searchable messages in the index
  • 2,400+ app integrations
  • Average of 90 minutes per user per day on the platform

2. Requirements

2.1 Functional Requirements

Messaging:
  • Send and receive messages in channels (public, private), DMs, and group DMs
  • Support threaded conversations within channels
  • Rich message formatting (Markdown, code blocks, mentions, reactions, emoji)
  • Message editing and deletion with audit trail
  • Message pinning and bookmarking
  • File and media attachments with inline previews

Channels & Workspaces:
  • Create/join/leave public and private channels
  • Multi-workspace organizations with shared channels
  • Channel directories and categories
  • Channel topic, description, and purpose

Real-Time:
  • Sub-second message delivery to all connected clients
  • Typing indicators
  • Presence status (online, away, do not disturb)
  • Read receipts and message viewed status

Search:
  • Full-text search across messages, files, and people
  • Advanced filters (date range, sender, channel, has:link, has:file)
  • Search result ranking and relevance

Integrations & Bots:
  • Bot users that can send messages and respond to events
  • Incoming and outgoing webhooks
  • App home tabs and message actions
  • Workflow Builder for no-code automations

Enterprise:
  • SAML-based SSO integration
  • Data Loss Prevention (DLP) policies
  • Message retention policies
  • eDiscovery and legal hold
  • Domain claiming and administration

2.2 Non-Functional Requirements

RequirementTargetJustification
Availability99.99% (52 min downtime/year)Enterprise customers require near-zero downtime for critical communications
Latency (P99)< 200ms for message send, < 500ms for deliveryReal-time feel is critical for adoption
DurabilityZero message lossBusiness communications cannot lose messages
ConsistencyStrong ordering per channel, eventual across channelsMessages within a channel must appear in order
Scalability10M+ concurrent WebSocket connectionsGlobal enterprise scale
SecurityEnd-to-end encryption option, SOC2, HIPAAEnterprise compliance requirements

3. Capacity Estimation

3.1 Traffic Estimation

Assumptions:
  • 65 million DAU, each user sends ~40 messages/day
  • Average message size: 200 bytes (text) + 50 bytes metadata = 250 bytes
  • Average user reads/receives ~200 messages/day (including channel messages)
  • Each message is delivered to ~25 recipients on average (channel size)

Write Throughput:

  • Messages written: 65M users × 40 msgs/day = 2.6 billion messages/day
  • Write QPS: 2.6B / 86,400 ≈ 30,000 messages/second (peak: ~90,000 msg/s)
  • Storage per day: 2.6B × 250 bytes ≈ 650 GB/day
  • Storage per year: ~237 TB/year (raw), ~355 TB with indexes and replicas

Read Throughput:

  • Feed reads: 65M users × 50 channel loads/day = 3.25B reads/day ≈ 37,600 reads/s
  • Search queries: 65M × 5 searches/day = 325M/day ≈ 3,760 queries/s
  • File downloads: ~100M/day ≈ 1,160/s

Connection Capacity:

  • Peak concurrent connections: ~10M (assuming 15% concurrent rate)
  • WebSocket messages: ~150M/min = 2.5M/s during peak

4. Data Model

4.1 Core Entities

erDiagram WORKSPACE ||--o{ CHANNEL : contains WORKSPACE ||--o{ USER : has WORKSPACE ||--o{ WORKSPACE_SETTING : configures CHANNEL ||--o{ CHANNEL_MEMBER : has USER ||--o{ CHANNEL_MEMBER : belongs_to CHANNEL ||--o{ MESSAGE : contains MESSAGE ||--o{ MESSAGE : replies_in_thread USER ||--o{ MESSAGE : sends MESSAGE ||--o{ FILE_ATTACHMENT : includes MESSAGE ||--o{ REACTION : has USER ||--o{ REACTION : gives CHANNEL ||--o{ CHANNEL_INTEGRATION : has WORKSPACE ||--o{ APP : installs APP ||--o{ BOT_USER : creates

4.2 Database Schema

-- Workspace: Top-level organizational unit
CREATE TABLE workspaces (
    workspace_id    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name            VARCHAR(255) NOT NULL,
    domain          VARCHAR(255) UNIQUE NOT NULL,
    plan            VARCHAR(50) NOT NULL DEFAULT 'free',
    owner_user_id   UUID NOT NULL,
    created_at      TIMESTAMP DEFAULT NOW(),
    updated_at      TIMESTAMP DEFAULT NOW(),
    settings        JSONB DEFAULT '{}',
    is_active       BOOLEAN DEFAULT TRUE
);

-- Channel: Communication room within a workspace
CREATE TABLE channels (
    channel_id      UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    workspace_id    UUID NOT NULL REFERENCES workspaces(workspace_id),
    name            VARCHAR(255),
    topic           TEXT DEFAULT '',
    purpose         TEXT DEFAULT '',
    channel_type    VARCHAR(20) NOT NULL,  -- public, private, dm, group_dm
    created_by      UUID NOT NULL,
    created_at      TIMESTAMP DEFAULT NOW(),
    updated_at      TIMESTAMP DEFAULT NOW(),
    is_archived     BOOLEAN DEFAULT FALSE,
    member_count    INT DEFAULT 0,
    shared_channel  BOOLEAN DEFAULT FALSE
);

-- User
CREATE TABLE users (
    user_id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    workspace_id    UUID NOT NULL REFERENCES workspaces(workspace_id),
    display_name    VARCHAR(255) NOT NULL,
    real_name       VARCHAR(255),
    email           VARCHAR(255) NOT NULL,
    avatar_url      TEXT,
    title           VARCHAR(255),
    timezone        VARCHAR(100),
    status_text     VARCHAR(255),
    status_emoji    VARCHAR(50),
    presence_state  VARCHAR(20) DEFAULT 'offline',
    created_at      TIMESTAMP DEFAULT NOW(),
    updated_at      TIMESTAMP DEFAULT NOW()
);

-- Message: The core entity
CREATE TABLE messages (
    message_id      UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    channel_id      UUID NOT NULL REFERENCES channels(channel_id),
    user_id         UUID NOT NULL REFERENCES users(user_id),
    thread_id       UUID,  -- NULL for top-level messages
    parent_id       UUID,  -- for threaded replies
    content         TEXT NOT NULL,
    content_type    VARCHAR(50) DEFAULT 'text',
    edited_at       TIMESTAMP,
    is_deleted      BOOLEAN DEFAULT FALSE,
    is_pinned       BOOLEAN DEFAULT FALSE,
    reactions       JSONB DEFAULT '[]',
    mentions        JSONB DEFAULT '[]',
    metadata        JSONB DEFAULT '{}',
    created_at      TIMESTAMP DEFAULT NOW()
) PARTITION BY RANGE (created_at);

-- Monthly partitions for messages
CREATE TABLE messages_2026_07 PARTITION OF messages
    FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');

-- Channel membership
CREATE TABLE channel_members (
    channel_id      UUID NOT NULL REFERENCES channels(channel_id),
    user_id         UUID NOT NULL REFERENCES users(user_id),
    role            VARCHAR(20) DEFAULT 'member',  -- member, admin, owner
    joined_at       TIMESTAMP DEFAULT NOW(),
    last_read_at    TIMESTAMP DEFAULT NOW(),
    is_muted        BOOLEAN DEFAULT FALSE,
    notification_pref VARCHAR(20) DEFAULT 'all',
    PRIMARY KEY (channel_id, user_id)
);

-- File attachments
CREATE TABLE file_attachments (
    file_id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    message_id      UUID REFERENCES messages(message_id),
    channel_id      UUID NOT NULL,
    uploaded_by     UUID NOT NULL,
    file_name       VARCHAR(500) NOT NULL,
    file_size       BIGINT NOT NULL,
    mime_type       VARCHAR(100),
    storage_url     TEXT NOT NULL,
    thumbnail_url   TEXT,
    preview_text    TEXT,
    created_at      TIMESTAMP DEFAULT NOW()
);

-- Thread aggregation (denormalized for fast access)
CREATE TABLE threads (
    thread_id       UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    channel_id      UUID NOT NULL,
    parent_message_id UUID NOT NULL,
    reply_count     INT DEFAULT 0,
    last_reply_at   TIMESTAMP,
    last_reply_by   UUID,
    participant_ids UUID[] DEFAULT '{}',
    created_at      TIMESTAMP DEFAULT NOW()
);

-- App integration
CREATE TABLE apps (
    app_id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    workspace_id    UUID REFERENCES workspaces(workspace_id),
    name            VARCHAR(255) NOT NULL,
    app_type        VARCHAR(50) NOT NULL,  -- bot, webhook, workflow
    client_id       VARCHAR(255) UNIQUE,
    client_secret   VARCHAR(255),
    scopes          TEXT[],
    webhook_url     TEXT,
    event_subscriptions TEXT[],
    is_active       BOOLEAN DEFAULT TRUE,
    created_at      TIMESTAMP DEFAULT NOW()
);

4.3 Indexes for Hot Paths

CREATE INDEX idx_messages_channel_created ON messages(channel_id, created_at DESC);
CREATE INDEX idx_messages_thread ON messages(thread_id, created_at ASC);
CREATE INDEX idx_messages_user ON messages(user_id, created_at DESC);
CREATE INDEX idx_messages_mentions ON messages USING GIN(mentions);
CREATE INDEX idx_channel_members_user ON channel_members(user_id);
CREATE INDEX idx_channels_workspace_type ON channels(workspace_id, channel_type);
CREATE INDEX idx_users_workspace ON users(workspace_id);
CREATE INDEX idx_files_channel ON file_attachments(channel_id, created_at DESC);

5. API Design

5.1 Web API

The Slack Web API follows RESTful conventions with JSON payloads. All endpoints require authentication via Bearer token.

MethodEndpointDescription
POST/api/chat.postMessageSend a message to a channel
POST/api/chat.updateEdit an existing message
POST/api/chat.deleteDelete a message
GET/api/conversations.historyFetch channel message history
GET/api/conversations.repliesFetch thread replies
POST/api/conversations.createCreate a new channel
POST/api/conversations.joinJoin a channel
GET/api/search.messagesSearch messages
POST/api/files.uploadUpload a file
GET/api/users.infoGet user details
GET/api/presence.getGet user presence

5.2 Events API (Push Events)

The Events API delivers real-time events to your app via HTTP POST webhooks. Events include new messages, reactions, channel joins, presence changes, and more.

{
    "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl",
    "team_id": "T061EG9R6",
    "api_app_id": "A0FWB21ZV",
    "event": {
        "type": "message",
        "user": "U023BECGF",
        "text": "Hello, world!",
        "ts": "1355517523.000006",
        "channel": "C024BE91L",
        "event_ts": "1355517523.000006",
        "channel_type": "channel"
    },
    "type": "event_callback",
    "event_id": "Ev0FWB20KK",
    "created_at": 1355517523,
    "authed_users": ["U023BECGF"]
}

5.3 Socket Mode

Socket Mode allows apps to receive events and interact with Slack APIs without exposing a public HTTP endpoint. The app opens a WebSocket connection to Slack's servers, receiving all events over the persistent connection. This is ideal for development environments, internal tools, and firewalled networks.

6. High-Level Architecture

graph TB subgraph "Client Layer" C1[Desktop App] C2[Mobile App] C3[Web App] C4[Bots & Integrations] end subgraph "Edge / Load Balancing" LB[Global Load Balancer
Cloudflare / AWS Global Accelerator] CDN[CDN - Static Assets] WSS[WebSocket Gateway
Connection Manager] end subgraph "API Gateway" GW[API Gateway
Rate Limiting, Auth] REST[REST API Servers] WS[WebSocket Servers] EVENTS[Events API Service] end subgraph "Core Services" MSG[Message Service] CHAN[Channel Service] USER[User Service] THREAD[Thread Service] SEARCH[Search Service] FILE[File Service] NOTIFY[Notification Service] PRESENCE[Presence Service] BOT[Bot & App Service] WORKFLOW[Workflow Engine] end subgraph "Data Layer" PG[(PostgreSQL
Partitioned)] REDIS[(Redis Cluster
Cache + PubSub)] ES[(Elasticsearch
Search Index)] S3[(Object Storage
S3)] KAFKA[(Kafka
Event Bus)] end C1 & C2 & C3 --> CDN C1 & C2 & C3 --> LB C4 --> REST LB --> GW GW --> REST GW --> WSS WSS --> WS REST --> MSG & CHAN & USER & FILE & SEARCH WS --> MSG & PRESENCE EVENTS --> MSG & CHAN & BOT MSG --> KAFKA KAFKA --> SEARCH & NOTIFY & EVENTS MSG & CHAN & USER & THREAD --> PG MSG & CHAN & PRESENCE --> REDIS SEARCH --> ES FILE --> S3 BOT --> KAFKA WORKFLOW --> KAFKA

6.1 Key Design Principles

  • Event-Driven Architecture: All state changes flow through Kafka, enabling decoupled consumers for search indexing, notifications, analytics, and compliance
  • Separation of Read/Write: Write path goes through Message Service to Kafka; read path is served from materialized views in PostgreSQL and Redis cache
  • WebSocket per Region: Each user maintains exactly one WebSocket connection to their nearest regional gateway
  • Sharding by Workspace: Primary sharding key is workspace_id, enabling co-located data for workspace-local queries
  • Idempotent Operations: All message sends accept a client-generated idempotency key to prevent duplicate messages

7. Message Send & Delivery Pipeline

The message send and delivery pipeline is the most critical path in the system. Every component must be optimized for low latency while maintaining strong guarantees.

sequenceDiagram participant Client participant LB as Load Balancer participant API as API Gateway participant MSG as Message Service participant DB as PostgreSQL participant KFK as Kafka participant CACHE as Redis participant WS as WebSocket Server participant SEARCH as Search Index participant NOTIFY as Notification Service Client->>LB: POST /api/chat.postMessage LB->>API: Route to nearest API server API->>API: Authenticate + Rate limit API->>MSG: Send message request MSG->>MSG: Validate permissions MSG->>MSG: Assign sequence number MSG->>DB: Write message (with sequence) MSG->>CACHE: Publish to Redis PubSub MSG->>KFK: Publish MessageSent event MSG-->>Client: 200 OK (message_id, timestamp) KFK->>SEARCH: Async indexing KFK->>NOTIFY: Async notification dispatch CACHE-->>WS: Real-time push WS-->>RecipientClient: WebSocket message push

7.1 Message Ordering Guarantee

Within a single channel, messages must be strictly ordered. We achieve this through a monotonically increasing sequence number per channel, assigned by the Message Service at write time.

Important: If the Message Service has multiple replicas, we use a distributed sequence generator (e.g., a dedicated lightweight service backed by Redis INCR or a Snowflake-like approach) to assign globally unique, monotonically increasing sequence numbers. The sequence is (channel_id, sequence_number) which provides per-channel ordering.
// Sequence number generation using Redis
public async Task<long> GetNextSequenceAsync(string channelId)
{
    var key = $"channel:{channelId}:seq";
    return await _redis.StringIncrementAsync(key);
}

// Message with ordering
public class OrderedMessage
{
    public Guid MessageId { get; set; }
    public string ChannelId { get; set; }
    public long SequenceNumber { get; set; }  // Per-channel monotonically increasing
    public string Content { get; set; }
    public Guid UserId { get; set; }
    public DateTime CreatedAt { get; set; }
    public Guid? ThreadParentId { get; set; }
    public string IdempotencyKey { get; set; }
}

7.2 Fan-Out Strategy

When a message is sent to a channel with 10,000 members, we must deliver it to all online members efficiently. We use a two-tier fan-out approach:

  1. Hot Path (Online Users): The message is published to Redis PubSub keyed by channel_id. Each WebSocket server subscribes to the channels its connected users care about. The message is pushed in real-time via WebSocket.
  2. Cold Path (Offline/Notification Users): The message event is consumed from Kafka by the Notification Service, which checks each member's notification preferences and sends push notifications (APNs/FCM) or email digests as appropriate.

8. Channel Architecture

8.1 Channel Types

Channel TypeVisibilityMax MembersDescription
PublicAll workspace members500,000Open to all; anyone can join and read
PrivateInvited members only500,000Restricted; requires invite or request
DM (Direct Message)1:1 only2Private conversation between two users
Group DMParticipants only9Small private group conversation
Shared ChannelMultiple workspaces500,000Cross-workspace collaboration

8.2 Channel Membership and Permissions

Channel access is controlled through the channel_members table. For public channels, any workspace member can join. For private channels, explicit membership is required. The permission model uses roles: owner, admin, and member. Channel owners can manage settings, admins can manage members, and members can send messages.

graph LR subgraph "Permission Hierarchy" WS_OWNER[Workspace Owner] WS_ADMIN[Workspace Admin] CH_OWNER[Channel Owner] CH_ADMIN[Channel Admin] CH_MEMBER[Channel Member] CH_GUEST[Channel Guest] end WS_OWNER --> WS_ADMIN WS_ADMIN --> CH_OWNER CH_OWNER --> CH_ADMIN CH_ADMIN --> CH_MEMBER CH_MEMBER --> CH_GUEST

8.3 Shared Channels (Multi-Workspace)

Shared channels allow organizations to collaborate across workspace boundaries. When a channel is shared, messages from external workspace members are replicated to both workspaces' data stores. This requires a conflict-free replicated data type (CRDT) approach for message ordering, since messages from different workspaces may arrive with slight clock skew. Each shared message carries its origin workspace ID and a hybrid logical timestamp for consistent ordering.

9. Thread System

Threads are one of Slack's most important features, allowing focused conversations within a channel without cluttering the main feed. Each thread is rooted in a parent message, and all replies are stored with a reference to the parent.

graph TB subgraph "Channel #general" M1[Message 1] M2[Message 2 - Thread Parent] M5[Message 5] M2R1[Reply 1] M2R2[Reply 2] M2R3[Reply 3] end M1 --> M2 M2 --> M5 M2 --> M2R1 M2R1 --> M2R2 M2R2 --> M2R3 style M2 fill:#e6f3ff,stroke:#0088ff style M2R1 fill:#f0fdf4,stroke:#10b981 style M2R2 fill:#f0fdf4,stroke:#10b981 style M2R3 fill:#f0fdf4,stroke:#10b981

9.1 Thread Data Model

Thread replies store the thread_id (which is the parent message's ID) and are ordered by their sequence number. The threads table is a denormalized aggregation that tracks reply_count, last_reply_at, and participant_ids for fast display without querying all replies.

9.2 Thread Notifications

When someone replies to a thread, notifications are sent to:

  • All participants in the thread (unless they muted it)
  • People who were @mentioned in the parent message or earlier replies
  • Workspace members who bookmarked the thread

The thread participant list is maintained incrementally — when a new reply arrives, the user_id is appended to the participant_ids array if not already present. This avoids a full table scan for notification targeting.

10. Real-Time Messaging (WebSocket, Long Polling)

Real-time delivery is the core UX differentiator. Slack uses WebSocket connections as the primary transport, with Server-Sent Events (SSE) and long polling as fallbacks.

graph TB subgraph "WebSocket Connection Lifecycle" CONNECT[Client Connects] AUTH[Authenticate Token] SUBSCRIBE[Subscribe to Channels] HEARTBEAT[Heartbeat every 30s] RECEIVE[Receive Messages] RECONNECT[Auto-Reconnect] CONNECT --> AUTH AUTH --> SUBSCRIBE SUBSCRIBE --> HEARTBEAT SUBSCRIBE --> RECEIVE HEARTBEAT --> HEARTBEAT RECEIVE --> RECEIVE HEARTBEAT -.->|timeout 60s| RECONNECT RECONNECT --> CONNECT end

10.1 Connection Management

Each WebSocket server maintains a mapping of user_id to connection. When a message arrives for a user, the Redis PubSub channel is checked, and the message is pushed through the appropriate WebSocket connection. Connection state is stored in Redis with a 60-second TTL, refreshed by heartbeats every 30 seconds.

public class WebSocketConnectionManager
{
    private readonly ConcurrentDictionary<string, WebSocket> _connections = new();
    private readonly ISubscriber _redisSub;

    public async Task RegisterConnection(string userId, WebSocket socket, string[] channels)
    {
        _connections[userId] = socket;
        await _redisSub.SubscribeAsync(
            channels.Select(ch => (RedisChannel)$"channel:{ch}"),
            async (channel, message) =>
            {
                if (_connections.TryGetValue(userId, out var ws) && ws.State == WebSocketState.Open)
                {
                    var buffer = Encoding.UTF8.GetBytes(message);
                    await ws.SendAsync(new ArraySegment<byte>(buffer),
                        WebSocketMessageType.Text, true, CancellationToken.None);
                }
            });
        // Register presence
        await SetPresence(userId, "online");
    }

    public async Task HandleDisconnect(string userId)
    {
        _connections.TryRemove(userId, out _);
        await SetPresence(userId, "offline");
    }

    public async Task<int> GetOnlineCount(string channelId)
    {
        var members = await GetChannelMembers(channelId);
        return members.Count(m => _connections.ContainsKey(m));
    }
}

10.2 Message Delivery Guarantees

Slack uses an "at-least-once" delivery model with deduplication. Each message carries a unique event_id and timestamp. The client maintains the timestamp of the last received message per channel and includes it in reconnection requests to fetch any missed messages. The server-side buffer retains messages for 5 minutes per user, after which they are purged and the client must do a full history reload.

11. File Sharing & Preview Generation

File sharing is a critical feature supporting images, videos, documents, audio, code snippets, and more. The system must handle files up to 1 GB per file.

sequenceDiagram participant Client participant API as Upload API participant S3 as S3 Storage participant QUEUE as Processing Queue participant WORKER as Preview Worker participant CDN as CDN Client->>API: Request presigned upload URL API->>S3: Generate presigned POST API-->>Client: Upload URL + credentials Client->>S3: Direct upload to S3 S3->>QUEUE: S3 Event Notification QUEUE->>WORKER: Process file WORKER->>WORKER: Generate thumbnail(s) WORKER->>WORKER: Extract metadata WORKER->>WORKER: Create preview text WORKER->>S3: Store thumbnails WORKER->>API: Update file record Client->>CDN: Load preview via CDN

11.1 File Processing Pipeline

Supported formats and processing:

  • Images: Generate 3 thumbnails (128px, 360px, 800px), extract EXIF, detect faces for privacy blur
  • Videos: Extract first frame as thumbnail, generate 10s preview clip, detect audio for waveform
  • Documents (PDF, DOCX, PPTX): Convert first 3 pages to images, extract text for search indexing
  • Audio (MP3, WAV, OGG): Generate waveform visualization, extract duration
  • Code files: Syntax highlighting, extract first 20 lines for preview
  • Spreadsheets: Render first sheet as image, extract cell data for search

Slack's search is one of its most valued features. Users need to find messages, files, people, and channels across potentially billions of records with sub-second response times.

graph LR subgraph "Write Path" KFK[(Kafka)] --> |"Consumer"| IDX[Search Indexer] IDX --> ES1[(Elasticsearch
Messages)] IDX --> ES2[(Elasticsearch
Files)] IDX --> ES3[(Elasticsearch
Users)] end subgraph "Read Path" QUERY[Search Query] --> QP[Query Parser] QP --> ES1 QP --> ES2 QP --> ES3 ES1 --> RANK[Result Ranker] ES2 --> RANK ES3 --> RANK RANK --> CACHE[Result Cache] CACHE --> RESP[Response] end

12.1 Search Features

FeatureSyntaxExample
Basic text searchkeyworddeployment plan
Phrase search"phrase""quarterly report"
From userfrom:userfrom:@john
In channelin:channelin:#engineering
Date rangebefore/afterbefore:2026-07-01
Has linkhas:linkhas:link
Has filehas:filehas:file
Has emojihas:reactionhas:reaction
Boolean operatorsAND, OR, NOTdeploy AND NOT staging

12.2 Search Ranking Algorithm

Search results are ranked using a combination of signals:

  • Text relevance: TF-IDF score from Elasticsearch with custom analyzers for code, URLs, and mentions
  • Recency: More recent messages get a slight boost (decays over 90 days)
  • Engagement: Messages with more reactions, replies, or links clicked rank higher
  • User affinity: Messages from people you interact with frequently rank higher
  • Channel relevance: Messages from channels you're active in get boosted

13. Bot & App Platform

The bot and app platform allows third-party developers to build integrations that run within Slack. This includes bot users that appear as team members, interactive messages with buttons and modals, incoming/outgoing webhooks, and the Bolt SDK for rapid development.

graph TB subgraph "Slack App Platform" APP[Slack App] BOT[Bot User] WF[Workflow] HC[Home Tab] MSG_A[Message Actions] SHORT[Shortcuts] end subgraph "Event Flow" E1[message.channels] --> APP E2[reaction.added] --> APP E3[app_mention] --> BOT E4[shortcut] --> SHORT I1[Block Kit Actions] --> APP I2[Modal Submissions] --> APP end subgraph "Developer Tools" SDK[Bolt SDK
Python/JS/Java] API_TEST[API Tester] EVENTS_CFG[Event Subscriptions] OAUTH[OAuth 2.0 Flow] end

13.1 Bot Event Processing

When a bot receives an event (e.g., a message mentioning it), the Events API delivers the event to the app's configured Request URL. The app processes the event and can respond with a message, update, or interactive component. Bots must handle events idempotonously and within the 3-second timeout — for longer operations, acknowledge the event and respond asynchronously.

13.2 Rate Limiting for Apps

TierMethodLimitWindow
Tier 1chat.postMessage1 msg/secPer channel
Tier 2conversations.history50 req/minPer method
Tier 3users.list50 req/minPer method
Tier 4api.test1000 req/minPer method

14. Workflow Builder & Automation

Workflow Builder is a no-code tool that allows users to create automated workflows. It supports triggers (new message, emoji reaction, schedule, webhook) and actions (send message, create channel, collect form data, conditional logic).

graph LR TRIGGER[Trigger
Message/Emoji/Schedule/Webhook] --> WF_ENGINE[Workflow Engine] WF_ENGINE --> STEP1[Step 1: Collect Form] STEP1 --> STEP2[Step 2: Conditional Check] STEP2 -->|Yes| STEP3[Step 3: Send Message] STEP2 -->|No| STEP4[Step 4: Create Task] STEP3 --> STEP5[Step 5: Update Channel Topic] STEP4 --> STEP5

14.1 Workflow Execution Engine

The workflow engine is a state machine that processes workflow definitions stored as JSON. Each step is executed by a dedicated worker that reads the step configuration, invokes the appropriate action (API call, conditional evaluation, data transformation), and passes the output to the next step. Workflow state is persisted in PostgreSQL to support recovery and debugging. The engine supports parallel branches, loops (up to 100 iterations), and error handling with retry logic. Failed steps can trigger fallback actions or notify the workflow creator.

Workflow definitions are versioned — when a user modifies a workflow in the builder, a new version is published while in-flight executions of the previous version continue to completion. This ensures backward compatibility and prevents mid-execution failures from schema changes. The engine scales horizontally with Kafka-based work distribution, where each workflow trigger produces an event to the workflow-executions topic, and worker instances consume from the topic partition assigned to their consumer group.

public class WorkflowEngine
{
    private readonly IWorkflowRepository _repository;
    private readonly IActionExecutor _executor;
    private readonly IMessageService _messageService;

    public async Task ExecuteWorkflowAsync(string workflowId, Dictionary<string, object> triggerData)
    {
        var workflow = await _repository.GetWorkflowAsync(workflowId);
        var state = new WorkflowState
        {
            WorkflowId = workflowId,
            StartedAt = DateTime.UtcNow,
            Variables = new Dictionary<string, object>(triggerData)
        };

        foreach (var step in workflow.Steps.OrderBy(s => s.Order))
        {
            state.CurrentStep = step.Id;
            try
            {
                var result = await _executor.ExecuteStepAsync(step, state.Variables);
                state.Variables[step.OutputKey] = result.Output;

                if (step.IsConditional && !result.ConditionMet)
                {
                    state.CurrentStep = step.FalseBranchId;
                    continue;
                }

                await _repository.SaveStateAsync(state);
            }
            catch (Exception ex)
            {
                state.Error = ex.Message;
                state.Status = WorkflowStatus.Failed;
                await _repository.SaveStateAsync(state);
                throw;
            }
        }

        state.Status = WorkflowStatus.Completed;
        state.CompletedAt = DateTime.UtcNow;
        await _repository.SaveStateAsync(state);
    }
}

15. Notification System

The notification system must handle millions of notification dispatches per minute while respecting user preferences, DND schedules, and notification batching rules.

graph TB KFK[(Kafka)] --> CONSUMER[Notification Consumer] CONSUMER --> PREF_CHECK{Check User
Preferences} PREF_CHECK -->|Channel Muted| DROP[Drop Notification] PREF_CHECK -->|DND Active| QUEUE[Buffer for Later] PREF_CHECK -->|Realtime| PUSH[Push Notification] PREF_CHECK -->|Batched| BATCH[Batch Buffer] BATCH -->|Every 15 min| DIGEST[Send Digest] PUSH --> APNS[Apple Push] PUSH --> FCM[Firebase Cloud] PUSH --> WS_PUSH[WebSocket Push] PUSH --> EMAIL[Email] QUEUE --> DND_RESOLVER[DND Resolver] DND_RESOLVER --> PUSH

15.1 Notification Batching

Slack batches notifications intelligently. If a user is mentioned in a channel, and 15 more messages arrive in that channel within 5 minutes, the user receives a single notification: "15 new messages in #channel, including a mention from @user" rather than 16 separate notifications. This is controlled by a per-channel timer that resets on each new notification and fires after 5 minutes of inactivity.

15.2 Do Not Disturb

DND can be set manually, on a schedule (e.g., 10 PM - 7 AM in user's timezone), or automatically based on calendar integration. During DND, push notifications and sounds are suppressed, but messages are still delivered and can be viewed. DND overrides are allowed for @everyone and @here mentions in channels the user has specifically allowed.

16. Presence & Status

Presence indicates whether a user is currently active, away, or in DND mode. Status is a user-configured message with an emoji (e.g., "Working from home 🏠").

16.1 Presence Detection

Presence is determined by:

  • Active: WebSocket connection alive + client focus event in last 10 seconds
  • Away: No focus events for 10 minutes (auto-away)
  • Offline: No WebSocket connection for 60 seconds

Presence state is stored in Redis with a short TTL (30 seconds) that is refreshed by the client's focus events. This avoids expensive database writes for presence updates.

public class PresenceService
{
    private readonly IDatabase _redis;

    public async Task UpdatePresenceAsync(string userId, string state)
    {
        var key = $"presence:{userId}";
        await _redis.StringSetAsync(key, state, TimeSpan.FromSeconds(60));
        await _redis.Publisher.PublishAsync("presence-updates",
            JsonSerializer.Serialize(new PresenceUpdate
            {
                UserId = userId,
                State = state,
                Timestamp = DateTime.UtcNow
            }));
    }

    public async Task<string> GetPresenceAsync(string userId)
    {
        var state = await _redis.StringGetAsync($"presence:{userId}");
        return state.HasValue ? state.ToString() : "offline";
    }
}

17. Huddles & Voice/Video

Slack Huddles provide lightweight audio and video calls that can be started instantly in any channel or DM. Unlike scheduled video meetings, huddles are ephemeral and spontaneous — designed to replace quick hallway conversations and impromptu standups. A huddle can be started with a single click, and any channel member or thread participant can join instantly without a separate meeting link.

17.1 Architecture

  • Signaling Server: Uses WebSocket for call signaling (join, leave, mute, screen share). The signaling server maintains call state and broadcasts participant changes to all connected clients in the huddle.
  • Media Server (SFU): WebRTC Selective Forwarding Unit for efficient media routing. Unlike a full mesh (where every participant sends to every other participant), an SFU receives each participant's stream once and forwards it to all other participants, reducing upload bandwidth from O(n) to O(1) per client.
  • TURN/STUN Servers: For NAT traversal and relay when peer-to-peer direct connection is not possible due to symmetric NATs or restrictive firewalls. TURN relay servers handle the actual media forwarding when direct P2P fails.
  • Recording Service: Optional cloud recording for compliance (enterprise feature). The SFU forwards a copy of the mixed audio stream and individual video streams to the recording service, which stores them in S3 for later playback and transcription.
  • Audio Processing: Real-time noise suppression, echo cancellation, and automatic gain control are applied client-side using WebRTC audio processing modules.

17.2 Huddle Lifecycle

When a user clicks the huddle icon in a channel, a call session is created with a unique ID and the creator becomes the initial participant. The signaling server broadcasts a "huddle.started" event to all channel members via the channel's WebSocket topic. Participants who join send a "join" signal, receive the list of current participants, and begin establishing WebRTC peer connections through the SFU. When the last participant leaves, the huddle session is terminated and a system message is posted to the channel summarizing the huddle duration and participants.

17.3 Thread Huddles and Screen Sharing

Huddles can be started within a thread context, linking the audio/video call to a specific discussion. Screen sharing uses the WebRTCgetDisplayMedia API with a custom cursor overlay for annotations. The shared screen is encoded as a high-priority video track with adaptive bitrate — the SFU dynamically adjusts quality based on each viewer's bandwidth. For enterprise compliance, screen shares in recorded huddles are stored alongside the video feeds.

18. Enterprise Features (SAML SSO, DLP, Compliance)

18.1 SAML SSO

Enterprise Grid workspaces support SAML 2.0 SSO integration with identity providers like Okta, Azure AD, and OneLogin. The SSO flow handles SP-initiated and IdP-initiated authentication, with Just-In-Time (JIT) provisioning for new users.

18.2 Data Loss Prevention (DLP)

DLP Rule TypeDetection MethodAction
SSN / Credit CardRegex pattern matchingBlock + Alert admin
PII (email, phone)Regex + NER modelWarn user + Log
Source code patternsEntropy analysis + keywordsAlert admin
Confidential filesFile label / keywordBlock upload
External sharingShared channel policyBlock + Review

18.3 Compliance & eDiscovery

Enterprise customers require message retention policies (e.g., retain all messages for 7 years), legal holds (preserve messages for litigation), and eDiscovery export (produce messages in standard formats). All messages and file metadata are stored in append-only partitions, and deletion is logical (tombstone) rather than physical for compliance purposes.

19. Database Design & Sharding

19.1 Sharding Strategy

graph TB subgraph "Shard Key: workspace_id" WS1[Workspace A] --> SHARD1[Shard 1
workspaces: A, F, K, P, U] WS2[Workspace B] --> SHARD2[Shard 2
workspaces: B, G, L, Q, V] WS3[Workspace C] --> SHARD3[Shard 3
workspaces: C, H, M, R, W] WS4[Workspace D] --> SHARD4[Shard 4
workspaces: D, I, N, S, X] WS5[Workspace E] --> SHARD5[Shard 5
workspaces: E, J, O, T, Y] end SHARD1 --> DB1[(Primary + 2 Replicas)] SHARD2 --> DB2[(Primary + 2 Replicas)] SHARD3 --> DB3[(Primary + 2 Replicas)] SHARD4 --> DB4[(Primary + 2 Replicas)] SHARD5 --> DB5[(Primary + 2 Replicas)]

Sharding is performed by workspace_id using consistent hashing. This ensures that all data for a workspace resides on the same shard, enabling efficient cross-table joins within a workspace context. Each shard runs a PostgreSQL primary with synchronous replication to 2 read replicas, providing high availability and read scaling.

19.2 Partitioning Strategy for Messages

The messages table is first sharded by workspace_id (via the shard), then partitioned by month using PostgreSQL range partitioning. Old partitions can be moved to cheaper storage (e.g., S3 with Parquet format) for long-term retention, while recent partitions remain on SSD for fast access.

19.3 Cross-Shard Queries

Some queries span multiple shards — for example, global search across all workspaces. These queries are handled by the Search Service, which maintains a separate Elasticsearch index that aggregates data from all shards via Kafka change data capture (CDC).

20. Caching Strategy

20.1 Cache Layers

CacheTechnologyWhat's CachedTTLInvalidation
L1: ClientLocal memoryRecent messages, user profiles5 minWebSocket push
L2: EdgeRedis (in-memory)Channel state, presence, session30-60sPubSub
L3: ApplicationRedis ClusterHot channel messages, user data5-15 minTTL + event-driven
L4: CDNCloudflareStatic assets, file previews1-24 hoursCache busting

20.2 Cache Warming

When a user opens a channel for the first time in a session, the client requests the last 100 messages. These are fetched from PostgreSQL, cached in Redis with a 15-minute TTL, and returned to the client. Subsequent loads hit the Redis cache. For large channels, we pre-warm the cache during off-peak hours.

20.3 Redis PubSub for Real-Time

Redis PubSub is the backbone of real-time delivery. When a message is written, it is immediately published to the channel's Redis PubSub topic. All WebSocket server instances subscribe to the channels their connected users care about. This provides fan-out with O(1) latency per subscriber, critical for the sub-second delivery requirement.

21. Multi-Region Design

graph TB subgraph "US-EAST-1 (Primary)" LB_US[Load Balancer] API_US[API Servers] DB_US[(PostgreSQL
Primary)] RD_US[(Redis)] ES_US[(Elasticsearch)] end subgraph "EU-WEST-1 (Secondary)" LB_EU[Load Balancer] API_EU[API Servers] DB_EU[(PostgreSQL
Read Replica)] RD_EU[(Redis)] ES_EU[(Elasticsearch)] end subgraph "AP-SOUTHEAST-1 (Secondary)" LB_AP[Load Balancer] API_AP[API Servers] DB_AP[(PostgreSQL
Read Replica)] RD_AP[(Redis)] ES_AP[(Elasticsearch)] end DB_US -->|Async Replication| DB_EU DB_US -->|Async Replication| DB_AP ES_US -->|Cross-Cluster Replication| ES_EU ES_US -->|Cross-Cluster Replication| ES_AP RD_US -->|Redis CRDT Replication| RD_EU RD_US -->|Redis CRDT Replication| RD_AP

21.1 Region Affinity

Each workspace is assigned a primary region based on the creator's location. All writes go to the primary region. Read replicas in other regions serve read requests with slightly higher latency. WebSocket connections are always routed to the nearest region.

21.2 Failover

If the primary region fails, DNS-based routing shifts traffic to the nearest healthy region. After failover, the read replica is promoted to primary. In-flight writes that were replicated before the failure are preserved; writes that were in the replication lag window may need client retry. The system targets an RPO (Recovery Point Objective) of under 5 seconds and RTO (Recovery Time Objective) of under 60 seconds.

22. Cost Estimation

22.1 Infrastructure Costs (Monthly)

ComponentSpecQuantityMonthly Cost
API/WebSocket Serversc6i.2xlarge (8 vCPU, 16 GB)100$35,000
PostgreSQL (Primary per shard)r6i.4xlarge (16 vCPU, 128 GB)20$30,000
PostgreSQL (Read Replicas)r6i.2xlarge (8 vCPU, 64 GB)40$30,000
Redis Clusterr6i.xlarge nodes50$18,000
Elasticsearchr6i.2xlarge data nodes30$25,000
Kafka (MSK)kafka.m5.2xlarge20$15,000
S3 Storage~500 TB total-$12,000
CDN (Cloudflare Enterprise)Bandwidth + WAF-$20,000
Global Load BalancerAWS Global Accelerator3$5,000
Push Notification (APNs/FCM)~500M push/month-$3,000
Monitoring & ObservabilityDatadog / PagerDuty-$15,000
Total~$208,000/month

22.2 Cost Per User

With 65 million DAU, the infrastructure cost per user per month is approximately $3.20. At an average revenue of $8.67 per user/month (Blended Pro + Enterprise Grid pricing), this yields a gross margin of ~63%, which is healthy for a SaaS business at this scale.

23. Interview Q&A

Q1: How do you ensure message ordering within a channel?

We use a per-channel monotonically increasing sequence number, assigned by the Message Service at write time using Redis INCR on the channel's sequence key. Each message is stored with (channel_id, sequence_number), and reads are always ordered by sequence_number. This guarantees strict ordering within a channel without requiring distributed locks.

Q2: How do you handle duplicate message delivery?

Each client sends an idempotency_key with every message. The Message Service checks this key against a Redis deduplication cache (TTL 5 minutes) before writing. If a duplicate is detected, the existing message_id is returned without creating a new message. For WebSocket delivery, each push message carries an event_id; the client deduplicates locally.

Q3: How do you handle fan-out for a message sent to a 100K-member channel?

We use a hybrid approach: (1) For online users connected via WebSocket, we publish to Redis PubSub per channel, and each WebSocket server pushes to its connected subscribers. (2) For offline users, we enqueue notifications via Kafka for batch processing. We never directly loop through 100K members — Redis PubSub handles the fan-out efficiently.

Q4: How would you design the search system for 10 billion messages?

We use Elasticsearch with time-based indices (monthly). Messages are streamed from Kafka to search indexers, which batch-index into Elasticsearch. We use custom analyzers for code, URLs, and mentions. Results are cached in Redis for frequent queries. For ranking, we combine TF-IDF with engagement signals (reactions, replies, recency). We shard the index by workspace for data isolation and query performance.

Q5: What happens when the primary database region fails?

We use synchronous replication to one standby and async replication to the DR region. On primary failure, the synchronous standby is promoted (RTO ~10s). DNS failover routes traffic to the DR region. Writes in-flight during the replication lag window may need client retry, but the idempotency mechanism prevents duplicates. The system targets RPO < 5 seconds.

Q6: How do you prevent a bot from spamming the API?

We enforce tiered rate limits per app (e.g., 1 msg/sec for chat.postMessage per channel). Rate limits are tracked using a sliding window counter in Redis. Apps that exceed limits receive 429 responses with a Retry-After header. For persistent abusers, we suspend the app. Additionally, workspace admins can view app usage metrics and revoke access.

Q7: How do you handle message editing with an audit trail?

When a message is edited, the original content is preserved in a separate message_history table. The messages table stores only the current content with an updated_at timestamp. The message_history table retains all previous versions with their edit timestamps and editor user_id. This provides a complete audit trail for compliance without slowing down reads.

Q8: How would you design the thread system to avoid polluting the main channel?

Thread replies are stored with thread_id = parent_message_id and are never included in the main channel feed. The main channel shows only top-level messages and a reply_count indicator. The Thread Service handles thread-specific queries (conversations.replies). We maintain a denormalized threads table with reply_count and last_reply_at for fast display without scanning all replies.

Q9: How do you handle shared channels across workspaces?

Messages in shared channels are replicated to both workspaces' data stores. Each message carries its origin workspace_id. We use hybrid logical timestamps (HLC) for consistent ordering across workspaces despite clock skew. The channel membership table tracks which workspaces have access, and permission checks are workspace-aware.

Q10: How do you optimize for the "channel switch" use case (user rapidly clicking between channels)?

We preload the last 100 messages of the 5 most recently accessed channels on app launch. When switching channels, the client first renders from local cache (IndexedDB on web, SQLite on mobile), then fetches from the server if the cache is stale (> 5 minutes). We use optimistic updates and a skeleton UI to make switching feel instant. Server responses update the cache for next time.

Q11: How do you handle file uploads for very large files (1 GB video)?

We use presigned S3 URLs for direct client-to-S3 upload, bypassing our API servers for the actual data transfer. The client first requests a presigned URL from our Upload API, uploads directly to S3, then notifies our API of completion. S3 triggers a Lambda for preview generation (thumbnails, metadata extraction). This avoids loading large files through our application servers.

Q12: How would you implement presence for 65 million users efficiently?

Presence state is stored in Redis with a 60-second TTL, refreshed every 30 seconds by client focus events. We never write presence to PostgreSQL — it's ephemeral. When a user's presence is queried, we check Redis first; if the key exists, the user is online; if not, they're offline. This is O(1) per lookup and avoids any database overhead.

24. Full C# Implementation

Below is a complete C# implementation of the core messaging system, including the Message Service, Channel Service, WebSocket Connection Manager, Search Service, and supporting infrastructure. This implementation covers 300+ lines and demonstrates production patterns.

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;

namespace EnterpriseMessaging.Core
{
    // ============================================================
    // Domain Models
    // ============================================================

    public enum ChannelType { Public, Private, DirectMessage, GroupDM, Shared }
    public enum UserRole { Guest, Member, Admin, Owner }
    public enum PresenceState { Online, Away, Offline, DoNotDisturb }
    public enum MessageContentType { Text, Code, File, System, Bot }

    public class Workspace
    {
        public Guid Id { get; set; } = Guid.NewGuid();
        public string Name { get; set; } = string.Empty;
        public string Domain { get; set; } = string.Empty;
        public string Plan { get; set; } = "free";
        public bool IsActive { get; set; } = true;
        public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    }

    public class Channel
    {
        public Guid Id { get; set; } = Guid.NewGuid();
        public Guid WorkspaceId { get; set; }
        public string? Name { get; set; }
        public string Topic { get; set; } = string.Empty;
        public string Purpose { get; set; } = string.Empty;
        public ChannelType Type { get; set; }
        public Guid CreatedBy { get; set; }
        public bool IsArchived { get; set; }
        public int MemberCount { get; set; }
        public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    }

    public class User
    {
        public Guid Id { get; set; } = Guid.NewGuid();
        public Guid WorkspaceId { get; set; }
        public string DisplayName { get; set; } = string.Empty;
        public string RealName { get; set; } = string.Empty;
        public string Email { get; set; } = string.Empty;
        public string? AvatarUrl { get; set; }
        public PresenceState Presence { get; set; } = PresenceState.Offline;
        public string StatusText { get; set; } = string.Empty;
        public string StatusEmoji { get; set; } = string.Empty;
        public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    }

    public class Message
    {
        public Guid Id { get; set; } = Guid.NewGuid();
        public Guid ChannelId { get; set; }
        public Guid UserId { get; set; }
        public Guid? ThreadId { get; set; }
        public string Content { get; set; } = string.Empty;
        public MessageContentType ContentType { get; set; } = MessageContentType.Text;
        public long SequenceNumber { get; set; }
        public bool IsDeleted { get; set; }
        public bool IsEdited { get; set; }
        public bool IsPinned { get; set; }
        public string? IdempotencyKey { get; set; }
        public List<Reaction> Reactions { get; set; } = new();
        public List<Guid> Mentions { get; set; } = new();
        public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
        public DateTime? EditedAt { get; set; }
    }

    public class Reaction
    {
        public string Emoji { get; set; } = string.Empty;
        public List<Guid> UserIds { get; set; } = new();
    }

    public class Thread
    {
        public Guid Id { get; set; }
        public Guid ChannelId { get; set; }
        public Guid ParentMessageId { get; set; }
        public int ReplyCount { get; set; }
        public DateTime? LastReplyAt { get; set; }
        public Guid? LastReplyBy { get; set; }
        public HashSet<Guid> ParticipantIds { get; set; } = new();
    }

    public class ChannelMember
    {
        public Guid ChannelId { get; set; }
        public Guid UserId { get; set; }
        public UserRole Role { get; set; } = UserRole.Member;
        public bool IsMuted { get; set; }
        public string NotificationPref { get; set; } = "all";
        public DateTime JoinedAt { get; set; } = DateTime.UtcNow;
        public DateTime LastReadAt { get; set; } = DateTime.UtcNow;
    }

    public class FileAttachment
    {
        public Guid Id { get; set; } = Guid.NewGuid();
        public Guid MessageId { get; set; }
        public Guid ChannelId { get; set; }
        public Guid UploadedBy { get; set; }
        public string FileName { get; set; } = string.Empty;
        public long FileSize { get; set; }
        public string MimeType { get; set; } = string.Empty;
        public string StorageUrl { get; set; } = string.Empty;
        public string? ThumbnailUrl { get; set; }
        public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    }

    // ============================================================
    // Event Models for Kafka / Inter-Service Communication
    // ============================================================

    public class MessageEvent
    {
        public string EventType { get; set; } = "message.sent";
        public Guid MessageId { get; set; }
        public Guid ChannelId { get; set; }
        public Guid WorkspaceId { get; set; }
        public Guid UserId { get; set; }
        public string Content { get; set; } = string.Empty;
        public long SequenceNumber { get; set; }
        public DateTime Timestamp { get; set; }
        public Guid? ThreadId { get; set; }
        public List<Guid> Mentions { get; set; } = new();
    }

    public class PresenceUpdate
    {
        public Guid UserId { get; set; }
        public PresenceState State { get; set; }
        public DateTime Timestamp { get; set; }
    }

    // ============================================================
    // Interfaces
    // ============================================================

    public interface IMessageRepository
    {
        Task<Message> SaveMessageAsync(Message message);
        Task<List<Message>> GetChannelHistoryAsync(Guid channelId, int limit = 100, DateTime? before = null);
        Task<List<Message>> GetThreadRepliesAsync(Guid threadId, int limit = 100);
        Task<Message?> GetMessageAsync(Guid messageId);
        Task<bool> CheckIdempotencyKeyAsync(string key);
    }

    public interface IChannelRepository
    {
        Task<Channel> CreateChannelAsync(Channel channel);
        Task<Channel?> GetChannelAsync(Guid channelId);
        Task<List<Channel>> GetUserChannelsAsync(Guid userId);
        Task<bool> IsMemberAsync(Guid channelId, Guid userId);
        Task<ChannelMember> AddMemberAsync(ChannelMember member);
        Task<List<Guid>> GetChannelMemberIdsAsync(Guid channelId);
    }

    public interface IThreadRepository
    {
        Task<Thread> GetOrCreateThreadAsync(Guid channelId, Guid parentMessageId);
        Task UpdateThreadAsync(Thread thread);
        Task<Thread?> GetThreadAsync(Guid threadId);
    }

    public interface ISearchService
    {
        Task IndexMessageAsync(Message message, Channel channel, User user);
        Task<List<SearchResult>> SearchAsync(string query, Guid workspaceId, SearchFilters? filters = null);
    }

    public interface INotificationService
    {
        Task DispatchNotificationAsync(MessageEvent messageEvent, List<Guid> recipientIds);
    }

    // ============================================================
    // Search Models
    // ============================================================

    public class SearchResult
    {
        public Guid MessageId { get; set; }
        public string Content { get; set; } = string.Empty;
        public Guid ChannelId { get; set; }
        public string ChannelName { get; set; } = string.Empty;
        public string UserName { get; set; } = string.Empty;
        public double Score { get; set; }
        public DateTime CreatedAt { get; set; }
    }

    public class SearchFilters
    {
        public Guid? UserId { get; set; }
        public Guid? ChannelId { get; set; }
        public DateTime? Before { get; set; }
        public DateTime? After { get; set; }
        public bool? HasFile { get; set; }
        public bool? HasLink { get; set; }
    }

    // ============================================================
    // Message Service - Core Business Logic
    // ============================================================

    public class MessageService
    {
        private readonly IMessageRepository _messageRepo;
        private readonly IChannelRepository _channelRepo;
        private readonly IThreadRepository _threadRepo;
        private readonly IConnectionMultiplexer _redis;
        private readonly ISearchService _searchService;
        private readonly INotificationService _notificationService;
        private readonly ILogger<MessageService> _logger;

        public MessageService(
            IMessageRepository messageRepo,
            IChannelRepository channelRepo,
            IThreadRepository threadRepo,
            IConnectionMultiplexer redis,
            ISearchService searchService,
            INotificationService notificationService,
            ILogger<MessageService> logger)
        {
            _messageRepo = messageRepo;
            _channelRepo = channelRepo;
            _threadRepo = threadRepo;
            _redis = redis;
            _searchService = searchService;
            _notificationService = notificationService;
            _logger = logger;
        }

        public async Task<Message> SendMessageAsync(
            Guid channelId, Guid userId, string content,
            MessageContentType contentType = MessageContentType.Text,
            string? idempotencyKey = null, Guid? threadId = null,
            List<Guid>? mentions = null)
        {
            // Step 1: Idempotency check
            if (!string.IsNullOrEmpty(idempotencyKey))
            {
                var isDuplicate = await _messageRepo.CheckIdempotencyKeyAsync(idempotencyKey);
                if (isDuplicate)
                {
                    _logger.LogWarning("Duplicate message detected for key {Key}", idempotencyKey);
                    throw new DuplicateMessageException(idempotencyKey);
                }
            }

            // Step 2: Validate channel membership
            var isMember = await _channelRepo.IsMemberAsync(channelId, userId);
            if (!isMember)
            {
                throw new UnauthorizedAccessException(
                    $"User {userId} is not a member of channel {channelId}");
            }

            // Step 3: Get channel info for workspace context
            var channel = await _channelRepo.GetChannelAsync(channelId)
                ?? throw new ChannelNotFoundException(channelId);

            // Step 4: Assign sequence number
            var db = _redis.GetDatabase();
            var seqKey = $"channel:{channelId}:seq";
            var sequenceNumber = await db.StringIncrementAsync(seqKey);

            // Step 5: Create message
            var message = new Message
            {
                ChannelId = channelId,
                UserId = userId,
                Content = content,
                ContentType = contentType,
                SequenceNumber = sequenceNumber,
                IdempotencyKey = idempotencyKey,
                ThreadId = threadId,
                Mentions = mentions ?? new List<Guid>(),
                CreatedAt = DateTime.UtcNow
            };

            // Step 6: Persist to database
            message = await _messageRepo.SaveMessageAsync(message);
            _logger.LogInformation(
                "Message {MessageId} sent to channel {ChannelId} by user {UserId} (seq: {Seq})",
                message.Id, channelId, userId, sequenceNumber);

            // Step 7: Handle thread reply
            if (threadId.HasValue)
            {
                var thread = await _threadRepo.GetOrCreateThreadAsync(channelId, threadId.Value);
                thread.ReplyCount++;
                thread.LastReplyAt = message.CreatedAt;
                thread.LastReplyBy = userId;
                thread.ParticipantIds.Add(userId);
                await _threadRepo.UpdateThreadAsync(thread);
            }

            // Step 8: Publish to Redis PubSub for real-time delivery
            var pubSubChannel = RedisChannel.Literal($"channel:{channelId}");
            var messageEvent = new MessageEvent
            {
                EventType = threadId.HasValue ? "thread.reply" : "message.sent",
                MessageId = message.Id,
                ChannelId = channelId,
                WorkspaceId = channel.WorkspaceId,
                UserId = userId,
                Content = content,
                SequenceNumber = sequenceNumber,
                Timestamp = message.CreatedAt,
                ThreadId = threadId,
                Mentions = message.Mentions
            };
            await db.PublishAsync(pubSubChannel,
                JsonSerializer.Serialize(messageEvent));

            // Step 9: Publish to Kafka for async consumers (search, notifications, compliance)
            await PublishToKafkaAsync("message-events", messageEvent);

            return message;
        }

        public async Task<Message> EditMessageAsync(
            Guid messageId, Guid userId, string newContent)
        {
            var message = await _messageRepo.GetMessageAsync(messageId)
                ?? throw new MessageNotFoundException(messageId);

            if (message.UserId != userId)
            {
                throw new UnauthorizedAccessException(
                    "Only the message author can edit a message");
            }

            if (message.IsDeleted)
            {
                throw new InvalidOperationException("Cannot edit a deleted message");
            }

            message.Content = newContent;
            message.IsEdited = true;
            message.EditedAt = DateTime.UtcNow;

            await _messageRepo.SaveMessageAsync(message);
            return message;
        }

        public async Task DeleteMessageAsync(Guid messageId, Guid userId, bool isAdmin = false)
        {
            var message = await _messageRepo.GetMessageAsync(messageId)
                ?? throw new MessageNotFoundException(messageId);

            if (message.UserId != userId && !isAdmin)
            {
                throw new UnauthorizedAccessException(
                    "Only the message author or admin can delete a message");
            }

            message.IsDeleted = true;
            message.Content = "[message deleted]";
            await _messageRepo.SaveMessageAsync(message);
        }

        public async Task AddReactionAsync(Guid messageId, string emoji, Guid userId)
        {
            var message = await _messageRepo.GetMessageAsync(messageId)
                ?? throw new MessageNotFoundException(messageId);

            var existing = message.Reactions.FirstOrDefault(r => r.Emoji == emoji);
            if (existing != null)
            {
                if (!existing.UserIds.Contains(userId))
                    existing.UserIds.Add(userId);
            }
            else
            {
                message.Reactions.Add(new Reaction
                {
                    Emoji = emoji,
                    UserIds = new List<Guid> { userId }
                });
            }

            await _messageRepo.SaveMessageAsync(message);
        }

        public async Task PinMessageAsync(Guid messageId, Guid userId)
        {
            var message = await _messageRepo.GetMessageAsync(messageId)
                ?? throw new MessageNotFoundException(messageId);

            message.IsPinned = !message.IsPinned;
            await _messageRepo.SaveMessageAsync(message);
        }

        private async Task PublishToKafkaAsync(string topic, MessageEvent messageEvent)
        {
            // In production, this uses Confluent.Kafka Producer
            _logger.LogDebug(
                "Publishing event {EventType} for message {MessageId} to Kafka topic {Topic}",
                messageEvent.EventType, messageEvent.MessageId, topic);
            await Task.CompletedTask;
        }
    }

    // ============================================================
    // WebSocket Connection Manager
    // ============================================================

    public class WebSocketConnectionManager
    {
        private readonly ConcurrentDictionary<Guid, ClientConnection> _connections = new();
        private readonly IConnectionMultiplexer _redis;
        private readonly ILogger<WebSocketConnectionManager> _logger;

        public WebSocketConnectionManager(
            IConnectionMultiplexer redis,
            ILogger<WebSocketConnectionManager> logger)
        {
            _redis = redis;
            _logger = logger;
        }

        public int OnlineCount => _connections.Count;

        public async Task RegisterConnectionAsync(
            Guid userId, Guid workspaceId, string[] subscribedChannels)
        {
            var connection = new ClientConnection
            {
                UserId = userId,
                WorkspaceId = workspaceId,
                SubscribedChannels = new HashSet<string>(subscribedChannels),
                ConnectedAt = DateTime.UtcNow,
                LastHeartbeat = DateTime.UtcNow
            };

            _connections[userId] = connection;

            // Store connection info in Redis for cross-server visibility
            var db = _redis.GetDatabase();
            var presenceKey = $"presence:{userId}";
            await db.StringSetAsync(presenceKey, "online", TimeSpan.FromSeconds(60));

            // Register in channel subscriber sets
            foreach (var channel in subscribedChannels)
            {
                await db.SetAddAsync($"channel:subscribers:{channel}", userId.ToString());
            }

            _logger.LogInformation(
                "User {UserId} connected, subscribed to {Count} channels",
                userId, subscribedChannels.Length);
        }

        public async Task UnregisterConnectionAsync(Guid userId)
        {
            if (_connections.TryRemove(userId, out var connection))
            {
                var db = _redis.GetDatabase();
                await db.StringSetAsync($"presence:{userId}", "offline", TimeSpan.FromMinutes(5));

                foreach (var channel in connection.SubscribedChannels)
                {
                    await db.SetRemoveAsync(
                        $"channel:subscribers:{channel}", userId.ToString());
                }

                _logger.LogInformation(
                    "User {UserId} disconnected after {Duration}",
                    userId, DateTime.UtcNow - connection.ConnectedAt);
            }
        }

        public async Task RefreshHeartbeatAsync(Guid userId)
        {
            if (_connections.TryGetValue(userId, out var conn))
            {
                conn.LastHeartbeat = DateTime.UtcNow;
                var db = _redis.GetDatabase();
                await db.KeyExpireAsync($"presence:{userId}", TimeSpan.FromSeconds(60));
            }
        }

        public bool IsUserOnline(Guid userId)
        {
            return _connections.ContainsKey(userId);
        }

        public async Task<int> GetOnlineCountInChannelAsync(Guid channelId)
        {
            var db = _redis.GetDatabase();
            var members = await db.SetMembersAsync(
                $"channel:subscribers:{channelId.ToString()}");
            return members.Count(m =>
                _connections.ContainsKey(Guid.Parse(m.ToString())));
        }

        public async Task BroadcastToChannelAsync(
            string channelId, string message)
        {
            var db = _redis.GetDatabase();
            var subscribers = await db.SetMembersAsync(
                $"channel:subscribers:{channelId}");

            var buffer = Encoding.UTF8.GetBytes(message);
            var tasks = new List<Task>();

            foreach (var subscriber in subscribers)
            {
                var userId = Guid.Parse(subscriber.ToString());
                if (_connections.TryGetValue(userId, out var conn) &&
                    conn.WebSocket != null &&
                    conn.WebSocket.State == System.Net.WebSockets.WebSocketState.Open)
                {
                    tasks.Add(conn.WebSocket.SendAsync(
                        new ArraySegment<byte>(buffer),
                        System.Net.WebSockets.WebSocketMessageType.Text,
                        true,
                        CancellationToken.None));
                }
            }

            await Task.WhenAll(tasks);
        }

        public List<Guid> GetStaleConnections(TimeSpan threshold)
        {
            var stale = new List<Guid>();
            var cutoff = DateTime.UtcNow - threshold;

            foreach (var kvp in _connections)
            {
                if (kvp.Value.LastHeartbeat < cutoff)
                    stale.Add(kvp.Key);
            }

            return stale;
        }
    }

    public class ClientConnection
    {
        public Guid UserId { get; set; }
        public Guid WorkspaceId { get; set; }
        public HashSet<string> SubscribedChannels { get; set; } = new();
        public DateTime ConnectedAt { get; set; }
        public DateTime LastHeartbeat { get; set; }
        public System.Net.WebSockets.WebSocket? WebSocket { get; set; }
    }

    // ============================================================
    // Presence Service
    // ============================================================

    public class PresenceService
    {
        private readonly IConnectionMultiplexer _redis;
        private readonly ILogger<PresenceService> _logger;

        public PresenceService(
            IConnectionMultiplexer redis,
            ILogger<PresenceService> logger)
        {
            _redis = redis;
            _logger = logger;
        }

        public async Task UpdatePresenceAsync(Guid userId, PresenceState state)
        {
            var db = _redis.GetDatabase();
            var key = $"presence:{userId}";
            var ttl = state == PresenceState.Offline
                ? TimeSpan.FromMinutes(5)
                : TimeSpan.FromSeconds(60);

            await db.StringSetAsync(key, state.ToString(), ttl);

            var update = new PresenceUpdate
            {
                UserId = userId,
                State = state,
                Timestamp = DateTime.UtcNow
            };

            await db.PublishAsync("presence-updates",
                JsonSerializer.Serialize(update));

            _logger.LogDebug(
                "Presence updated: User {UserId} is now {State}",
                userId, state);
        }

        public async Task<PresenceState> GetPresenceAsync(Guid userId)
        {
            var db = _redis.GetDatabase();
            var value = await db.StringGetAsync($"presence:{userId}");

            if (!value.HasValue)
                return PresenceState.Offline;

            return Enum.TryParse<PresenceState>(value.ToString(), out var state)
                ? state
                : PresenceState.Offline;
        }

        public async Task<Dictionary<Guid, PresenceState>> GetBulkPresenceAsync(
            List<Guid> userIds)
        {
            var db = _redis.GetDatabase();
            var tasks = userIds.Select(async userId =>
            {
                var state = await GetPresenceAsync(userId);
                return (userId, state);
            });

            var results = await Task.WhenAll(tasks);
            return results.ToDictionary(r => r.userId, r => r.state);
        }
    }

    // ============================================================
    // Channel Service
    // ============================================================

    public class ChannelService
    {
        private readonly IChannelRepository _channelRepo;
        private readonly IConnectionMultiplexer _redis;
        private readonly ILogger<ChannelService> _logger;

        public ChannelService(
            IChannelRepository channelRepo,
            IConnectionMultiplexer redis,
            ILogger<ChannelService> logger)
        {
            _channelRepo = channelRepo;
            _redis = redis;
            _logger = logger;
        }

        public async Task<Channel> CreateChannelAsync(
            Guid workspaceId, Guid creatorUserId,
            string name, ChannelType type, string purpose = "")
        {
            var channel = new Channel
            {
                WorkspaceId = workspaceId,
                Name = name,
                Type = type,
                CreatedBy = creatorUserId,
                Purpose = purpose,
                MemberCount = 1
            };

            channel = await _channelRepo.CreateChannelAsync(channel);

            // Add creator as owner
            await _channelRepo.AddMemberAsync(new ChannelMember
            {
                ChannelId = channel.Id,
                UserId = creatorUserId,
                Role = UserRole.Owner
            });

            // Cache channel info
            var db = _redis.GetDatabase();
            await db.HashSetAsync($"channel:{channel.Id}",
                new HashEntry[]
                {
                    new("name", name),
                    new("type", type.ToString()),
                    new("workspace_id", workspaceId.ToString()),
                    new("member_count", 1)
                });

            _logger.LogInformation(
                "Channel {ChannelId} ({Name}) created in workspace {WorkspaceId} by {UserId}",
                channel.Id, name, workspaceId, creatorUserId);

            return channel;
        }

        public async Task JoinChannelAsync(Guid channelId, Guid userId)
        {
            var channel = await _channelRepo.GetChannelAsync(channelId)
                ?? throw new ChannelNotFoundException(channelId);

            if (channel.Type == ChannelType.Private)
            {
                throw new UnauthorizedAccessException(
                    "Cannot join a private channel without an invitation");
            }

            var existing = await _channelRepo.IsMemberAsync(channelId, userId);
            if (existing)
            {
                throw new InvalidOperationException("User is already a member");
            }

            await _channelRepo.AddMemberAsync(new ChannelMember
            {
                ChannelId = channelId,
                UserId = userId,
                Role = UserRole.Member
            });

            var db = _redis.GetDatabase();
            await db.SetAddAsync($"channel:subscribers:{channelId}",
                userId.ToString());
        }

        public async Task<List<Channel>> GetWorkspaceChannelsAsync(
            Guid userId, ChannelType? filterType = null)
        {
            var channels = await _channelRepo.GetUserChannelsAsync(userId);

            if (filterType.HasValue)
                channels = channels.Where(c => c.Type == filterType.Value).ToList();

            return channels;
        }

        public async Task UpdateTopicAsync(
            Guid channelId, Guid userId, string topic)
        {
            var channel = await _channelRepo.GetChannelAsync(channelId)
                ?? throw new ChannelNotFoundException(channelId);

            channel.Topic = topic;
            await _channelRepo.CreateChannelAsync(channel); // upsert
        }
    }

    // ============================================================
    // Notification Service
    // ============================================================

    public class NotificationService : INotificationService
    {
        private readonly IConnectionMultiplexer _redis;
        private readonly WebSocketConnectionManager _connectionManager;
        private readonly ILogger<NotificationService> _logger;

        public NotificationService(
            IConnectionMultiplexer redis,
            WebSocketConnectionManager connectionManager,
            ILogger<NotificationService> logger)
        {
            _redis = redis;
            _connectionManager = connectionManager;
            _logger = logger;
        }

        public async Task DispatchNotificationAsync(
            MessageEvent messageEvent, List<Guid> recipientIds)
        {
            foreach (var recipientId in recipientIds)
            {
                // Skip the sender
                if (recipientId == messageEvent.UserId)
                    continue;

                // Check if user is online (push via WebSocket)
                if (_connectionManager.IsUserOnline(recipientId))
                {
                    _logger.LogDebug(
                        "User {UserId} is online, will receive via WebSocket",
                        recipientId);
                    continue; // WebSocket handles it
                }

                // Check DND status
                var db = _redis.GetDatabase();
                var dndKey = $"dnd:{recipientId}";
                var isDnd = await db.KeyExistsAsync(dndKey);

                if (isDnd)
                {
                    // Buffer notification for later delivery
                    await db.ListRightPushAsync(
                        $"notification-buffer:{recipientId}",
                        JsonSerializer.Serialize(messageEvent));
                    _logger.LogDebug(
                        "Notification buffered for DND user {UserId}", recipientId);
                    continue;
                }

                // Queue push notification (APNs / FCM)
                _logger.LogInformation(
                    "Push notification queued for user {UserId} from message {MessageId}",
                    recipientId, messageEvent.MessageId);
            }

            await Task.CompletedTask;
        }
    }

    // ============================================================
    // Search Service Implementation
    // ============================================================

    public class ElasticsearchSearchService : ISearchService
    {
        private readonly ILogger<ElasticsearchSearchService> _logger;

        public ElasticsearchSearchService(
            ILogger<ElasticsearchSearchService> logger)
        {
            _logger = logger;
        }

        public async Task IndexMessageAsync(
            Message message, Channel channel, User user)
        {
            _logger.LogDebug(
                "Indexing message {MessageId} in channel {ChannelId}",
                message.Id, channel.Id);

            // In production: POST to Elasticsearch _bulk API
            var document = new
            {
                message_id = message.Id,
                channel_id = message.ChannelId,
                workspace_id = channel.WorkspaceId,
                user_id = message.UserId,
                user_name = user.DisplayName,
                channel_name = channel.Name,
                content = message.Content,
                content_type = message.ContentType.ToString(),
                thread_id = message.ThreadId,
                mentions = message.Mentions,
                reaction_count = message.Reactions.Count,
                created_at = message.CreatedAt,
                is_deleted = message.IsDeleted
            };

            await Task.CompletedTask;
        }

        public async Task<List<SearchResult>> SearchAsync(
            string query, Guid workspaceId, SearchFilters? filters = null)
        {
            _logger.LogInformation(
                "Searching for '{Query}' in workspace {WorkspaceId}", query, workspaceId);

            // In production: Build Elasticsearch query with bool must/filter clauses
            var results = new List<SearchResult>();

            await Task.CompletedTask;
            return results;
        }
    }

    // ============================================================
    // Custom Exceptions
    // ============================================================

    public class DuplicateMessageException : Exception
    {
        public string IdempotencyKey { get; }
        public DuplicateMessageException(string key)
            : base($"Duplicate message with key {key}") =>
            IdempotencyKey = key;
    }

    public class ChannelNotFoundException : Exception
    {
        public Guid ChannelId { get; }
        public ChannelNotFoundException(Guid id)
            : base($"Channel {id} not found") => ChannelId = id;
    }

    public class MessageNotFoundException : Exception
    {
        public Guid MessageId { get; }
        public MessageNotFoundException(Guid id)
            : base($"Message {id} not found") => MessageId = id;
    }
}

26. Slack Connect & Cross-Workspace Collaboration

Slack Connect is one of the platform's most transformative enterprise features, enabling organizations to communicate with external partners, vendors, and clients through shared channels that bridge separate workspaces. Rather than relying on email threads or ad-hoc file sharing, teams collaborate in a single shared environment where messages, files, and integrations flow seamlessly across organizational boundaries. This capability fundamentally changes how enterprises think about inter-company communication, replacing fragmented multi-tool workflows with a unified collaboration surface.

26.1 Shared Channel Architecture

When a channel is shared between two or more workspaces, the system must solve several complex distributed systems challenges: message replication across workspace data stores, consistent message ordering despite clock skew between organizations, permission model reconciliation, and compliance boundary enforcement. Each shared channel maintains a unique global identifier that is distinct from its workspace-local channel ID. Messages posted to the shared channel are written to both workspaces' databases, ensuring each workspace retains its own authoritative copy. A shared channel metadata record tracks all participating workspaces, their access levels, and the origin workspace for each message.

graph TB subgraph "Workspace A — Acme Corp" SA_DB[(PostgreSQL
Workspace A)] SA_WS[WebSocket Gateway] SA_MSG[Message Service A] end subgraph "Shared Channel Hub" SCH[Shared Channel
Replication Service] SCH_DB[(Shared Channel
Metadata Store)] end subgraph "Workspace B — Globex Inc" SB_DB[(PostgreSQL
Workspace B)] SB_WS[WebSocket Gateway] SB_MSG[Message Service B] end SA_MSG -->|Write + Replicate| SCH SCH --> SCH_DB SCH -->|Replicate| SB_MSG SB_MSG -->|Write + Replicate| SCH SCH -->|Replicate| SA_MSG SA_WS -->|Push via WS| SA_DB SB_WS -->|Push via WS| SB_DB

The replication service sits between workspaces and handles several critical responsibilities. It normalizes message formats since different workspaces may have different emoji sets or custom reactions. It enforces per-workspace policies — for example, Workspace A may have a DLP rule that blocks credit card numbers while Workspace B does not. The replication service applies the originating workspace's DLP rules to outgoing messages and the receiving workspace's rules to incoming messages, providing a bidirectional compliance layer. It also manages channel membership synchronization, ensuring that when a user is removed from the shared channel in Workspace A, the change is reflected across all connected workspaces.

26.2 Permission Model Across Organizations

Each workspace in a shared channel retains independent permission control. Workspace A may designate certain members as channel admins within the shared channel, but those admin rights only apply to Workspace A's members. A Workspace A admin cannot remove Workspace B members from the channel. The system tracks per-workspace roles in a shared_channel_members table that includes a workspace_id discriminator. This design ensures that organizational sovereignty is preserved — each company maintains full control over its own members while sharing a common communication space.

PermissionSame WorkspaceCross-WorkspaceDescription
Send messagesYes (if member)Yes (if member)All channel members can post regardless of workspace
Add workspace membersYes (admin+)NoOnly the originating workspace can add its own members
Remove own membersYes (admin+)Yes (own workspace admin)Each workspace manages its own membership independently
Edit channel topicYes (owner+)Yes (owner+)Topic is shared — edits by either workspace owner apply globally
Archive channelYes (owner+)Origin workspace onlyOnly the workspace that created the channel can archive it
View file historyYesWorkspace-scopedFiles uploaded by Workspace A members are visible to Workspace B, but metadata access is scoped
Install integrationsYes (admin+)Workspace-scopedEach workspace installs bots independently; bots only see events from their own workspace's users
Apply DLP policiesWorkspace-wideBidirectionalBoth workspaces' DLP policies are applied to messages flowing through the shared channel

26.3 Slack Connect at Scale — Engineering Considerations

At the infrastructure level, shared channels introduce cross-shard communication. Since our sharding strategy partitions data by workspace_id, messages for a shared channel must be written to two different shards (one per workspace). The Shared Channel Replication Service handles this by writing to the originating workspace's shard first (synchronous, for low latency) and then replicating to the destination workspace's shard asynchronously. The replication lag is typically under 200 milliseconds. If the destination workspace's shard is temporarily unavailable, the message is queued in a dedicated Kafka topic (shared-channel-replication) with at-least-once delivery semantics and a dead-letter queue for repeated failures.

Clock synchronization between workspaces is handled using Hybrid Logical Timestamps (HLC) rather than wall-clock time. An HLC combines a physical timestamp component with a logical counter, ensuring that messages from two different workspaces always have a total order even if their clocks differ by up to several seconds. The replication service stamps each replicated message with the HLC from the originating workspace, and the receiving workspace uses this timestamp for ordering rather than its local clock. This eliminates the need for cross-workspace clock synchronization while maintaining consistent message ordering.

Security for shared channels extends beyond DLP. All cross-workspace communication is encrypted in transit using TLS 1.3. At rest, each workspace's data is encrypted with its own KMS key, meaning that Workspace B cannot decrypt Workspace A's data at rest even if it has access to the shared channel messages in transit. Session tokens and authentication credentials are never shared between workspaces — each workspace maintains its own OAuth flow and token lifecycle. Audit logs record all cross-workspace message events, and enterprise administrators can review shared channel activity through a consolidated compliance dashboard.

// Shared Channel Replication Model
public class SharedChannelReplication
{
    private readonly IMessageRepository _messageRepo;
    private readonly IWorkspaceRepository _workspaceRepo;
    private readonly IKafkaProducer _kafkaProducer;
    private readonly ILogger<SharedChannelReplication> _logger;

    public async Task ReplicateMessageAsync(
        SharedChannelMessage message, Guid originWorkspaceId)
    {
        // Step 1: Write to originating workspace shard (synchronous)
        message.OriginWorkspaceId = originWorkspaceId;
        message.HybridLogicalClock = await _hlcService.GetNextTimestampAsync();
        await _messageRepo.SaveMessageAsync(message);

        // Step 2: Identify all connected workspaces
        var connectedWorkspaces = await _workspaceRepo
            .GetConnectedWorkspacesAsync(message.SharedChannelId);

        foreach (var targetWorkspace in connectedWorkspaces
            .Where(w => w.Id != originWorkspaceId))
        {
            // Step 3: Apply target workspace DLP rules
            var dlpResult = await _dlpService
                .EvaluateAsync(message.Content, targetWorkspace.Id);

            if (dlpResult.IsBlocked)
            {
                _logger.LogWarning(
                    "Message {MessageId} blocked by DLP for workspace {WorkspaceId}: {Reason}",
                    message.Id, targetWorkspace.Id, dlpResult.Reason);
                continue;
            }

            // Step 4: Replicate asynchronously to target shard
            var replicateEvent = new ReplicationEvent
            {
                MessageId = message.Id,
                SharedChannelId = message.SharedChannelId,
                TargetWorkspaceId = targetWorkspace.Id,
                Content = message.Content,
                HLC = message.HybridLogicalClock,
                CreatedAt = message.CreatedAt
            };

            await _kafkaProducer.ProduceAsync(
                "shared-channel-replication", replicateEvent);
        }
    }
}

Shared channels also interact with the search system. When a user in Workspace A searches their messages, the search index includes messages from Workspace B users that were posted to shared channels the user is a member of. The Elasticsearch index stores a workspace_access array for each message, listing the workspace IDs that have visibility. Search queries filter on this array to ensure results respect cross-workspace access boundaries. This means that a user cannot search for messages in a shared channel they are not a member of, even though the message physically exists in both workspaces' data stores.

27. Slack AI & Intelligence Features

Slack AI represents the integration of large language models and machine learning capabilities directly into the messaging platform, transforming Slack from a communication tool into an intelligent collaboration assistant. These features process the vast corpus of channel messages, threads, and files to surface relevant information, summarize lengthy discussions, and answer natural language questions grounded in organizational knowledge. The AI layer operates as a separate service cluster that reads from the same Kafka event bus used by search and notifications, maintaining its own inference infrastructure with GPU-backed model serving endpoints.

27.1 Channel & Thread Summaries

Channel summaries use a retrieval-augmented generation (RAG) pipeline to produce concise overviews of channel activity. When a user opens a channel they have not visited in a while, or clicks the "Summarize" button, the system fetches the unread messages since their last read timestamp, groups them by topic and thread, and passes the grouped content to a fine-tuned language model. The model produces a structured summary with key discussion points, decisions made, and action items. Thread summaries work similarly but are scoped to a single thread's reply chain, producing a compressed overview of a long discussion that may contain dozens or hundreds of replies.

sequenceDiagram participant User participant API as API Gateway participant AI as AI Service participant RET as Retrieval Service participant LLM as LLM Inference participant CACHE as Summary Cache User->>API: GET /api/ai/channel-summary?channel_id=X API->>AI: Request channel summary AI->>CACHE: Check cached summary alt Cache hit CACHE-->>AI: Return cached summary else Cache miss AI->>RET: Fetch unread messages since last_read RET-->>AI: Messages (last 500 unread) AI->>AI: Group messages by topic and thread AI->>LLM: Summarize grouped content LLM-->>AI: Structured summary AI->>CACHE: Store summary (TTL: 15 min) end AI-->>API: Summary with key points API-->>User: Channel summary response

The retrieval step is critical for accuracy and relevance. Rather than passing hundreds of raw messages to the LLM, the retrieval service first clusters messages using sentence embeddings, identifies the most significant threads based on engagement metrics (reply count, reaction count, participant diversity), and ranks message clusters by information density. Only the top clusters are passed to the LLM, keeping the input token count manageable while ensuring the summary captures the most important discussions. The system also respects file-sharing events, including references to shared documents in the summary ("@alice shared a design doc for the API redesign" rather than omitting file references).

27.2 Search Answers

Search Answers augment the traditional keyword-based search with natural language question answering. When a user types a question like "What was the decision about the new authentication provider?" instead of requiring them to scan through search results, the AI system first identifies relevant messages using semantic search (embedding similarity), then passes those messages to the LLM with a prompt asking it to answer the question based on the provided context. The response includes the answer along with source citations — clickable links to the specific messages that informed the answer. This allows users to verify the answer by reading the original discussion.

The semantic search component uses a dual-encoder model for query-document similarity. The user's query is encoded into a vector embedding, and this embedding is compared against pre-computed embeddings of messages stored in a vector database (Pinecone or pgvector). The top 20 most similar messages are retrieved and passed to the LLM as context. The LLM is prompted with a "answer only from the provided context" constraint to prevent hallucination — if the context does not contain enough information to answer the question, the system returns "I couldn't find enough information to answer this question" rather than fabricating an answer.

AI FeatureInputProcessingOutputLatency Target
Channel SummaryUnread messages (up to 500)Topic clustering → RAG summarizationBullet-point summary with decisions and action items< 3 seconds
Thread RecapThread replies (up to 200)Sequential summarization with context windowingConcise recap with key viewpoints< 2 seconds
Search AnswerNatural language questionSemantic search → RAG answer generationAnswer with source message citations< 4 seconds
Message TranslationMessage content + target languageContext-aware translationTranslated message with "Translated from English" label< 1 second
Action Item ExtractionChannel or thread messagesNER + relation extractionList of action items with owners and deadlines< 3 seconds
Meeting Notes GenerationHuddle transcript + thread contextTranscript processing → structured notesFormatted meeting notes with attendees, topics, decisions< 5 seconds
Smart NotificationsMessage content + user contextUrgency classification + relevance scoringPriority-ranked notifications with relevance explanations< 500ms
Emoji SuggestionMessage contentSentiment analysis + emoji mappingTop 3 suggested emoji reactions< 200ms

27.3 Conversation Summaries and AI-Powered Thread Recaps

AI-powered thread recaps are one of the most valued features for enterprise users dealing with long-running threads. A thread with 50+ replies is difficult to follow, especially for someone joining mid-conversation. The recap system processes the entire thread, identifies key decision points and consensus shifts, and produces a narrative summary that captures the evolution of the discussion. For example, a thread recap might read: "The team initially proposed using Redis for caching (3 votes), then discussed DynamoDB as an alternative (2 votes pointing out better scalability), and ultimately decided on Redis with a fallback migration plan to DynamoDB in Q4. The primary concern was operational complexity."

The thread recap model uses a hierarchical summarization approach. First, messages are grouped into logical segments based on topic shifts (detected via embedding similarity between consecutive messages). Each segment is summarized independently. Then, a second pass summarizes across segment summaries, producing the final thread recap. This two-level approach handles threads with tens of thousands of tokens that would otherwise exceed the LLM's context window. The segment boundaries are stored alongside the summary, enabling users to click on a summary point and jump to the corresponding section of the thread.

The infrastructure powering Slack AI runs on a dedicated GPU cluster separate from the main messaging infrastructure. This isolation ensures that AI workloads — which are bursty and compute-intensive — do not impact the latency-sensitive message delivery pipeline. The AI service cluster includes model serving endpoints (using NVIDIA Triton Inference Server or similar), a vector database for semantic search embeddings, a summarization cache backed by Redis (TTL 15 minutes for channel summaries, 1 hour for thread recaps), and a feedback loop that collects user ratings on summary quality to fine-tune the models over time. All AI processing is subject to data residency requirements — a workspace hosted in EU-West has its AI processing routed to EU-based GPU nodes to ensure GDPR compliance.

// AI Summary Service
public class AiSummaryService
{
    private readonly IMessageRepository _messageRepo;
    private readonly IVectorDatabase _vectorDb;
    private readonly ILlmInferenceClient _llmClient;
    private readonly IDatabase _cache;
    private readonly ILogger<AiSummaryService> _logger;

    public async Task<ChannelSummary> GenerateChannelSummaryAsync(
        Guid channelId, Guid userId, DateTime since)
    {
        // Step 1: Check cache
        var cacheKey = $"ai:summary:channel:{channelId}:{since:yyyyMMdd}";
        var cached = await _cache.StringGetAsync(cacheKey);
        if (cached.HasValue)
            return JsonSerializer.Deserialize<ChannelSummary>(cached!);

        // Step 2: Fetch unread messages
        var messages = await _messageRepo
            .GetChannelHistoryAsync(channelId, limit: 500, before: null);
        var unread = messages
            .Where(m => m.CreatedAt >= since && !m.IsDeleted)
            .ToList();

        if (unread.Count == 0)
            return new ChannelSummary { Empty = true, Message = "No new messages" };

        // Step 3: Cluster messages by topic using embeddings
        var clusters = await _vectorDb
            .ClusterMessagesAsync(unread, numClusters: 10);

        // Step 4: Rank clusters by engagement and information density
        var rankedClusters = clusters
            .OrderByDescending(c => c.EngagementScore)
            .ThenByDescending(c => c.MessageCount)
            .Take(5)
            .ToList();

        // Step 5: Generate summary via LLM
        var prompt = BuildSummaryPrompt(rankedClusters);
        var llmResponse = await _llmClient.GenerateAsync(prompt,
            maxTokens: 512,
            temperature: 0.3f);

        var summary = new ChannelSummary
        {
            ChannelId = channelId,
            KeyPoints = llmResponse.KeyPoints,
            Decisions = llmResponse.Decisions,
            ActionItems = llmResponse.ActionItems,
            GeneratedAt = DateTime.UtcNow,
            MessageCount = unread.Count,
            TimeRange = (unread.Last().CreatedAt - unread.First().CreatedAt)
        };

        // Step 6: Cache result
        await _cache.StringSetAsync(cacheKey,
            JsonSerializer.Serialize(summary),
            TimeSpan.FromMinutes(15));

        return summary;
    }

    public async Task<ThreadRecap> GenerateThreadRecapAsync(Guid threadId)
    {
        var messages = await _messageRepo.GetThreadRepliesAsync(threadId, 200);

        // Hierarchical summarization: segment → summarize → merge
        var segments = SegmentThreadByTopic(messages);
        var segmentSummaries = new List<string>();

        foreach (var segment in segments)
        {
            var segmentSummary = await _llmClient.GenerateAsync(
                $"Summarize this thread segment concisely:\n{string.Join("\n", segment.Select(m => $"{m.UserId}: {m.Content}"))}",
                maxTokens: 128,
                temperature: 0.2f);
            segmentSummaries.Add(segmentSummary.Text);
        }

        var finalRecap = await _llmClient.GenerateAsync(
            $"Create a narrative recap of this discussion:\n{string.Join("\n---\n", segmentSummaries)}",
            maxTokens: 256,
            temperature: 0.3f);

        return new ThreadRecap
        {
            ThreadId = threadId,
            Recap = finalRecap.Text,
            SegmentCount = segments.Count,
            TotalMessages = messages.Count
        };
    }

    private List<List<Message>> SegmentThreadByTopic(List<Message> messages)
    {
        var segments = new List<List<Message>>();
        var currentSegment = new List<Message> { messages[0] };

        for (int i = 1; i < messages.Count; i++)
        {
            var similarity = _embeddingService
                .CosineSimilarity(messages[i - 1].Content, messages[i].Content);

            if (similarity < 0.4f)
            {
                segments.Add(currentSegment);
                currentSegment = new List<Message>();
            }
            currentSegment.Add(messages[i]);
        }
        segments.Add(currentSegment);
        return segments;
    }
}

The user experience for AI features is designed to be optional and transparent. Summaries are never shown automatically — the user must explicitly request them by clicking a "Summarize" button or using the /ai summarize slash command. Each summary includes a disclosure that it was AI-generated, along with a feedback widget (thumbs up/down) that feeds into the model improvement pipeline. Enterprise administrators can disable AI features entirely for their workspace if required by policy, and individual users can opt out of having their messages included in summary generation for other users. This opt-out is enforced at the retrieval layer — when building the context for a summary, messages from opted-out users are filtered before being passed to the LLM.

28. Conclusion

Designing an enterprise messaging system like Slack requires careful consideration of numerous interconnected components. From the message send pipeline that guarantees per-channel ordering, to the real-time WebSocket infrastructure that delivers messages in under 200 milliseconds, to the search system that indexes billions of messages, every component must be engineered for reliability, performance, and scale.

The key architectural decisions we covered include:

  • Event-Driven Architecture: Kafka as the backbone for decoupling write-path from search indexing, notifications, and compliance processing
  • Per-Channel Sharding: Using workspace_id as the shard key and monthly partitioning for messages, enabling efficient workspace-local queries
  • Redis-Powered Real-Time: Redis PubSub for WebSocket fan-out, Redis for presence state, and Redis for caching hot data
  • Hybrid Fan-Out: WebSocket push for online users, push notifications for offline users, batch digests for muted channels
  • Idempotent Operations: Client-generated idempotency keys prevent duplicate messages across retries
  • Multi-Region with Strong Consistency: Synchronous replication within region, async across regions, with conflict resolution for shared channels

Building a Slack-scale messaging system is a massive engineering undertaking, but the principles and patterns discussed in this article provide a solid foundation. The C# implementation demonstrates how the core services interact in practice, covering message sending, WebSocket management, presence tracking, notifications, and search.

For system design interviews, focus on the message delivery pipeline end-to-end, the trade-offs between consistency and availability, and the specific numbers that drive your capacity decisions. Understanding the "why" behind each architectural choice is more important than memorizing the components. Be prepared to dive deep into any subsystem — whether it is the fan-out strategy for large channels, the search indexing pipeline, the WebSocket connection lifecycle, or the multi-region failover mechanism. Interviewers at senior+ levels expect you to reason about trade-offs under real-world constraints, not just recite textbook patterns.

The most common pitfall in designing messaging systems is underestimating the complexity of the "last mile" — getting messages delivered reliably to millions of concurrent clients with sub-second latency while maintaining strict ordering guarantees, handling presence, and processing millions of bot-triggered events simultaneously. The secondary challenge is building a search system that can handle billions of documents with sub-second query latency while supporting complex boolean queries, date range filters, and personalized ranking. These are the areas where interviewers will probe deepest, and where the difference between a senior and staff-level answer lies.

As you build and scale messaging systems in production, remember that the architecture is never truly "done." The requirements evolve — from adding AI-powered message summaries and smart notifications, to supporting ephemeral messaging and disappearing messages, to integrating large language models as conversational agents within channels. Each new feature demands careful consideration of how it fits into the existing architecture, how it impacts performance at scale, and how it maintains the security and compliance guarantees that enterprise customers depend on.

Further Reading:
  • Slack Engineering Blog: Architecture of a Real-Time Distributed System
  • Martin Kleppmann: Designing Data-Intensive Applications (Chapters on Replication, Partitioning, and Transactions)
  • Real-World Distributed Systems: Discord, Slack, and Teams Architecture Deep Dives
  • AWS Well-Architected Framework: Reliability Pillar

Written by Ayodhyya • Last updated July 14, 2026

© 2026 Ayodhyya. All rights reserved.

System Design Articles for Senior Engineers