Learnixo

.NET & C# Development · Lesson 183 of 229

System Design Scenarios in .NET — Walkthroughs for Senior Interviews

System Design Scenarios in .NET — Walkthroughs for Senior Interviews

System design interviews test whether you can translate requirements into concrete architecture decisions. This guide walks through five common scenarios with specific .NET implementation choices — not vague boxes and arrows.


How to Answer System Design Questions

PACED framework:
  P — Problem & constraints  (ask clarifying questions)
  A — API design             (endpoints, contracts)
  C — Capacity estimation    (scale numbers)
  E — Entity model           (data model, storage)
  D — Deep dive              (the hard parts: consistency, failure modes)

Always ask:
  - How many users / requests per second?
  - Read-heavy or write-heavy?
  - What consistency guarantees are needed?
  - What does "failure" look like? (latency? data loss? both?)
  - Any geographic distribution?

Scenario 1 — URL Shortener (like bit.ly)

Requirements

- Create a short URL for any long URL
- Redirect short URL to original in under 10ms
- 1B URLs stored, 10K writes/s, 100K reads/s
- Analytics: click count per URL

Design

C#
// Entity
public class ShortUrl
{
    public string Code        { get; set; } = "";   // "aB3kZ9" — 6 chars = 62^6 = 56B options
    public string OriginalUrl { get; set; } = "";
    public int    UserId      { get; set; }
    public DateTime CreatedAt { get; set; }
    public long   ClickCount  { get; set; }   // denormalised for speed
}

// Code generation — base62 encode a unique ID
public class ShortCodeGenerator
{
    private const string Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";

    public string Generate(long id)
    {
        var sb = new StringBuilder();
        while (id > 0)
        {
            sb.Insert(0, Chars[(int)(id % 62)]);
            id /= 62;
        }
        return sb.ToString().PadLeft(6, '0');
    }
}
C#
// Redirect endpoint — the hot path
app.MapGet("/{code}", async (
    string code,
    IDatabase redis,
    IShortUrlRepository db,
    HttpContext ctx) =>
{
    // 1. Cache lookup (sub-millisecond)
    var cached = await redis.StringGetAsync($"url:{code}");
    if (cached.HasValue)
    {
        // Async click count increment — don't block the redirect
        _ = redis.StringIncrementAsync($"clicks:{code}", flags: CommandFlags.FireAndForget);
        return Results.Redirect(cached!, permanent: false);
    }

    // 2. Database lookup (cache miss)
    var url = await db.GetByCodeAsync(code);
    if (url is null) return Results.NotFound();

    // 3. Populate cache (1-hour TTL for hot URLs)
    await redis.StringSetAsync($"url:{code}", url.OriginalUrl, TimeSpan.FromHours(1),
        flags: CommandFlags.FireAndForget);

    return Results.Redirect(url.OriginalUrl, permanent: false);
});
Trade-offs discussed:
- Code uniqueness: use a distributed counter (Redis INCR) or UUID hash — avoid collisions
- Click counting: Redis INCR per redirect, flush to PostgreSQL every minute with a background job
- Hotspot URLs: in-memory cache in the application (IMemoryCache) before Redis
- Custom codes: check availability before saving
- Expiry: add ExpiresAt column, check in redirect endpoint

Scenario 2 — Notification Service

Requirements

- Send notifications via Email, SMS, Push
- 1M notifications/day, peaks at 50K/hour
- Guaranteed delivery (at least once)
- Respect user preferences per channel
- Retry failed deliveries

Design

C#
// Domain
public record Notification(
    Guid   Id,
    int    UserId,
    string Type,         // "order_shipped", "password_reset"
    string Subject,
    string Body,
    Dictionary<string, string> TemplateVars);

public enum DeliveryStatus { Pending, Sent, Failed, Skipped }

public class DeliveryRecord
{
    public Guid   NotificationId { get; set; }
    public string Channel        { get; set; } = "";   // Email, SMS, Push
    public DeliveryStatus Status { get; set; }
    public int    AttemptCount   { get; set; }
    public string? ErrorMessage  { get; set; }
    public DateTime? SentAt      { get; set; }
}
C#
// Publisher — enqueues notification
public class NotificationPublisher(IBus bus)
{
    public async Task SendAsync(Notification notification, CancellationToken ct)
    {
        // Store in DB first (outbox), then publish
        await bus.Publish(notification, ct);
    }
}

// Consumer — routes to correct channel handlers
public class NotificationConsumer(
    IUserPreferenceService prefs,
    IEmailProvider email,
    ISmsProvider sms,
    IPushProvider push)
    : IConsumer<Notification>
{
    public async Task Consume(ConsumeContext<Notification> context)
    {
        var n       = context.Message;
        var userPrefs = await prefs.GetAsync(n.UserId, context.CancellationToken);

        // Deliver on all enabled channels
        var tasks = new List<Task>();

        if (userPrefs.EmailEnabled)
            tasks.Add(email.SendAsync(n, context.CancellationToken));

        if (userPrefs.SmsEnabled && IsSmsEligible(n.Type))
            tasks.Add(sms.SendAsync(n, context.CancellationToken));

        if (userPrefs.PushEnabled)
            tasks.Add(push.SendAsync(n, context.CancellationToken));

        await Task.WhenAll(tasks);   // parallel delivery
    }

    private static bool IsSmsEligible(string type)
        => type is "password_reset" or "security_alert";
}
Architecture:
  API → MassTransit (RabbitMQ) → Consumer pool (N replicas)
                                    → EmailProvider (SendGrid)
                                    → SmsProvider (Twilio)
                                    → PushProvider (FCM/APNs)

Retry: MassTransit exponential retry (5 attempts, max 1 hour)
DLQ: manual inspection for permanently failed notifications
Rate limiting: per-provider (SendGrid: 100/s, Twilio: 100/s)
Template rendering: Scriban/Razor templates, rendered in the consumer
Unsubscribe: update user preferences; check in consumer before delivering

Scenario 3 — Social Media Feed

Requirements

- Users follow other users
- Timeline: last 100 posts from followed users
- 10M users, 50M follows, 5M posts/day
- Timeline must load in under 100ms

Design Decision: Push vs Pull

Pull (fan-out on read):
  - Timeline query: SELECT posts WHERE author_id IN (SELECT followed_id FROM follows WHERE follower_id = ?)
    ORDER BY created_at DESC LIMIT 100
  - Simple, no precomputation
  - Expensive for users following 10,000 accounts (huge IN clause, slow JOIN)
  → Good for low-follow-count users

Push (fan-out on write):
  - When user posts: write post ID to each follower's timeline cache (Redis list)
  - Timeline read: read from Redis list — O(1)
  - Expensive for celebrities (10M followers = 10M Redis writes per post)
  → Good for normal users, terrible for celebrities

Hybrid (what Twitter/X actually does):
  - Normal users (< 5K followers): fan-out on write → push to timelines
  - Celebrities (> 5K followers): fan-out on read → merge at query time
C#
// Timeline cache — Redis sorted set (score = timestamp)
public class TimelineCache(IDatabase redis)
{
    public async Task AddPostAsync(long followerId, long postId, DateTimeOffset createdAt)
    {
        var key = $"timeline:{followerId}";
        await redis.SortedSetAddAsync(key, postId, createdAt.ToUnixTimeSeconds());

        // Trim to last 1000 posts
        await redis.SortedSetRemoveRangeByRankAsync(key, 0, -1001);

        // 7-day expiry (inactive users lose their cache)
        await redis.KeyExpireAsync(key, TimeSpan.FromDays(7));
    }

    public async Task<List<long>> GetTimelineAsync(long followerId, int count = 50)
    {
        var postIds = await redis.SortedSetRangeByRankAsync(
            $"timeline:{followerId}",
            0, count - 1,
            Order.Descending);

        return postIds.Select(id => (long)id).ToList();
    }
}

Scenario 4 — Payment Processor

Requirements

- Process credit card payments
- Exactly once: never charge twice for the same order
- Consistent: if charge succeeds, order status must reflect it
- Audit trail: every state change must be logged
- 10K transactions/s

Design

C#
// Idempotency key — client provides, server stores
public class PaymentEndpoint
{
    [HttpPost("/api/payments")]
    public async Task<IActionResult> ProcessPayment(
        [FromHeader(Name = "Idempotency-Key")] string idempotencyKey,
        PaymentRequest request,
        IPaymentService payments,
        CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(idempotencyKey))
            return BadRequest("Idempotency-Key header is required");

        return await payments.ProcessAsync(idempotencyKey, request, ct) switch
        {
            { AlreadyProcessed: true, Result: var r } => Ok(r),    // replay cached response
            { Success: true, Result: var r }          => Ok(r),
            { Declined: true, Message: var m }        => UnprocessableEntity(new { error = m }),
            _                                         => StatusCode(502, "Payment gateway error"),
        };
    }
}

// Service — idempotency + outbox
public class PaymentService(
    IPaymentGateway gateway,
    IIdempotencyStore idempotency,
    AppDbContext db,
    IPublishEndpoint bus)
{
    public async Task<PaymentServiceResult> ProcessAsync(
        string idempotencyKey,
        PaymentRequest request,
        CancellationToken ct)
    {
        // 1. Check idempotency store
        var existing = await idempotency.GetAsync(idempotencyKey, ct);
        if (existing is not null)
            return PaymentServiceResult.Replay(existing);

        // 2. Charge the card
        var chargeResult = await gateway.ChargeAsync(request.CardToken, request.Amount, ct);

        // 3. Save result + publish event atomically (outbox)
        await using var tx = await db.Database.BeginTransactionAsync(ct);

        var payment = new Payment
        {
            Id            = Guid.NewGuid(),
            IdempotencyKey = idempotencyKey,
            OrderId       = request.OrderId,
            Amount        = request.Amount,
            Status        = chargeResult.Success ? "Completed" : "Declined",
            GatewayRef    = chargeResult.Reference,
        };

        db.Payments.Add(payment);
        await idempotency.StoreAsync(idempotencyKey, chargeResult, ct);

        if (chargeResult.Success)
            await bus.Publish(new PaymentCompleted(payment.Id, request.OrderId), ct);

        await db.SaveChangesAsync(ct);
        await tx.CommitAsync(ct);

        return chargeResult.Success
            ? PaymentServiceResult.Success(chargeResult)
            : PaymentServiceResult.Declined(chargeResult.DeclineReason);
    }
}

Scenario 5 — Rate Limiter Service

Requirements

- 100 API requests per user per minute
- Distributed: 50 API server instances
- Low latency: decision in under 1ms
- Burst allowance: allow short bursts above the limit
C#
// Token bucket in Redis — atomic Lua script
// (see redis-patterns-dotnet.mdx for the full Lua implementation)

public class DistributedRateLimiter(IDatabase redis) : IRateLimiter
{
    private static readonly LuaScript Script = LuaScript.Prepare("""
        local tokens   = tonumber(redis.call('GET', KEYS[1])) or tonumber(ARGV[1])
        local capacity = tonumber(ARGV[1])
        local now      = tonumber(ARGV[2])
        local window   = tonumber(ARGV[3])
        local lastTs   = tonumber(redis.call('GET', KEYS[2])) or now

        -- Refill tokens based on time elapsed
        local elapsed = now - lastTs
        local refill  = math.floor(elapsed / window * capacity)
        tokens = math.min(capacity, tokens + refill)

        if tokens < 1 then return 0 end

        redis.call('SET', KEYS[1], tokens - 1, 'EX', window)
        redis.call('SET', KEYS[2], now,         'EX', window)
        return 1
        """);

    public async Task<bool> IsAllowedAsync(string userId, int capacity = 100, int windowSeconds = 60)
    {
        var result = await redis.ScriptEvaluateAsync(Script,
            [(RedisKey)$"rl:tokens:{userId}", (RedisKey)$"rl:ts:{userId}"],
            [capacity, DateTimeOffset.UtcNow.ToUnixTimeSeconds(), windowSeconds]);

        return (int)result == 1;
    }
}

Interview Tips

Things that impress interviewers:

1. Quantify everything — "100K reads/s at P99 under 100ms" not "it should be fast"

2. State trade-offs explicitly — "I chose push fan-out for normal users because pull is
   O(follows) on read; for celebrities I'd fall back to pull to avoid O(followers) on write"

3. Failure modes first — "If Redis is down, we fall back to the database directly
   with a 10x higher latency — acceptable as a degraded mode"

4. Idempotency for anything that costs money or sends a message
   — "Every payment endpoint requires an Idempotency-Key header"

5. The outbox pattern for cross-service consistency
   — "The payment record and the PaymentCompleted event save in one transaction"

6. Don't design a perfect system — design a system that fails gracefully
   — "When the notification queue is full, we drop lower-priority notifications
      and alert the on-call team rather than dropping payment confirmations"

Interview Answer

For any system design question:
1. Clarify: scale (req/s), consistency needs, failure modes
2. API first: GET /urls/{code} returns 302, POST /urls returns {code}
3. Data model: which table, which columns, which indexes
4. Hot path: cache strategy (Redis), what's in-memory vs on-disk
5. Write path: queue? synchronous? outbox?
6. Trade-offs: you've chosen X over Y because Z — state it
7. Failure: Redis down? Database down? What degrades, what breaks