system-design51 min read

How to Design Substack — Newsletter Monetization Platform — A Senior+ Guide

How to Design Substack — Newsletter Monetization Platform — A Senior+ Guide

System design deep-dive: building a platform that empowers millions of writers to earn revenue through independent newsletters, podcasts, and community engagement.

Article #192 Published: April 28, 2024 Reading Time: ~45 min Category: System Design

1. Introduction: Substack at Scale

Substack has fundamentally transformed how writers, journalists, and creators monetize their work. What began in 2017 as a simple platform for independent writers to publish newsletters has grown into an ecosystem that serves millions of subscribers across hundreds of thousands of active publications. The platform handles the delivery of billions of emails annually, processes tens of millions of dollars in subscription revenue through Stripe integration, and has become a critical infrastructure for the creator economy. Understanding how to design such a system at scale requires deep consideration of email deliverability, payment processing, content distribution, real-time engagement, and the delicate balance between platform control and creator independence.

When we look at the numbers behind Substack, the scale becomes apparent. The platform hosts over 35 million active subscriptions, with some individual publications boasting hundreds of thousands of paid subscribers. Top writers on the platform earn millions of dollars annually, which means the payment infrastructure must be rock-solid with zero tolerance for lost transactions. Email delivery rates must exceed 99.5% to maintain trust with both writers and readers. The system must handle massive spikes in traffic when popular newsletters publish simultaneously, such as during major news events when millions of readers open their inboxes within a narrow time window.

The design challenges are multifaceted. Unlike a traditional SaaS platform, Substack must simultaneously serve two distinct user personas with very different needs. Writers need powerful composition tools, analytics dashboards, payment management, and audience growth features. Readers need a clean, distraction-free reading experience, seamless subscription management, discovery of new content, and reliable delivery of newsletters to their inboxes. Both personas expect real-time responsiveness, and the platform must scale gracefully as the writer's audience grows from tens to hundreds of thousands of subscribers.

Furthermore, Substack's business model creates unique technical requirements. The platform takes a 10% cut of all paid subscription revenue, which means every transaction must be tracked with precision, fraud must be prevented without creating friction for legitimate subscribers, and the financial reporting must be accurate to the penny. The platform also offers features like Substack Reads (a recommendation engine), Notes (a social microblogging feature), podcast hosting, and community comments — each of which introduces its own set of scaling challenges and architectural decisions.

In this system design deep-dive, we will dissect every major subsystem of a platform like Substack, exploring the database schemas, API designs, scaling strategies, and architectural patterns required to build a production-ready newsletter monetization platform. We will examine how to handle the complexities of email deliverability at scale, the integration with payment processors for global subscription management, the implementation of a recommendation engine that helps readers discover new publications, and the real-time analytics that give writers actionable insights into their audience engagement. This guide is written for senior engineers and architects who need to understand not just the what, but the why behind each architectural decision.

2. Platform Overview

Substack's platform serves as a complete ecosystem for independent publishing and monetization. At its core, the platform offers four distinct tiers of content delivery and revenue models that writers can leverage to build sustainable businesses. Understanding these tiers is essential because each one introduces different technical requirements, payment flows, and user experience considerations that ripple through the entire system architecture.

2.1 Free Newsletters

Free newsletters form the foundation of the Substack ecosystem. Writers publish content that readers can subscribe to without any payment. From a system design perspective, free newsletters still require robust email delivery infrastructure, subscriber management, and basic analytics. The key challenge here is scale — a popular free newsletter might have millions of subscribers, and every issue must be delivered reliably within minutes of publication. The free tier also serves as the top of the funnel for converting readers into paid subscribers, which means the reading experience must be compelling enough to motivate upgrades.

2.2 Paid Subscriptions

Paid subscriptions are where Substack's monetization model shines. Writers can set monthly or annual pricing, and Substack handles all payment processing through Stripe. The platform supports the complete subscription lifecycle including initial purchase, recurring billing, failed payment retry, subscription pauses, and cancellation. Substack's 10% revenue share is automatically deducted from each transaction. The technical complexity here involves handling currency conversion for international subscribers, managing subscription proration when plans change, processing refunds, and providing writers with accurate financial reporting that reconciles with Stripe's ledger.

2.3 Founding Subscriber Tiers

Founding subscriptions allow writers to offer premium tiers at higher price points, often with additional perks like direct access, exclusive content, or community membership. This feature requires the system to support multiple subscription tiers within a single publication, each with different pricing and content access rules. The backend must correctly enforce entitlement checks to ensure that only subscribers at the appropriate tier can access premium content.

2.4 Referral Program

Substack's referral program incentivizes existing subscribers to recruit new ones by offering rewards such as free subscription months or exclusive content. The referral system requires tracking referral chains, attributing new signups to referrers, managing reward fulfillment, and preventing abuse through fraud detection mechanisms. This introduces distributed counting challenges and event-driven processing requirements.

Tier Revenue Model Platform Fee Technical Complexity Key Features
Free Newsletter No revenue $0 Medium — scale-focused Email delivery, basic analytics, subscriber management
Paid Subscription Recurring billing (monthly/annual) 10% of revenue High — payment + billing Stripe integration, billing management, financial reporting
Founding Subscriber Premium recurring billing 10% of revenue High — multi-tier Tiered access, exclusive content, higher price points
Referral Program Growth incentive Absorbed Medium — attribution Referral tracking, reward fulfillment, fraud prevention

Beyond these core monetization tiers, Substack has expanded into adjacent content formats including podcast hosting with transcription, a Notes feature for short-form social content, and community features for comment threads. Each of these additions broadens the platform's value proposition while introducing new scaling requirements. The platform must serve as both a publishing tool and a consumer content platform, requiring careful architectural decisions to balance the needs of content creation with content consumption at scale.

3. System Architecture Overview

The high-level architecture of a Substack-like platform follows a microservices pattern with clear separation between content management, email delivery, payment processing, and reader-facing services. The architecture must support both synchronous request-response patterns for user interactions and asynchronous event-driven patterns for email delivery, analytics processing, and payment reconciliation. The following diagram illustrates the major components and their interactions.

graph TB subgraph "Client Layer" WEB["Web App
React/Next.js"] MOB["Mobile Apps
iOS / Android"] API_EXT["External APIs"] end subgraph "API Gateway" GW["API Gateway
Rate Limiting / Auth"] CDN["CDN
CloudFront / Fastly"] end subgraph "Core Services" PUB["Publication Service"] COMP["Composition Service"] SUB["Subscription Service"] PAY["Payment Service"] EMAIL["Email Delivery Service"] ANALYTICS["Analytics Service"] end subgraph "Supporting Services" RECO["Recommendation Engine"] NOTES["Notes Service"] PODCAST["Podcast Service"] COMMUNITY["Community Service"] MODERATION["Moderation Service"] SEARCH["Search Service"] end subgraph "Data Layer" PG["PostgreSQL
Primary DB"] REDIS["Redis
Cache / Sessions"] ES["Elasticsearch
Search Index"] S3["S3
Media Storage"] KAFKA["Kafka
Event Stream"] end subgraph "External Integrations" STRIPE["Stripe
Payments"] SMTP["SMTP Providers
SendGrid / Postmark"] ANALYTICS_EXT["Analytics Ext
Segment / Amplitude"] end WEB --> GW MOB --> GW GW --> CDN GW --> PUB GW --> COMP GW --> SUB GW --> PAY GW --> ANALYTICS PUB --> PG COMP --> PG COMP --> S3 SUB --> PG SUB --> REDIS PAY --> STRIPE PAY --> PG EMAIL --> SMTP EMAIL --> KAFKA ANALYTICS --> PG ANALYTICS --> KAFKA RECO --> PG RECO --> ES NOTES --> PG NOTES --> REDIS PODCAST --> S3 COMMUNITY --> PG MODERATION --> ES SEARCH --> ES

3.1 Service Decomposition

The Publication Service owns all CRUD operations for newsletters, posts, and author profiles. It manages the metadata layer — publication names, descriptions, branding settings, and author information. This service reads from and writes to the primary PostgreSQL database and exposes RESTful APIs for the web and mobile clients. It also integrates with the CDN for serving static assets like publication logos and author avatars. The service must handle versioning of publication metadata to support features like post scheduling and draft management without affecting the live publication state.

3.2 Composition Service

The Composition Service is responsible for the rich text editor experience, draft management, image uploads, and post scheduling. It handles the complex state management required for real-time collaborative editing (for multi-author publications), autosave functionality, and the conversion of editor content into email-compatible HTML. This service stores draft content in PostgreSQL with version history, manages media uploads to S3, and coordinates with the Email Delivery Service for scheduled publications. The editor content must be sanitized for security while preserving the formatting capabilities that writers expect.

3.3 Subscription and Payment Services

The Subscription Service manages the relationship between readers and publications, tracking subscription status, preferences, and notification settings. The Payment Service interfaces directly with Stripe to handle subscription creation, billing cycles, payment failures, and revenue reporting. These two services work closely together but maintain separation of concerns — the Subscription Service understands subscription semantics while the Payment Service understands payment provider specifics. This separation allows for potential future integration with alternative payment providers without modifying subscription logic.

3.4 Email Delivery Service

The Email Delivery Service is perhaps the most critical component from a reliability standpoint. It consumes published posts from the Kafka event stream, batches email delivery requests, personalizes email content (for features like recommendation inserts), and coordinates with multiple SMTP providers for redundancy. The service must handle rate limiting, bounce management, unsubscribe processing, and deliverability monitoring. It maintains its own queue of pending deliveries and tracks delivery status for each email sent, feeding this data back to the Analytics Service.

3.5 Data Flow Pattern

The data flow through the system follows a hub-and-spoke pattern centered on the Kafka event bus. When a writer publishes a post, the Composition Service writes the content to PostgreSQL and emits a PostPublished event to Kafka. The Email Delivery Service consumes this event and begins the delivery pipeline. The Analytics Service consumes the same event to initialize tracking metrics. The Recommendation Engine processes the event to update its index. This event-driven approach ensures loose coupling between services and allows each consumer to process events at its own pace without blocking the publisher.

4. Newsletter Composition Engine

The newsletter composition engine is the creative heart of the platform. Writers spend hours crafting their content, and the editor must provide a seamless, reliable, and feature-rich experience. The composition engine must handle rich text editing, embedded media, code blocks, LaTeX equations, custom HTML elements, and email-compatible formatting — all while providing real-time autosave and supporting collaborative editing for multi-author publications.

4.1 Rich Text Editor Architecture

The editor is built on top of a modern contenteditable framework such as TipTap (built on ProseMirror) or Plate.js (built on Slate). These frameworks provide a structured document model that separates the visual representation from the underlying content. This separation is critical because content must be rendered differently across three contexts: the web editor, the web reading view, and the email HTML output. The document model uses a tree of typed nodes (paragraphs, headings, lists, images, embeds, code blocks) that can be serialized into different output formats.

4.2 Draft Management and Autosave

Draft management requires careful handling of concurrent saves, version history, and offline resilience. The system uses a delta-based approach where each autosave sends only the changes since the last save, reducing bandwidth and server load. A debouncing mechanism ensures that saves are batched to avoid overwhelming the server during rapid editing sessions. The backend stores each draft as a versioned document, allowing writers to revert to any previous version.

C#
public class DraftVersion
{
    public Guid DraftId { get; set; }
    public int VersionNumber { get; set; }
    public string ContentJson { get; set; }
    public string ContentHtml { get; set; }
    public string ContentMarkdown { get; set; }
    public Guid AuthorId { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? PublishedAt { get; set; }
    public DraftStatus Status { get; set; }
    public List<MediaAttachment> Attachments { get; set; }
    public Dictionary<string, string> Metadata { get; set; }
}

public class DraftService
{
    private readonly IDraftRepository _repository;
    private readonly IEventBus _eventBus;
    private readonly IMediaStorageService _mediaStorage;

    public async Task<DraftVersion> SaveDraftAsync(
        Guid publicationId, string authorId, DraftSaveRequest request)
    {
        var currentDraft = await _repository.GetLatestDraftAsync(
            publicationId, authorId);

        var newVersion = new DraftVersion
        {
            DraftId = currentDraft?.DraftId ?? Guid.NewGuid(),
            VersionNumber = (currentDraft?.VersionNumber ?? 0) + 1,
            ContentJson = request.ContentJson,
            ContentHtml = ConvertToEmailHtml(request.ContentJson),
            ContentMarkdown = ConvertToMarkdown(request.ContentJson),
            AuthorId = Guid.Parse(authorId),
            CreatedAt = DateTime.UtcNow,
            Status = DraftStatus.Draft,
            Attachments = await ProcessAttachmentsAsync(request.MediaIds),
            Metadata = request.Metadata ?? new Dictionary<string, string>()
        };

        await _repository.SaveVersionAsync(newVersion);

        await _eventBus.PublishAsync(new DraftSavedEvent
        {
            DraftId = newVersion.DraftId,
            VersionNumber = newVersion.VersionNumber,
            AuthorId = authorId,
            SavedAt = newVersion.CreatedAt
        });

        return newVersion;
    }

    private string ConvertToEmailHtml(string contentJson)
    {
        var document = JsonSerializer.Deserialize<EditorDocument>(contentJson);
        var converter = new EmailHtmlConverter();
        return converter.Convert(document, new EmailHtmlOptions
        {
            InlineStyles = true,
            MaxWidth = 600,
            StripExternalStyles = true,
            FallbackFontFamily = "Georgia, serif"
        });
    }
}

4.3 Email HTML Rendering

One of the most complex challenges in the composition engine is converting rich editor content into email-compatible HTML. Email clients have notoriously poor support for modern CSS and HTML. Gmail strips most <style> tags, Outlook ignores many CSS properties, and Yahoo Mail has its own quirks. The rendering pipeline must inline all CSS styles, use table-based layouts for maximum compatibility, handle image hosting and lazy loading, and provide fallbacks for unsupported features. The rendering engine maintains a library of tested patterns that work across the major email clients.

4.4 Post Scheduling

Post scheduling allows writers to compose content and set a future publication time. The scheduling system uses a distributed job scheduler (such as Quartz.NET or Hangfire) backed by PostgreSQL for persistence. When a writer schedules a post, the system creates a scheduled job with the target publication time and timezone. At the scheduled time, the job triggers the publication workflow, which updates the post status, emits a PostPublished event, and begins the email delivery pipeline. The system must handle timezone conversions correctly and provide writers with accurate previews of when their post will be published in their readers' local timezones.

Editor Feature Implementation Approach Email Client Support Complexity
Rich Text Formatting ProseMirror/TipTap document model Inline CSS conversion High
Image Embeds S3 upload + CDN serving Img tags with width constraints Medium
Code Blocks Prism.js highlighting Pre/code with monospace fallback Medium
Video Embeds Thumbnail + link overlay Fallback to thumbnail image High
Polls / Interactive Web-only rendering Link to web version Medium
Autosave Delta-based debounced saves N/A High
Scheduling Distributed job scheduler N/A Medium

5. Email Delivery Infrastructure

Email delivery is the backbone of any newsletter platform. If emails fail to reach inboxes, the entire value proposition collapses. Building a reliable email delivery infrastructure requires understanding SMTP protocols, email authentication standards (SPF, DKIM, DMARC), sender reputation management, bounce handling, and the intricacies of inbox placement across different email providers. At Substack's scale, the system must deliver millions of emails per day while maintaining deliverability rates above 99.5% and adapting to the constantly evolving anti-spam policies of major email providers.

5.1 Email Authentication and Domain Reputation

Before sending a single email, the platform must establish proper email authentication. This involves configuring SPF (Sender Policy Framework) records to authorize sending servers, DKIM (DomainKeys Identified Mail) to cryptographically sign outgoing messages, and DMARC (Domain-based Message Authentication, Reporting & Conformance) to define the policy for handling authentication failures. Each writer's publication domain must be verified and configured with these authentication records. The platform maintains a shared sending domain with strong reputation while also supporting custom domains for publications that want branded email addresses.

C#
public class EmailDeliveryService
{
    private readonly IEmailQueue _queue;
    private readonly ISmtpProviderPool _smtpPool;
    private readonly IDeliveryTracker _tracker;
    private readonly IDomainReputationManager _reputationManager;
    private readonly ILogger<EmailDeliveryService> _logger;

    public async Task<DeliveryResult> SendNewsletterAsync(
        NewsletterIssue issue, IReadOnlyList<Subscriber> recipients)
    {
        var batches = recipients.Chunk(500);
        var results = new List<BatchDeliveryResult>();

        foreach (var batch in batches)
        {
            var batchResult = await ProcessBatchAsync(issue, batch.ToList());
            results.Add(batchResult);

            if (batchResult.FailureRate > 0.05)
            {
                _logger.LogWarning(
                    "Batch {BatchId} had {Rate:P2} failure rate, " +
                    "throttling next batch",
                    batchResult.BatchId, batchResult.FailureRate);
                await Task.Delay(TimeSpan.FromSeconds(30));
            }
        }

        var totalSent = results.Sum(r => r.SuccessCount);
        var totalFailed = results.Sum(r => r.FailureCount);

        return new DeliveryResult
        {
            IssueId = issue.Id,
            TotalRecipients = recipients.Count,
            SuccessfullyQueued = totalSent,
            Failed = totalFailed,
            BatchResults = results
        };
    }

    private async Task<BatchDeliveryResult> ProcessBatchAsync(
        NewsletterIssue issue, List<Subscriber> batch)
    {
        var provider = await _smtpPool.GetOptimalProviderAsync(
            issue.PublicationId);

        var sentCount = 0;
        var failedRecipients = new List<string>();

        foreach (var subscriber in batch)
        {
            try
            {
                var personalizedEmail = PersonalizeEmail(issue, subscriber);

                if (!await _reputationManager.CanSendToAsync(
                    subscriber.EmailDomain))
                {
                    failedRecipients.Add(subscriber.Email);
                    continue;
                }

                var messageId = await provider.SendAsync(personalizedEmail);

                await _tracker.TrackQueuedAsync(new DeliveryRecord
                {
                    MessageId = messageId,
                    RecipientEmail = subscriber.Email,
                    IssueId = issue.Id,
                    PublicationId = issue.PublicationId,
                    QueuedAt = DateTime.UtcNow,
                    Status = DeliveryStatus.Queued
                });

                sentCount++;
            }
            catch (SmtpException ex) when (IsTransientFailure(ex))
            {
                _logger.LogError(ex,
                    "Transient failure sending to {Email}", subscriber.Email);
                await _queue.RequeueAsync(subscriber.Email, issue.Id,
                    retryAfter: TimeSpan.FromMinutes(5));
            }
            catch (SmtpException ex) when (IsPermanentFailure(ex))
            {
                _logger.LogWarning(
                    "Permanent failure for {Email}: {Reason}",
                    subscriber.Email, ex.Message);
                failedRecipients.Add(subscriber.Email);

                await _tracker.RecordBounceAsync(
                    subscriber.Email, issue.Id,
                    ex.Message.Contains("550") ? BounceType.Hard : BounceType.Soft);
            }
        }

        return new BatchDeliveryResult
        {
            BatchId = Guid.NewGuid(),
            SuccessCount = sentCount,
            FailureCount = failedRecipients.Count,
            FailedRecipients = failedRecipients
        };
    }
}

5.2 Multi-Provider SMTP Strategy

Relying on a single SMTP provider creates a single point of failure. The platform implements a provider pool that distributes email sending across multiple providers (SendGrid, Postmark, Amazon SES, Mailgun) based on recipient domain, current provider health, cost optimization, and rate limits. For example, emails to Gmail recipients might be routed through SendGrid (which has strong Gmail deliverability) while Yahoo-bound emails go through Postmark. The pool continuously monitors provider health metrics and automatically routes traffic away from providers experiencing issues.

sequenceDiagram participant Writer participant API participant Queue participant Delivery participant SMTP participant Recipient Writer->>API: Publish Newsletter API->>API: Generate Email HTML API->>Queue: Enqueue Emails (per subscriber) Queue->>Delivery: Consume Email Batch Delivery->>Delivery: Personalize Content Delivery->>Delivery: Check Domain Reputation Delivery->>SMTP: Send via Optimal Provider SMTP-->>Delivery: Delivery Receipt Delivery->>Delivery: Track Status Delivery->>Delivery: Handle Bounces/Complaints Recipient-->>SMTP: Open / Click / Bounce SMTP->>Delivery: Webhook Events Delivery->>Queue: Publish Engagement Events

5.3 Deliverability Monitoring

The platform maintains real-time deliverability dashboards that track open rates, bounce rates, spam complaint rates, and inbox placement across all email providers. It uses seed list testing (sending test emails to monitored inboxes at major providers) to measure actual inbox placement rates. When deliverability metrics degrade for a specific publication, the system automatically throttles sending and alerts the writer. The system also monitors blacklists (Spamhaus, Barracuda) and proactively addresses issues before they impact delivery.

5.4 Bounce and Complaint Handling

Every bounced email and spam complaint must be processed within minutes to maintain sender reputation. Hard bounces (invalid addresses) are immediately removed from the subscriber list. Soft bounces (full inboxes, temporary failures) trigger retry logic with exponential backoff. Spam complaints result in immediate suppression and investigation. The system maintains a global suppression list that is checked before every send, ensuring that no email is ever sent to an address that has previously bounced or complained. This suppression list is shared across all publications on the platform to prevent any individual writer from damaging the platform's overall sender reputation.

Email Provider Role in Architecture Priority Rate Limit Cost per 1K Emails
SendGrid Primary — Gmail, Outlook domains High 100K/day $0.19
Postmark Secondary — transactional emails High Unlimited $1.25
Amazon SES Bulk — cost optimization Medium 200K/day $0.10
Mailgun Fallback — failover routing Low 50K/day $0.80

6. Subscription and Payment System

The subscription and payment system is the revenue engine of the platform. It handles the complete lifecycle of paid subscriptions — from initial purchase through recurring billing to cancellation and reactivation. Stripe serves as the payment processor, but the platform must build substantial logic on top of Stripe to handle the nuances of a multi-tenant newsletter platform. This includes managing subscription plans, handling failed payments gracefully, supporting gift subscriptions, processing refunds, and providing accurate financial reporting to writers.

6.1 Subscription State Machine

Every subscription follows a well-defined state machine that governs its lifecycle. The states include Active (currently paying and has access), PastDue (payment failed, grace period), Canceled (explicitly canceled or grace period expired), Paused (writer-initiated pause), and Gifted (access granted through gift subscription). Each state transition triggers specific actions — for example, transitioning to PastDue sends a payment failure notification and starts a retry schedule, while transitioning to Canceled removes content access and sends a cancellation confirmation.

C#
public enum SubscriptionState
{
    Active,
    PastDue,
    Canceled,
    Paused,
    Gifted,
    Trial
}

public class Subscription
{
    public Guid Id { get; set; }
    public Guid PublicationId { get; set; }
    public string SubscriberId { get; set; }
    public string StripeSubscriptionId { get; set; }
    public string StripeCustomerId { get; set; }
    public SubscriptionState State { get; set; }
    public SubscriptionTier Tier { get; set; }
    public decimal AmountCents { get; set; }
    public string Currency { get; set; }
    public BillingInterval Interval { get; set; }
    public DateTime CurrentPeriodStart { get; set; }
    public DateTime CurrentPeriodEnd { get; set; }
    public DateTime? CanceledAt { get; set; }
    public DateTime? PauseStartsAt { get; set; }
    public int FailedPaymentAttempts { get; set; }
    public DateTime? NextRetryAt { get; set; }
    public SubscriptionSource Source { get; set; }
    public string ReferralCode { get; set; }
}

public class SubscriptionService
{
    private readonly IStripeClient _stripe;
    private readonly ISubscriptionRepository _repository;
    private readonly IEventBus _eventBus;
    private readonly IAccessControlService _accessControl;

    public async Task<Subscription> CreateSubscriptionAsync(
        CreateSubscriptionRequest request)
    {
        var customer = await _stripe.GetOrCreateCustomerAsync(
            request.SubscriberEmail,
            request.PaymentMethodId);

        var publication = await _publicationService.GetAsync(
            request.PublicationId);

        var priceId = request.Tier == SubscriptionTier.Founding
            ? publication.FoundingPriceId
            : publication.StandardPriceId;

        var stripeSubscription = await _stripe.CreateSubscriptionAsync(
            new StripeSubscriptionCreateOptions
            {
                Customer = customer.Id,
                Items = new List<StripeSubscriptionItemOptions>
                {
                    new StripeSubscriptionItemOptions
                    {
                        Price = priceId,
                        Quantity = 1
                    }
                },
                PaymentBehavior = StripeSubscriptionPaymentBehavior.DefaultIncomplete,
                PaymentSettings = new StripeSubscriptionPaymentSettingsOptions
                {
                    SaveDefaultPaymentMethod = StripeSubscriptionSaveDefaultPaymentMethod.OnSubscription
                },
                Metadata = new Dictionary<string, string>
                {
                    { "publication_id", request.PublicationId.ToString() },
                    { "subscriber_id", request.SubscriberId },
                    { "referral_code", request.ReferralCode ?? "" }
                }
            });

        var subscription = new Subscription
        {
            Id = Guid.NewGuid(),
            PublicationId = request.PublicationId,
            SubscriberId = request.SubscriberId,
            StripeSubscriptionId = stripeSubscription.Id,
            StripeCustomerId = customer.Id,
            State = SubscriptionState.Active,
            Tier = request.Tier,
            AmountCents = request.AmountCents,
            Currency = request.Currency,
            Interval = request.Interval,
            CurrentPeriodStart = DateTime.UtcNow,
            CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1),
            Source = SubscriptionSource.Direct,
            ReferralCode = request.ReferralCode
        };

        await _repository.SaveAsync(subscription);

        await _eventBus.PublishAsync(new SubscriptionCreatedEvent
        {
            SubscriptionId = subscription.Id,
            PublicationId = request.PublicationId,
            SubscriberId = request.SubscriberId,
            Tier = request.Tier,
            AmountCents = request.AmountCents,
            CreatedAt = DateTime.UtcNow
        });

        await _accessControl.GrantAccessAsync(
            request.SubscriberId, request.PublicationId, request.Tier);

        return subscription;
    }

    public async Task HandlePaymentFailedAsync(
        string stripeSubscriptionId, StripeEvent evt)
    {
        var subscription = await _repository
            .GetByStripeIdAsync(stripeSubscriptionId);

        subscription.FailedPaymentAttempts++;
        subscription.State = SubscriptionState.PastDue;

        if (subscription.FailedPaymentAttempts >= 3)
        {
            subscription.State = SubscriptionState.Canceled;
            subscription.CanceledAt = DateTime.UtcNow;

            await _accessControl.RevokeAccessAsync(
                subscription.SubscriberId, subscription.PublicationId);

            await _eventBus.PublishAsync(new SubscriptionCanceledEvent
            {
                SubscriptionId = subscription.Id,
                Reason = "Payment failure after max retries",
                CanceledAt = DateTime.UtcNow
            });
        }
        else
        {
            var retryDelay = CalculateRetryDelay(
                subscription.FailedPaymentAttempts);
            subscription.NextRetryAt = DateTime.UtcNow.Add(retryDelay);

            await _eventBus.PublishAsync(new PaymentRetryScheduledEvent
            {
                SubscriptionId = subscription.Id,
                RetryAt = subscription.NextRetryAt.Value,
                AttemptNumber = subscription.FailedPaymentAttempts
            });
        }

        await _repository.UpdateAsync(subscription);
    }
}

6.2 Failed Payment Retry Strategy

Failed payments are one of the most common sources of revenue loss for subscription platforms. The system implements an intelligent retry strategy that balances revenue recovery against subscriber experience. The first retry occurs 3 days after the initial failure, the second retry at 7 days, and the third (final) retry at 14 days. Between retries, the subscriber is notified via email with a direct link to update their payment method. The system also uses Stripe's Smart Retries feature, which uses machine learning to determine the optimal retry timing based on when the customer's bank is most likely to approve the transaction.

6.3 Financial Reporting

Writers need accurate, real-time financial reporting that shows their earnings, subscription growth, churn metrics, and projected revenue. The platform maintains a financial ledger that mirrors Stripe's transaction data while adding platform-specific calculations like the 10% revenue share, referral credits, and tax withholding. This ledger is updated through Stripe webhooks and serves as the source of truth for writer dashboards, tax reporting, and payout calculations.

Billing Scenario System Behavior Stripe API Call Platform Fee Handling
Initial Subscription Create subscription + invoice Subscriptions.Create 10% deducted from payout
Monthly Renewal Automatic invoice + payment Invoice.Paid webhook 10% deducted from payout
Upgrade (Monthly to Annual) Prorate current + create new Subscriptions.Update 10% on prorated amount
Cancellation End at period close Subscriptions.Cancel No fee on cancellation
Refund Full or partial refund Refunds.Create Fee refunded proportionally
Gift Subscription Gift purchase + code redemption Invoices.Create 10% deducted from payout

7. Recommendation Engine

The recommendation engine powers Substack Reads, the platform's content discovery feature that helps readers find new publications to subscribe to. Unlike social media recommendation algorithms that optimize for engagement (likes, shares, time spent), Substack's recommendation engine must optimize for subscription quality — the likelihood that a reader will remain subscribed and engaged long-term. This requires understanding both reader preferences and publication characteristics, and finding matches that align interests with content style and frequency.

7.1 Data Collection and Feature Engineering

The recommendation engine ingests signals from multiple sources to build comprehensive reader and publication profiles. Reader signals include subscription history, reading patterns (which posts they open, how far they scroll, whether they click links), demographic data (inferred from email domain and subscription patterns), and explicit preferences (category selections, topic follows). Publication signals include content category, publication frequency, subscriber count, growth rate, engagement metrics, and content similarity scores derived from NLP analysis of post titles and summaries.

C#
public class RecommendationEngine
{
    private readonly IFeatureStore _featureStore;
    private readonly ISimilarityIndex _similarityIndex;
    private readonly ICollaborativeFilter _collaborativeFilter;
    private readonly IPublicationRepository _publicationRepo;

    public async Task<List<Recommendation>> GetRecommendationsAsync(
        string readerId, int count = 10)
    {
        var readerProfile = await _featureStore
            .GetReaderProfileAsync(readerId);

        var collaborativeRecs = await _collaborativeFilter
            .GetSimilarReadersSubscriptionsAsync(readerId);

        var contentBasedRecs = await _similarityIndex
            .FindSimilarPublicationsAsync(readerProfile.TopCategories);

        var trendingRecs = await GetTrendingPublicationsAsync(
            readerProfile.PreferredCategories);

        var allCandidates = MergeAndScore(
            collaborativeRecs,
            contentBasedRecs,
            trendingRecs,
            readerProfile);

        var alreadySubscribed = await _publicationRepo
            .GetSubscribedPublicationIdsAsync(readerId);

        var filtered = allCandidates
            .Where(r => !alreadySubscribed.Contains(r.PublicationId))
            .Where(r => r.Score > 0.3)
            .OrderByDescending(r => r.Score)
            .Take(count)
            .ToList();

        return filtered;
    }

    private List<Recommendation> MergeAndScore(
        List<CollaborativeRec> collaborative,
        List<ContentBasedRec> contentBased,
        List<TrendingRec> trending,
        ReaderProfile profile)
    {
        var merged = new Dictionary<Guid, Recommendation>();

        foreach (var rec in collaborative)
        {
            var key = rec.PublicationId;
            if (!merged.ContainsKey(key))
                merged[key] = new Recommendation { PublicationId = key };

            merged[key].CollaborativeScore = rec.Score;
            merged[key].Score += rec.Score * 0.4;
        }

        foreach (var rec in contentBased)
        {
            var key = rec.PublicationId;
            if (!merged.ContainsKey(key))
                merged[key] = new Recommendation { PublicationId = key };

            merged[key].ContentSimilarityScore = rec.Score;
            merged[key].Score += rec.Score * 0.35;
        }

        foreach (var rec in trending)
        {
            var key = rec.PublicationId;
            if (!merged.ContainsKey(key))
                merged[key] = new Recommendation { PublicationId = key };

            merged[key].TrendingScore = rec.Score;
            merged[key].Score += rec.Score * 0.15;
        }

        foreach (var rec in merged.Values)
        {
            var publication = _publicationRepo.GetAsync(rec.PublicationId)
                .GetAwaiter().GetResult();

            rec.Score *= CalculateQualityMultiplier(publication);
        }

        return merged.Values.ToList();
    }

    private double CalculateQualityMultiplier(Publication pub)
    {
        var openRateScore = Math.Min(pub.AverageOpenRate / 0.5, 1.0);
        var growthScore = Math.Min(pub.MonthlyGrowthRate / 0.05, 1.0);
        var consistencyScore = pub.ConsistencyScore;

        return (openRateScore * 0.5) +
               (growthScore * 0.25) +
               (consistencyScore * 0.25);
    }
}

7.2 Recommendation Algorithms

The engine combines three algorithmic approaches: collaborative filtering (readers who subscribe to similar publications tend to enjoy each other's choices), content-based filtering (publications with similar topics and writing styles are recommended to readers who enjoy those characteristics), and trending analysis (publications experiencing rapid growth and high engagement are surfaced as popular choices). Each approach generates a candidate set with an independent score, and the final ranking is computed as a weighted combination of all three scores, with additional quality multipliers based on publication health metrics.

graph LR subgraph "Data Signals" A["Reader Subscriptions"] B["Reading Patterns"] C["Content Analysis"] D["Publication Metrics"] end subgraph "Algorithm Pipeline" E["Collaborative
Filtering"] F["Content-Based
Filtering"] G["Trending
Analysis"] end subgraph "Scoring" H["Score Merger"] I["Quality Filter"] J["Deduplication"] end subgraph "Output" K["Personalized
Recommendations"] end A --> E B --> E C --> F D --> G D --> F E --> H F --> H G --> H H --> I I --> J J --> K

7.3 Cold Start Problem

New readers with no subscription history present the classic cold start problem. The system handles this by asking new readers to select interest categories during onboarding and using these explicit signals to generate initial recommendations. As the reader begins subscribing and reading, the system gradually shifts toward behavioral signals. For new publications, the engine uses content-based features (topic, writing style, frequency) to position them alongside similar established publications, giving them visibility without requiring existing subscriber data.

7.4 A/B Testing Framework

The recommendation engine continuously experiments with different scoring weights, feature combinations, and ranking strategies through a controlled A/B testing framework. Experiments are run on a per-reader basis with statistical significance tracking to ensure that changes genuinely improve recommendation quality (measured by subscription conversion rate and 30-day retention) rather than just increasing short-term click-through rates. The framework supports feature flags for gradual rollout and automatic rollback if key metrics degrade beyond configured thresholds.

8. Reader Engagement Analytics

Analytics are essential for writers to understand their audience and optimize their content strategy. The analytics system must track granular engagement metrics across email, web, and mobile reading contexts, and present them in a dashboard that is both powerful and easy to interpret. The key metrics include open rates, click-through rates, reading completion rates, subscriber growth and churn, revenue trends, and referral performance. At scale, computing these analytics requires efficient batch processing, real-time aggregation, and careful handling of privacy considerations.

8.1 Event Tracking Pipeline

Every reader interaction generates an analytics event that flows through the tracking pipeline. Email opens are tracked via invisible pixel tracking, link clicks are captured through redirected URLs, web reading behavior is tracked via scroll depth and time-on-page measurements, and subscription events are captured directly from the payment service. These events flow through Kafka into a real-time aggregation pipeline (using Apache Flink or Spark Streaming) for immediate dashboard updates, and into a batch processing pipeline (using Apache Spark or dbt) for historical reporting and trend analysis.

C#
public class AnalyticsEvent
{
    public Guid EventId { get; set; }
    public AnalyticsEventType Type { get; set; }
    public string ReaderId { get; set; }
    public Guid PublicationId { get; set; }
    public Guid? PostId { get; set; }
    public string Source { get; set; }
    public DateTime Timestamp { get; set; }
    public Dictionary<string, object> Properties { get; set; }
    public ClientInfo Client { get; set; }
}

public enum AnalyticsEventType
{
    EmailSent,
    EmailDelivered,
    EmailOpened,
    EmailLinkClicked,
    WebPostViewed,
    WebScrollDepth,
    WebTimeOnPage,
    SubscriptionCreated,
    SubscriptionRenewed,
    SubscriptionCanceled,
    ReferralCompleted,
    NoteLiked,
    NoteShared
}

public class AnalyticsAggregationService
{
    private readonly IAnalyticsRepository _repository;
    private readonly IRealTimeAggregator _realTime;
    private readonly IBatchProcessor _batchProcessor;

    public async Task<PublicationAnalytics> GetPublicationAnalyticsAsync(
        Guid publicationId, DateTimeRange range)
    {
        var cached = await _realTime.GetCachedAnalyticsAsync(
            publicationId, range);
        if (cached != null) return cached;

        var emailMetrics = await _repository
            .GetEmailMetricsAsync(publicationId, range);
        var webMetrics = await _repository
            .GetWebMetricsAsync(publicationId, range);
        var subscriberMetrics = await _repository
            .GetSubscriberMetricsAsync(publicationId, range);
        var revenueMetrics = await _repository
            .GetRevenueMetricsAsync(publicationId, range);

        var analytics = new PublicationAnalytics
        {
            PublicationId = publicationId,
            Period = range,
            Email = new EmailAnalytics
            {
                TotalSent = emailMetrics.TotalSent,
                TotalDelivered = emailMetrics.TotalDelivered,
                TotalOpened = emailMetrics.TotalOpened,
                UniqueOpens = emailMetrics.UniqueOpens,
                TotalClicked = emailMetrics.TotalClicked,
                UniqueClicks = emailMetrics.UniqueClicks,
                OpenRate = emailMetrics.UniqueOpens /
                    (double)emailMetrics.TotalDelivered,
                ClickThroughRate = emailMetrics.UniqueClicks /
                    (double)emailMetrics.UniqueOpens,
                BounceRate = emailMetrics.Bounces /
                    (double)emailMetrics.TotalSent
            },
            Web = new WebAnalytics
            {
                TotalPageViews = webMetrics.TotalViews,
                UniqueVisitors = webMetrics.UniqueVisitors,
                AverageTimeOnPage = webMetrics.AvgTimeOnPage,
                AverageScrollDepth = webMetrics.AvgScrollDepth,
                TopPosts = webMetrics.PostPerformance
                    .OrderByDescending(p => p.Views)
                    .Take(10)
                    .ToList()
            },
            Subscribers = new SubscriberAnalytics
            {
                TotalSubscribers = subscriberMetrics.CurrentCount,
                NewSubscribers = subscriberMetrics.NewCount,
                Cancellations = subscriberMetrics.CancelCount,
                NetGrowth = subscriberMetrics.NewCount -
                    subscriberMetrics.CancelCount,
                GrowthRate = subscriberMetrics.NewCount /
                    (double)subscriberMetrics.CurrentCount,
                RetentionRate = 1.0 - (subscriberMetrics.CancelCount /
                    (double)subscriberMetrics.CurrentCount)
            },
            Revenue = new RevenueAnalytics
            {
                TotalRevenueCents = revenueMetrics.TotalCents,
                PlatformFeeCents = revenueMetrics.PlatformFeeCents,
                NetRevenueCents = revenueMetrics.NetCents,
                AverageRevenuePerSubscriber = revenueMetrics.NetCents /
                    (double)subscriberMetrics.CurrentCount,
                MonthlyRecurringRevenue = revenueMetrics.MRR,
                AnnualRecurringRevenue = revenueMetrics.ARR
            }
        };

        await _realTime.CacheAnalyticsAsync(publicationId, range, analytics,
            ttl: TimeSpan.FromMinutes(5));

        return analytics;
    }
}

8.2 Privacy-Respecting Analytics

The platform balances detailed analytics with reader privacy. Open tracking uses a privacy-preserving approach that provides aggregate statistics without revealing individual reading habits to writers. Readers can opt out of open tracking while still receiving emails. Web analytics use aggregated, anonymized data rather than individual-level tracking. The system is designed to comply with GDPR, CCPA, and other privacy regulations, including providing readers with the ability to request their complete analytics data or request deletion.

Metric Data Source Computation Method Refresh Rate Retention
Open Rate Email pixel tracking Unique opens / delivered Real-time 2 years
Click-Through Rate Redirect link tracking Unique clicks / unique opens Real-time 2 years
Scroll Depth JavaScript scroll tracking Max scroll position / page height Batch (hourly) 1 year
Completion Rate Scroll + time analysis Read to bottom / total views Batch (daily) 1 year
Revenue Per Sub Payment service Net revenue / active subs Daily Indefinite
Churn Rate Subscription events Cancellations / total subs Daily 2 years

9. Notes Feature

Substack Notes is the platform's answer to Twitter — a microblogging feature that allows writers and readers to share short-form content, engage in conversations, and build community outside of the traditional newsletter format. From a system design perspective, Notes introduces real-time social features that are fundamentally different from the batch-delivery model of newsletters. The system must handle real-time feed generation, social interactions (likes, replies, reposts), content threading, and notification delivery at the pace of social media rather than the pace of email newsletters.

9.1 Feed Generation Architecture

The Notes feed must be generated efficiently for each user based on their follow graph, publication subscriptions, and engagement history. Unlike email delivery which happens in batches, the feed must be generated on-demand with low latency. The system uses a fan-out-on-write approach for popular accounts (pre-computing feeds for users with many followers) and fan-out-on-read for smaller accounts (computing feeds at read time). This hybrid approach balances write amplification against read latency.

C#
public class NotesFeedService
{
    private readonly IFollowGraph _followGraph;
    private readonly IFeedCache _feedCache;
    private readonly INotesRepository _notesRepo;
    private readonly IFanoutService _fanoutService;

    public async Task<FeedPage> GetFeedAsync(
        string userId, string? cursor = null, int pageSize = 20)
    {
        var cachedFeed = await _feedCache.GetAsync(userId, cursor);
        if (cachedFeed != null && cachedFeed.FreshnessMinutes < 5)
        {
            return cachedFeed.Page;
        }

        var followings = await _followGraph
            .GetFollowingIdsAsync(userId);

        var publications = await _followGraph
            .GetSubscribedPublicationIdsAsync(userId);

        var feedNoteIds = new List<string>();

        var followingNotes = await _notesRepo
            .GetRecentNotesAsync(followings, pageSize * 2);

        var publicationNotes = await _notesRepo
            .GetRecentPublicationNotesAsync(publications, pageSize);

        feedNoteIds.AddRange(followingNotes.Select(n => n.Id));
        feedNoteIds.AddRange(publicationNotes.Select(n => n.Id));

        var scored = await ScoreFeedItemsAsync(feedNoteIds, userId);

        var page = scored
            .OrderByDescending(s => s.Score)
            .Take(pageSize)
            .ToList();

        var notes = await _notesRepo
            .GetNotesByIdsAsync(page.Select(p => p.NoteId));

        var enrichedNotes = await EnrichNotesAsync(notes, userId);

        var feedPage = new FeedPage
        {
            Items = enrichedNotes,
            NextCursor = enrichedNotes.Last()?.Id,
            HasMore = enrichedNotes.Count == pageSize
        };

        await _feedCache.SetAsync(userId, cursor, new CachedFeed
        {
            Page = feedPage,
            FreshnessMinutes = 0
        });

        return feedPage;
    }

    private async Task<List<ScoredNote>> ScoreFeedItemsAsync(
        List<string> noteIds, string userId)
    {
        var scored = new List<ScoredNote>();

        foreach (var noteId in noteIds)
        {
            var note = await _notesRepo.GetByIdAsync(noteId);
            var engagement = await _notesRepo
                .GetEngagementCountsAsync(noteId);

            var score = CalculateFeedScore(note, engagement, userId);
            scored.Add(new ScoredNote { NoteId = noteId, Score = score });
        }

        return scored;
    }

    private double CalculateFeedScore(
        Note note, EngagementCounts engagement, string userId)
    {
        var recencyHours = (DateTime.UtcNow - note.CreatedAt).TotalHours;
        var recencyScore = Math.Exp(-recencyHours / 24.0);

        var engagementScore =
            (engagement.Likes * 1.0) +
            (engagement.Comments * 3.0) +
            (engagement.Reposts * 5.0);

        var normalizedEngagement = Math.Log(1 + engagementScore) / 10.0;

        var authorAffinity = CalculateAuthorAffinity(
            note.AuthorId, userId);

        return (recencyScore * 0.4) +
               (normalizedEngagement * 0.3) +
               (authorAffinity * 0.3);
    }
}

9.2 Real-Time Interactions

Notes interactions — likes, comments, reposts — must appear in real-time across all connected clients. The system uses WebSockets (via SignalR) to push live updates to connected browsers and mobile apps. When a user likes a note, the like count is optimistically updated on the client, and a WebSocket message is broadcast to other viewers. The backend writes the interaction to PostgreSQL, updates cached engagement counts in Redis, and publishes a notification event for the note author. This architecture provides instant feedback while maintaining eventual consistency for aggregate counts.

9.3 Content Threading

Notes supports threaded conversations where replies are grouped under parent notes. The threading model uses a parent_id reference pattern with materialized path for efficient tree queries. The system must handle deep nesting (up to 10 levels), support collapsing/expanding threads in the UI, and efficiently compute thread engagement metrics. For feed generation, threaded notes are grouped together with the root note first, followed by the most engaging replies, rather than strict chronological order.

10. Podcast Support

Substack's podcast feature allows writers to create audio content alongside their written newsletters. The system handles audio file hosting, podcast RSS feed generation, episode transcription, and distribution to major podcast platforms (Apple Podcasts, Spotify, Google Podcasts). From a system design perspective, podcast support introduces media processing pipelines, large file handling, and content moderation challenges that differ significantly from text-based content.

10.1 Audio Processing Pipeline

When a writer uploads an audio file, it enters a multi-stage processing pipeline. First, the file is uploaded to S3 and validated for format compliance. Then, a transcription job is queued using a speech-to-text service (such as AWS Transcribe or a custom Whisper deployment). Simultaneously, an audio optimization job generates compressed versions at multiple bitrates for different streaming contexts. The transcription result is stored alongside the episode metadata and used for search indexing, accessibility, and content moderation.

graph TB A["Writer Uploads
Audio File"] --> B["S3 Upload
(Multipart)"] B --> C{"Format
Validation"} C -->|Valid| D["Processing
Pipeline"] C -->|Invalid| E["Error:
Rejection"] D --> F["Audio Transcoding
(Multiple Bitrates)"] D --> G["Transcription
(Whisper / Transcribe)"] D --> H["Content
Moderation"] F --> I["S3: Optimized
Audio Files"] G --> J["Transcript
Storage"] H --> K{"Approved?"} K -->|Yes| L["Episode Published
+ RSS Updated"] K -->|No| M["Manual
Review Queue"] J --> L I --> L

10.2 RSS Feed Generation

Podcast distribution relies on RSS feeds that conform to Apple's podcasting specifications. The system generates and hosts RSS feeds for each podcast publication, ensuring that episode metadata, artwork, categories, and audio enclosures are correctly formatted. The feed must be updated within minutes of a new episode being published and must handle iTunes-specific requirements like explicit content tags, episode numbering, and season organization.

10.3 Transcription and Search

Full-text transcription of podcast episodes creates a searchable index that benefits both readers (who can search for specific topics across episodes) and the recommendation engine (which can analyze audio content for topic matching). The transcription pipeline uses diarization to identify different speakers, timestamps to enable click-to-seek in the player, and punctuation restoration to improve readability. Transcriptions are stored as structured documents with paragraph-level timestamps that sync with the audio player.

Audio Format Bitrate Use Case File Size per Hour
MP3 (High) 192 kbps Download / Wi-Fi streaming ~86 MB
MP3 (Standard) 128 kbps Default streaming ~57 MB
AAC 96 kbps Mobile streaming ~43 MB
Opus 64 kbps Low bandwidth ~29 MB

11. Community Features

The community features on Substack enable readers to engage with writers and each other through comments, discussion threads, and threaded replies beneath newsletter posts. Unlike traditional comment systems, Substack's comments are deeply integrated with the subscription model — writers can configure who can comment (all readers, paid subscribers only, or mentioned users), and the comment experience must feel like a natural extension of the reading experience. The system must handle high-engagement posts where thousands of comments arrive within hours of publication.

11.1 Comment System Architecture

The comment system uses a threaded model with nested replies, rich text formatting, and real-time updates. Comments are stored in PostgreSQL with a modified adjacency list pattern that uses materialized paths for efficient tree queries. Each comment carries metadata about its author, timestamp, engagement metrics (likes, replies count), and moderation status. The system caches hot comment threads in Redis to handle the read-heavy access pattern of popular posts.

C#
public class Comment
{
    public Guid Id { get; set; }
    public Guid PostId { get; set; }
    public string AuthorId { get; set; }
    public string AuthorName { get; set; }
    public string AuthorAvatarUrl { get; set; }
    public string Content { get; set; }
    public string ContentHtml { get; set; }
    public Guid? ParentCommentId { get; set; }
    public string MaterializedPath { get; set; }
    public int Depth { get; set; }
    public int LikeCount { get; set; }
    public int ReplyCount { get; set; }
    public CommentStatus Status { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? EditedAt { get; set; }
    public bool IsPinned { get; set; }
    public List<CommentReaction> Reactions { get; set; }
}

public class CommentService
{
    private readonly ICommentRepository _repository;
    private readonly ICacheService _cache;
    private readonly IModerationService _moderation;
    private readonly IAccessControlService _accessControl;
    private readonly IEventBus _eventBus;

    public async Task<Comment> CreateCommentAsync(
        CreateCommentRequest request, string authorId)
    {
        var post = await _postService.GetAsync(request.PostId);
        var publication = await _publicationService
            .GetAsync(post.PublicationId);

        if (!await _accessControl.CanCommentAsync(
            authorId, publication.Id))
        {
            throw new UnauthorizedAccessException(
                "You do not have permission to comment on this post");
        }

        var sanitizedContent = SanitizeHtml(request.Content);

        var parentPath = "";
        var depth = 0;
        if (request.ParentCommentId.HasValue)
        {
            var parent = await _repository
                .GetByIdAsync(request.ParentCommentId.Value);
            parentPath = parent.MaterializedPath;
            depth = parent.Depth + 1;

            if (depth > 10)
                throw new InvalidOperationException(
                    "Maximum comment nesting depth reached");
        }

        var newPath = string.IsNullOrEmpty(parentPath)
            ? $"/{Guid.NewGuid()}"
            : $"{parentPath}/{Guid.NewGuid()}";

        var comment = new Comment
        {
            Id = Guid.NewGuid(),
            PostId = request.PostId,
            AuthorId = authorId,
            AuthorName = request.AuthorName,
            AuthorAvatarUrl = request.AuthorAvatarUrl,
            Content = request.Content,
            ContentHtml = sanitizedContent,
            ParentCommentId = request.ParentCommentId,
            MaterializedPath = newPath,
            Depth = depth,
            Status = CommentStatus.Visible,
            CreatedAt = DateTime.UtcNow
        };

        var moderationResult = await _moderation
            .ModerateAsync(comment.Content);

        if (moderationResult.ContainsFlaggedContent)
        {
            comment.Status = CommentStatus.UnderReview;
        }

        await _repository.SaveAsync(comment);
        await _cache.InvalidateThreadAsync(request.PostId);

        if (comment.ParentCommentId.HasValue)
        {
            await _repository.IncrementReplyCountAsync(
                comment.ParentCommentId.Value);
        }

        await _eventBus.PublishAsync(new CommentCreatedEvent
        {
            CommentId = comment.Id,
            PostId = comment.PostId,
            AuthorId = comment.AuthorId,
            ParentCommentId = comment.ParentCommentId,
            CreatedAt = comment.CreatedAt
        });

        return comment;
    }
}

11.2 Real-Time Comment Updates

Comments are delivered in real-time using Server-Sent Events (SSE) for the web client and push notifications for mobile. When a new comment is posted on a thread that a user is currently viewing, the comment is pushed to all connected clients via a WebSocket channel scoped to the post. The system uses a Redis pub/sub layer to fan out comment events across multiple application server instances, ensuring that all connected clients receive updates regardless of which server they are connected to.

11.3 Comment Moderation

Writers have moderation tools to pin, hide, or delete individual comments. The platform also provides automatic moderation using ML-based content classification to detect spam, harassment, and hate speech. Writers can configure moderation policies at the publication level — from fully open (all comments visible immediately) to fully moderated (all comments require approval). Paid-only comment mode ensures that only subscribers who have an active paid subscription can participate in discussions, creating a higher-signal conversation environment.

12. Referral Program and Growth Tools

Growth is the lifeblood of any newsletter publication, and Substack provides writers with a suite of tools to grow their subscriber base. The referral program incentivizes existing subscribers to recruit new readers by offering rewards (free subscription months, exclusive content, swag) for successful referrals. Beyond the referral program, the platform provides SEO optimization, social sharing tools, customizable signup forms, and the recommendation engine to drive organic growth.

12.1 Referral Tracking System

The referral system must accurately attribute new subscriptions to referrers while preventing gaming and fraud. Each subscriber receives a unique referral link that encodes their subscriber ID. When a new reader signs up through a referral link, the system creates an attribution record linking the new subscriber to the referrer. The system must handle multi-step referral chains (subscriber A refers B, who then refers C), track reward eligibility (only completed referrals count, not just signups), and detect suspicious patterns like bulk signups from the same IP or email domain.

C#
public class ReferralService
{
    private readonly IReferralRepository _repository;
    private readonly IRewardFulfillmentService _rewards;
    private readonly IFraudDetectionService _fraudDetection;
    private readonly IEventBus _eventBus;

    public async Task<ReferralResult> ProcessReferralAsync(
        string referralCode, string newSubscriberId, string email)
    {
        var referrer = await _repository
            .GetSubscriberByReferralCodeAsync(referralCode);

        if (referrer == null)
            return ReferralResult.InvalidCode;

        if (referrer.Id == newSubscriberId)
            return ReferralResult.SelfReferral;

        if (await _repository.HasExistingReferralAsync(
            referrer.Id, newSubscriberId))
            return ReferralResult.AlreadyReferred;

        var fraudCheck = await _fraudDetection
            .AnalyzeReferralAsync(new ReferralAnalysis
            {
                ReferrerId = referrer.Id,
                NewSubscriberEmail = email,
                ReferrerIp = referrer.LastKnownIp,
                ReferralTimestamp = DateTime.UtcNow
            });

        if (fraudCheck.IsSuspicious)
        {
            await _eventBus.PublishAsync(new SuspiciousReferralEvent
            {
                ReferrerId = referrer.Id,
                NewSubscriberEmail = email,
                FraudReasons = fraudCheck.Reasons,
                DetectedAt = DateTime.UtcNow
            });

            return ReferralResult.UnderReview;
        }

        var referral = new Referral
        {
            Id = Guid.NewGuid(),
            ReferrerId = referrer.Id,
            NewSubscriberId = newSubscriberId,
            PublicationId = referrer.PublicationId,
            Status = ReferralStatus.Completed,
            CompletedAt = DateTime.UtcNow
        };

        await _repository.SaveReferralAsync(referral);

        var referralCount = await _repository
            .GetCompletedReferralCountAsync(referrer.Id);

        var publication = await _publicationService
            .GetAsync(referrer.PublicationId);
        var rewardTier = publication.ReferralRewards
            .Where(r => r.Threshold <= referralCount)
            .OrderByDescending(r => r.Threshold)
            .FirstOrDefault();

        if (rewardTier != null)
        {
            var existingRewards = await _repository
                .GetFulfilledRewardsAsync(referrer.Id);

            if (!existingRewards.Any(r => r.TierId == rewardTier.Id))
            {
                await _rewards.FulfillRewardAsync(
                    referrer.Id, rewardTier);
            }
        }

        await _eventBus.PublishAsync(new ReferralCompletedEvent
        {
            ReferralId = referral.Id,
            ReferrerId = referrer.Id,
            NewSubscriberId = newSubscriberId,
            TotalReferrals = referralCount,
            CompletedAt = referral.CompletedAt
        });

        return ReferralResult.Success;
    }
}

12.2 Growth Analytics

The platform provides writers with growth analytics that track subscriber acquisition channels, referral performance, and conversion funnels. Writers can see how many subscribers came from organic search, social sharing, the recommendation engine, referrals, and direct links. This data helps writers understand which growth strategies are working and where to invest their efforts. The analytics dashboard includes cohort analysis to show how subscribers acquired through different channels differ in engagement and retention.

graph TB A["Writer Creates
Referral Campaign"] --> B["System Generates
Unique Referral Links"] B --> C["Subscribers Share
Links via Email/Social"] C --> D["New Readers
Click Referral Links"] D --> E["New Reader
Signs Up"] E --> F{"Fraud
Check"} F -->|Clean| G["Referral
Attributed"] F -->|Suspicious| H["Under
Review"] G --> I{"Reward
Threshold
Met?"} I -->|Yes| J["Reward
Fulfillment"] I -->|No| K["Track
Count"] J --> L["Writer Notified
of Reward"] K --> M["Dashboard
Updated"]
Growth Channel Attribution Method Typical Conversion Rate Scalability
Organic Search (SEO) UTM parameters + referrer 2-5% High — depends on content quality
Social Sharing Share link tracking 1-3% Medium — viral dependent
Referral Program Unique referral codes 5-15% High — exponential growth
Recommendation Engine Internal impression tracking 3-8% High — algorithmic
Substack Network Cross-publication mentions 4-10% Medium — network dependent

13. Publication Management

Publication management encompasses all the administrative aspects of running a newsletter on the platform. This includes team management with role-based access control, multi-author collaboration, publication settings, branding customization, and the operational aspects of running a content business. The system must support publications ranging from solo writers to large editorial teams with dozens of contributors, each with appropriate access levels and workflow capabilities.

13.1 Role-Based Access Control

The publication team model supports four primary roles: Owner (full control including billing and team management), Admin (content management and team invitations), Writer (create and edit own posts), and Guest (create drafts for review). Each role maps to a set of permissions that are enforced at the API layer and reflected in the UI. The permission model is designed to be extensible, allowing publications to create custom roles in the future as their needs evolve.

Permission Owner Admin Writer Guest
Publish Posts Yes Yes Yes (own) No (submit for review)
Edit Any Post Yes Yes No No
Manage Team Yes Invite writers No No
View Analytics Full Full Own posts only No
Manage Billing Yes No No No
Publication Settings Yes Partial No No
Moderate Comments Yes Yes Own post threads No
Delete Publication Yes No No No

13.2 Multi-Author Workflows

Multi-author publications require editorial workflows that support drafting, review, editing, and publishing across team members. The system supports draft sharing within the team, inline commenting on drafts (similar to Google Docs), and a submission/review workflow where guest writers submit drafts that admins or owners can review, edit, and publish. The workflow is configurable per publication — some teams may want a simple "draft → publish" flow while others need "draft → review → edit → schedule → publish."

13.3 Publication Settings and Branding

Each publication has extensive settings that control its appearance, behavior, and functionality. This includes publication name, description, logo, cover image, color themes, custom domains, email template customization, subscription pricing tiers, comment policies, and featured posts. The settings are stored as a structured document in PostgreSQL and cached in Redis for fast access. Changes to critical settings (like pricing or custom domain) trigger verification workflows and are logged in an audit trail for accountability.

14. Import/Export and Migration

Writer lock-in is a significant concern for any content platform. Substack addresses this by providing robust import and export capabilities that allow writers to bring in content from other platforms and take their content out if they choose to leave. The import system must handle multiple source formats (Mailchimp, ConvertKit, Ghost, WordPress, custom RSS feeds), map subscriber lists, and preserve post formatting. The export system must provide complete data portability including content, subscriber lists, analytics, and financial records.

14.1 Content Import Pipeline

The import pipeline is designed as a pluggable architecture where each source platform has a dedicated adapter that transforms source data into Substack's content model. The pipeline handles HTML-to-rich-text conversion, image migration and re-hosting, subscriber list import with deduplication, and subscription mapping. Imports are processed asynchronously with progress tracking and error reporting, as large imports (hundreds of thousands of subscribers, thousands of posts) can take hours to complete.

C#
public interface IImportAdapter
{
    string SourcePlatform { get; }
    Task<ImportManifest> AnalyzeSourceAsync(ImportCredentials credentials);
    Task<IAsyncEnumerable<ImportedPost>> GetPostsAsync(ImportCredentials credentials);
    Task<IAsyncEnumerable<ImportedSubscriber>> GetSubscribersAsync(ImportCredentials credentials);
}

public class MailchimpImportAdapter : IImportAdapter
{
    public string SourcePlatform => "mailchimp";

    private readonly IMailchimpClient _client;

    public async Task<ImportManifest> AnalyzeSourceAsync(
        ImportCredentials credentials)
    {
        var client = new MailchimpClient(credentials.ApiKey);
        var lists = await client/lists.GetAllAsync();

        return new ImportManifest
        {
            Platform = SourcePlatform,
            AvailableLists = lists/lists.Select(l => new ImportListInfo
            {
                Id = l.Id,
                Name = l.Name,
                SubscriberCount = l.Stats.MemberCount,
                CampaignCount = l.Stats.CampaignCount
            }).ToList(),
            SupportsPostImport = true,
            SupportsSubscriberImport = true
        };
    }

    public async Task<IAsyncEnumerable<ImportedPost>> GetPostsAsync(
        ImportCredentials credentials)
    {
        var client = new MailchimpClient(credentials.ApiKey);
        var campaigns = await client.Campaigns
            .GetAllAsync(new CampaignGetAllQuery
            {
                Status = "sent",
                Count = 1000,
                SortField = CampaignSortField.SendTime,
                SortDir = CampaignSortDir.DESC
            });

        return campaigns.Campaigns
            .Select(c => new ImportedPost
            {
                Title = c.Settings.Title,
                ContentHtml = c.ContentHtml,
                PublishedAt = c.SendTime,
                AuthorEmail = c.Settings.FromEmail,
                Tags = c.Tags?.Split(',') ?? Array.Empty<string>(),
                Images = ExtractImages(c.ContentHtml)
            })
            .ToAsyncEnumerable();
    }

    public async Task<IAsyncEnumerable<ImportedSubscriber>> GetSubscribersAsync(
        ImportCredentials credentials)
    {
        var client = new MailchimpClient(credentials.ApiKey);
        var listId = credentials.ExtraData["list_id"];

        var members = await client.lists
            .GetMembersAsync(listId, new ListMembersQuery
            {
                Status = "subscribed",
                Count = 1000
            });

        return members.Members
            .Select(m => new ImportedSubscriber
            {
                Email = m.EmailAddress,
                Name = $"{m.MergeFields?.FirstName} {m.MergeFields?.LastName}".Trim(),
                SubscribedAt = m.TimestampSignup ?? m.MemberRatingDate,
                Status = m.Status == "subscribed"
                    ? SubscriberStatus.Active
                    : SubscriberStatus.Unsubscribed,
                Tags = m.Tags?.Select(t => t.Name).ToList()
            })
            .ToAsyncEnumerable();
    }
}

14.2 Data Export

The export system provides a self-service interface where writers can request a complete data export at any time. The export includes all posts (in HTML, Markdown, and JSON formats), subscriber lists (with consent metadata), analytics data, financial records, and publication settings. Exports are assembled asynchronously, compressed, and made available for download via a secure, time-limited link. The system ensures that exports are GDPR-compliant and include all required data portability information.

14.3 Subscriber Migration

Importing subscribers from another platform requires careful handling to maintain deliverability and comply with anti-spam regulations. The system validates imported email addresses, checks against existing suppression lists, sends confirmation emails to imported subscribers (double opt-in), and gradually ramps up sending to imported lists to avoid sudden spikes that could trigger spam filters. The migration tool provides detailed reports on import success rates, validation failures, and subscriber engagement during the confirmation process.

15. Mobile Apps

Substack's mobile apps for iOS and Android provide a native reading experience optimized for mobile consumption. The apps must handle offline reading, push notifications, background content sync, and seamless integration with the platform's core features. The mobile experience is not merely a responsive web wrapper — it includes native features like saved posts, reading lists, and personalized feeds that leverage platform-specific capabilities.

16.1 Offline Reading Architecture

The mobile app must provide a seamless offline experience for readers who want to read newsletters during commutes or in areas with poor connectivity. The offline architecture uses a local SQLite database that stores recently read and saved posts, along with their full content and embedded images. A background sync service periodically fetches new content and updates the local database. The sync strategy prioritizes recently published posts from subscribed publications and pre-fetches content based on the reader's predicted interests.

16.2 Push Notification System

Push notifications drive readers back to the app when new content is published. The notification system must handle millions of push tokens, personalize notification content (writers can customize the notification text per post), and respect reader preferences for notification frequency and types. The system uses APNs (Apple Push Notification service) for iOS and FCM (Firebase Cloud Messaging) for Android, with a notification queue that manages delivery timing and rate limiting to avoid overwhelming readers.

C#
public class PushNotificationService
{
    private readonly IApnsClient _apns;
    private readonly IFcmClient _fcm;
    private readonly IPushTokenRepository _tokens;
    private readonly IPersonalizationService _personalization;

    public async Task<PushDeliveryResult> SendNewPostNotificationAsync(
        Guid postId, Guid publicationId)
    {
        var post = await _postService.GetAsync(postId);
        var publication = await _publicationService
            .GetAsync(publicationId);

        var subscribers = await _subscriberService
            .GetPushEnabledSubscribersAsync(publicationId);

        var batchedTokens = subscribers.Chunk(500);
        var results = new List<BatchPushResult>();

        foreach (var batch in batchedTokens)
        {
            var iosTokens = batch
                .Where(s => s.PushToken.Platform == Platform.iOS)
                .ToList();
            var androidTokens = batch
                .Where(s => s.PushToken.Platform == Platform.Android)
                .ToList;

            if (iosTokens.Any())
            {
                var iosPayload = new ApnsPayload
                {
                    Title = publication.Name,
                    Body = post.PushNotificationText ??
                           post.Title ??
                           "New post from " + publication.Name,
                    Badge = await GetUnreadCountAsync(
                        iosTokens.Select(s => s.Id)),
                    Sound = "default",
                    CustomData = new Dictionary<string, string>
                    {
                        { "post_id", postId.ToString() },
                        { "publication_id", publicationId.ToString() },
                        { "type", "new_post" }
                    },
                    ThreadId = $"publication_{publicationId}"
                };

                var iosResult = await _apns.SendBulkAsync(
                    iosTokens.Select(t => t.PushToken.Token),
                    iosPayload);

                results.Add(new BatchPushResult
                {
                    Platform = Platform.iOS,
                    SentCount = iosResult.SuccessCount,
                    FailedCount = iosResult.FailureCount,
                    FailedTokens = iosResult.FailedTokens
                });
            }

            if (androidTokens.Any())
            {
                var fcmMessage = new FcmMessage
                {
                    Notification = new FcmNotification
                    {
                        Title = publication.Name,
                        Body = post.PushNotificationText ??
                               post.Title ??
                               "New post from " + publication.Name
                    },
                    Data = new Dictionary<string, string>
                    {
                        { "post_id", postId.ToString() },
                        { "publication_id", publicationId.ToString() },
                        { "type", "new_post" }
                    },
                    Android = new AndroidConfig
                    {
                        Priority = "high",
                        Notification = new AndroidNotification
                        {
                            ChannelId = "new_posts",
                            ClickAction = "OPEN_POST"
                        }
                    }
                };

                var androidResult = await _fcm.SendBulkAsync(
                    androidTokens.Select(t => t.PushToken.Token),
                    fcmMessage);

                results.Add(new BatchPushResult
                {
                    Platform = Platform.Android,
                    SentCount = androidResult.SuccessCount,
                    FailedCount = androidResult.FailureCount,
                    FailedTokens = androidResult.FailedTokens
                });
            }
        }

        await CleanupInvalidTokensAsync(
            results.SelectMany(r => r.FailedTokens));

        return new PushDeliveryResult
        {
            PostId = postId,
            TotalAttempted = subscribers.Count,
            TotalSent = results.Sum(r => r.SentCount),
            TotalFailed = results.Sum(r => r.FailedCount)
        };
    }
}
Mobile Feature iOS Implementation Android Implementation Offline Support
Reading Experience WKWebView + custom reader WebView + custom reader Full — cached content
Push Notifications APNs + rich notifications FCM + notification channels Queued for delivery
Offline Reading SQLite + background fetch SQLite + WorkManager Last 50 posts cached
Image Caching NSCache + disk cache LruCache + disk cache Pre-downloaded images
Audio Playback AVFoundation ExoPlayer Download for offline

16. Content Moderation and Trust & Safety

As a platform that hosts millions of publications with billions of readers, Substack must implement comprehensive content moderation and trust & safety measures. The moderation system must balance free expression with user safety, handle content at scale, and provide transparent enforcement policies. This includes automated content classification, human review workflows, user reporting mechanisms, and appeals processes. The system must handle diverse content types — text, images, audio, and embedded media — across newsletters, Notes, comments, and podcast content.

16.1 Multi-Layer Moderation Architecture

The moderation system operates in three layers: automated pre-publication screening, real-time post-publication monitoring, and user-initiated reporting. Pre-publication screening uses ML models to scan content for spam, phishing attempts, and clearly violating content. Post-publication monitoring uses a combination of automated classifiers and human reviewers to identify content that slips past pre-publication filters. User reporting provides a safety net where readers can flag content that they believe violates platform policies.

graph TB A["Content Created
(Post / Note / Comment)"] --> B["Layer 1: Automated
Pre-Publication"] B --> C{"ML Classification
Confidence > 0.9?"} C -->|High confidence OK| D["Published
Immediately"] C -->|High confidence Violation| E["Blocked +
Writer Notified"] C -->|Uncertain| F["Layer 2: Human
Review Queue"] D --> G["Layer 2: Post-Publication
Monitoring"] G --> H{"Automated Flag
Triggered?"} H -->|No| I["No Action"] H -->|Yes| J["Escalate to
Review Queue"] F --> K["Human Reviewer
Decision"] J --> K K --> L{"Approved?"} L -->|Yes| M["Content Remains
Published"] L -->|No| N["Content Removed
+ Writer Notified"] N --> O{"Writer
Appeals?"} O -->|Yes| P["Senior Review
Board Decision"] O -->|No| O["Enforcement
Applied"] P --> Q["Final Decision"]

16.2 User Reporting and Appeals

The user reporting system allows readers to flag content that violates platform policies. Reports are categorized by violation type (spam, harassment, hate speech, misinformation, copyright infringement) and routed to specialized review teams. The system tracks reporter credibility to prevent abuse of the reporting mechanism and provides reporters with updates on the status of their reports. Writers whose content is removed receive a detailed explanation and can appeal the decision to a senior review board.

16.3 Platform Policy Enforcement

Enforcement actions range from content warnings and reduced distribution to account suspension and permanent bans. The enforcement system tracks violation history per account and applies escalating consequences for repeat offenders. A comprehensive audit trail logs every moderation action, including the reviewer identity, evidence reviewed, policy cited, and reasoning. This audit trail is critical for legal compliance, appeals resolution, and continuous improvement of moderation policies.

Violation Type Detection Method Initial Response Escalation Path
Spam ML classifier + pattern matching Auto-remove + warn Account review after 3 strikes
Harassment ML classifier + user reports Queue for human review Temporary suspension → permanent ban
Hate Speech ML classifier + human review Immediate removal Account suspension + appeal
Misinformation User reports + fact-check queue Label + reduced distribution Human review → removal if confirmed
Copyright DMCA takedown requests Immediate removal + notify Counter-notice → legal review
CSAM PhotoDNA + NCMEC reporting Immediate removal + report Law enforcement + permanent ban

17. Interview Q&A

The following questions and answers are designed to test senior-level understanding of the Substack system design. Each question explores a critical design decision, scaling challenge, or architectural trade-off that is essential for building a production-ready newsletter monetization platform.

Q1: How would you design the email delivery system to handle 100 million emails per day while maintaining 99.5% deliverability?

The key is a multi-layered approach. First, implement a distributed email queue using Kafka that decouples email composition from delivery. Each publication's subscriber list is pre-segmented into batches of 500 recipients, and each batch is assigned to a delivery worker. The delivery workers select SMTP providers based on recipient domain and current provider health metrics. Implement aggressive bounce handling — hard bounces are immediately suppressed, soft bounces trigger exponential backoff retries. Maintain seed lists at major email providers (Gmail, Outlook, Yahoo) for real-time inbox placement monitoring. Use a shared sending domain with strong reputation for new publications while allowing established publications with their own domains to send from authenticated custom domains. Implement rate limiting per domain and per IP to prevent overloading any single sending infrastructure.

Q2: How do you handle the payment reconciliation between your internal ledger and Stripe's records?

Implement a dual-write pattern where every financial event is recorded in both the platform's PostgreSQL ledger and confirmed through Stripe webhooks. The ledger stores the platform's version of truth (including the 10% fee calculation), while Stripe provides the payment processor's view. A nightly reconciliation job compares the two ledgers, flagging discrepancies for manual review. Key events include subscription.created, invoice.paid, invoice.payment_failed, charge.refunded, and customer.subscription.deleted. Each event is idempotently processed using Stripe's event ID to prevent duplicate processing. The reconciliation also handles edge cases like partial refunds, subscription prorations, and currency conversion differences.

Q3: How would you design the recommendation engine to handle the cold start problem for both new readers and new publications?

For new readers, implement a multi-signal onboarding flow that collects interest categories, preferred publication frequency, and pricing sensitivity during signup. Use these explicit signals to seed initial recommendations. As the reader interacts, gradually blend in collaborative signals. For new publications, use content-based features (NLP analysis of post titles, summaries, and categories) to find similar established publications and position the new publication alongside them. Implement a "exploration budget" where a percentage of recommendation slots are reserved for new publications to gather engagement data. Use the publication's early engagement metrics (open rates, click-through rates from initial subscribers) as quality signals to accelerate the learning curve. The key metric for both cold start scenarios is time-to-quality-match — how quickly the system finds good recommendations despite limited historical data.

Q4: Design the system to handle a viral newsletter that gains 500,000 subscribers in 24 hours.

This scenario tests understanding of horizontal scaling, queue management, and graceful degradation. The subscriber registration flow must be horizontally scalable — use auto-scaling application servers behind a load balancer with consistent hashing for session affinity. The email delivery queue must handle the sudden volume spike — Kafka partitions by publication ID to ensure orderly delivery per publication while allowing parallel processing across publications. The payment system must handle subscription surges — implement a checkout queue that smooths out spikes and retries failed Stripe API calls with exponential backoff. The database must handle write amplification — use read replicas for analytics queries, connection pooling for write operations, and batch inserts for bulk subscriber creation. Implement circuit breakers on external dependencies (Stripe, SMTP providers) to prevent cascade failures.

Q5: How would you implement the Notes feed to handle a user following 500+ publications and accounts?

The feed generation for high-follow users requires a hybrid fan-out approach. For the initial feed load, use a pre-computed feed stored in Redis that is updated via fan-out-on-write for accounts with more than 1,000 followers. For accounts with fewer followers, compute the feed on-read using a query that fetches the last 100 notes from each followed account/publication and scores them. Implement a two-tier caching strategy: the first tier caches the full feed page in Redis with a 5-minute TTL, and the second tier caches individual note engagement counts with a 1-minute TTL. Use cursor-based pagination to handle the large feed efficiently. For the real-time updates (new notes appearing in the feed), use WebSocket connections with server-side filtering to only push notes from followed accounts. Implement a "feed digest" computation that runs every 15 minutes to refresh the pre-computed feed for high-follow users.

Q6: How do you ensure content portability and avoid vendor lock-in while maintaining platform-specific features?

The key principle is separating content storage from presentation. Store content in an open, portable format (Markdown with metadata YAML frontmatter) alongside the platform's internal rich-text format. Every post maintains an export-ready version that can be downloaded at any time. For subscriber data, provide CSV/JSON exports with consent metadata. For analytics, provide aggregated data exports in standard formats. Implement open API standards (ActivityPub for federation) that allow external tools to interact with publication data. Use open standards for podcast RSS feeds that any podcast client can consume. The business model (10% revenue share) creates incentive alignment — writers stay because the platform provides value, not because they can't leave. Document all data formats and provide migration guides for moving to competing platforms.

Q7: Design the real-time analytics dashboard that handles 10,000 concurrent writers viewing live metrics during a major publishing event.

Implement a CQRS (Command Query Responsibility Segregation) pattern where the write path (event ingestion) and read path (dashboard queries) are separated. Event data flows through Kafka into Apache Flink for real-time aggregation into pre-computed materialized views stored in Redis. The dashboard reads from these materialized views rather than querying raw event data. Use Server-Sent Events (SSE) to push dashboard updates to connected clients. Implement connection management that limits each writer to one active dashboard connection and uses sticky sessions to ensure consistent reads. Cache the materialized views with sliding window expiration (last 24 hours cached, older data queried from PostgreSQL). Implement a degradation strategy that falls back to batch-computed metrics (refreshed every 5 minutes) if the real-time pipeline is under stress.

Q8: How would you implement the referral program to prevent gaming and fraud while keeping the user experience simple?

Implement a multi-signal fraud detection system that analyzes referral patterns without adding friction to legitimate referrals. Track IP addresses, device fingerprints, email domain patterns, and signup timing to identify suspicious clusters. Use statistical anomaly detection to flag referral chains that deviate from normal patterns (e.g., a single referrer generating 100+ signups in an hour from the same IP range). Implement a "delayed gratification" model where referral rewards are only fulfilled after the referred subscriber has been active for 7 days and has opened at least one email, preventing fake account abuse. Use rate limiting per referrer (maximum 50 new referrals per day) to prevent automated referral farming. For high-value rewards, implement manual review. Maintain a global fraud database that tracks known fraudulent patterns across the platform to prevent repeat offenders from switching publications.

Q9: How would you handle the migration of a publication with 2 million subscribers from Mailchimp to your platform?

Large-scale migrations require a phased approach. Phase 1: Content migration — import all posts via Mailchimp's API, converting HTML to the platform's rich-text format and re-hosting images on the platform's CDN. This runs asynchronously and typically takes 4-8 hours for a large publication. Phase 2: Subscriber import — pull the subscriber list in batches of 10,000, validating email addresses, checking against global suppression lists, and creating subscriber records. Import subscribers as "pending confirmation" rather than immediately active. Phase 3: Confirmation campaign — send a reconfirmation email to all imported subscribers explaining the move and asking them to confirm their subscription. This double opt-in approach protects deliverability and ensures GDPR compliance. Phase 4: Gradual sending — start with low-volume sends (10% of the list) and ramp up over 2 weeks as the sending reputation is established. Provide the writer with a migration dashboard that shows progress, validation failures, confirmation rates, and deliverability metrics throughout the process.

Q10: Design the system to support custom publication domains with automatic SSL certificate provisioning.

Custom domains require DNS verification, SSL certificate provisioning, and request routing. When a writer adds a custom domain, the system generates DNS configuration instructions (CNAME record pointing to the platform's CDN). Use ACME protocol (Let's Encrypt) for automatic SSL certificate provisioning. Implement a DNS polling service that checks for the CNAME record every 30 seconds during the verification process. Once the CNAME is detected, trigger certificate issuance via the ACME challenge. Route requests to custom domains through a reverse proxy (Nginx/HAProxy) that maintains a mapping of custom domains to publication IDs. Use wildcard SSL certificates for subdomains (*.substack.com) and individual certificates for custom domains. Implement certificate renewal automation with 30-day pre-expiration renewal windows. Handle DNS propagation delays gracefully by providing writers with real-time verification status and estimated completion times.

Ayodhyya - System Design Blog Series

Substack Newsletter Monetization Platform - Senior+ Guide