.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 URLDesign
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 endpointScenario 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 deliveriesDesign
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 deliveringScenario 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 100msDesign 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 timeC#
// 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/sDesign
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 limitC#
// 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