Skip to content

Top Down and BFF

Starting point

Top-down infrastructure overview had just been introduced. BFF, YARP, auth flow, database per service reporting patterns had not been covered yet. Goal was to build a complete mental model of how a production system is structured from DNS down to storage, then understand how auth, routing, and cross-service data needs are handled.


What we did

Top-down infrastructure walkthrough

Covered every layer of a production system in order. Each layer explained with its job, when to use it, and mapped to student's existing stack.

YARP as API Gateway

Decided on YARP over APIM. Built a complete working implementation.

Auth flow through the gateway

Covered JWT validation at gateway, claims injection, custom auth handler in downstream services.

BFF pattern and full auth code flow

Covered what a BFF is, where it sits, how auth code flow works without PKCE, how Angular gets user info, how tokens are stored server side.

Database per service — reporting DB deep dive

Covered how cross-service data needs are handled via read model and reporting DB.


What we covered in detail


Layer 1 — DNS

Translates domain name to IP address. Nothing more.

api.yourplatform.com → 20.219.66.48

Client knows a name. DNS resolves it to your VM's public IP. Traffic arrives at your machine.

Relevance to student: Azure VM has a public IP. DNS points your domain at it.


Layer 2 — CDN

Network of edge servers globally distributed. Serves static assets from a node close to the user.

User in London requests invoice PDF
→ hits CDN edge node in London
→ served locally, not from your VM in West India
→ fast, low latency

When to use: user-facing systems with static content, downloadable files, images, JS, CSS.

When not to use: pure API-to-API traffic. Student's PEPPOL platform is B2B — no CDN needed. ERP with downloadable reports — CDN makes sense for those files.


Layer 3 — Load Balancer

Sits in front of service instances. Distributes incoming requests across them.

Request → Load Balancer → Instance A
                        → Instance B
                        → Instance C

Why it exists:

  • Removes single point of failure at entry
  • Enables horizontal scaling — add instances behind it
  • Health checks — removes unhealthy instances automatically

Strategies:

  • Round robin — A, B, C, A, B, C. Simple, default.
  • Least connections — sends to instance with fewest active connections. Better for uneven request durations.
  • Consistent hashing — same client always hits same instance. Used for sticky sessions, websocket connections.

Relevance to student: Docker Swarm has a built-in load balancer. Deploy a service with 3 replicas — Swarm distributes requests automatically via ingress mesh. Already in use.


Layer 4 — API Gateway

Single entry point for all clients. Sits in front of services.

Jobs:

  • Authentication — validate JWT before request reaches service
  • Rate limiting — 100 requests per minute per client
  • Request routing — /invoices/ goes to Invoice Service, /ubl/ goes to UBL Service
  • SSL termination — HTTPS handled here, internal traffic is HTTP
  • Request/response transformation
Client → API Gateway (auth, rate limit, route) → Invoice Service
                                               → UBL Service
                                               → Relay Service

Clients never call services directly. Gateway is the only public surface.

Student correctly questioned the ordering of load balancer vs API Gateway. Clarified:

Client
  ↓
Load Balancer        ← distributes across multiple gateway instances
  ↓
API Gateway          ← auth, rate limiting, routes to correct service
  ↓
Load Balancer        ← distributes across multiple instances of that service
  ↓
Service instances

Two load balancers in a large system. In smaller systems they collapse into one. Nginx in student's stack does both simultaneously — reverse proxy and basic gateway combined.

Relevance to student: Nginx handles SSL termination and reverse proxy. Adding YARP adds application-level concerns on top.


YARP vs APIM

YARP APIM
Cost Free Expensive
Control Full — your code Limited to policy config
Auth integration Your existing auth server Configurable but opaque
Ops burden You maintain it Managed
Cloud agnostic Yes — runs anywhere Azure only

YARP chosen — fits cloud-agnostic self-hosted philosophy. Runs as ASP.NET Core app in Docker container inside Swarm stack.


Full YARP implementation

Architecture:

Internet
  ↓
Nginx (SSL termination)
  ↓
YARP Gateway (auth, rate limiting, routing)
  ↓
Services inside Swarm (no ports exposed to host)

Only YARP is published to the host. Services are invisible to the outside world.

Program.cs — YARP Gateway:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidateAudience = true,
            ValidAudience = builder.Configuration["Jwt:Audience"],
            ValidateLifetime = true,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]))
        };
    });

builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

app.UseAuthentication();
app.UseAuthorization();

// claims injection middleware
app.Use(async (context, next) =>
{
    if (context.User.Identity?.IsAuthenticated == true)
    {
        var tenantId = context.User.FindFirst("tenantId")?.Value;
        var userId = context.User.FindFirst("sub")?.Value;
        var roles = context.User.FindAll("role").Select(c => c.Value);

        context.Request.Headers["X-Tenant-Id"] = tenantId;
        context.Request.Headers["X-User-Id"] = userId;
        context.Request.Headers["X-Roles"] = string.Join(",", roles);
        context.Request.Headers.Remove("Authorization");
    }
    await next();
});

app.MapReverseProxy();

appsettings.json routing:

{
  "ReverseProxy": {
    "Routes": {
      "invoices-route": {
        "ClusterId": "invoice-cluster",
        "AuthorizationPolicy": "Default",
        "Match": { "Path": "/invoices/{**catch-all}" }
      },
      "health-route": {
        "ClusterId": "invoice-cluster",
        "AuthorizationPolicy": "Anonymous",
        "Match": { "Path": "/health" }
      },
      "webhook-route": {
        "ClusterId": "relay-cluster",
        "AuthorizationPolicy": "Anonymous",
        "Match": { "Path": "/webhooks/{**catch-all}" }
      }
    },
    "Clusters": {
      "invoice-cluster": {
        "Destinations": {
          "destination1": { "Address": "http://invoice_api:8080/" }
        }
      },
      "relay-cluster": {
        "Destinations": {
          "destination1": { "Address": "http://relay_service:8080/" }
        }
      }
    }
  }
}

AuthorizationPolicy: "Default" — requires valid JWT. AuthorizationPolicy: "Anonymous" — no auth required.

Webhook endpoint is public — PEPPOL access point cannot send a JWT. Protected instead by HMAC signature verification or IP whitelisting at middleware level.

Docker Swarm stack:

services:
  nginx:
    ports:
      - "443:443"
      - "80:80"

  yarp_gateway:
    # no ports published — nginx talks to it internally

  invoice_api:
    # no ports published — only gateway reaches it

  relay_service:
    # no ports published

Auth through the gateway — two options

Option 1 — Gateway handles auth completely

JWT validated at gateway. Claims extracted. Forwarded as headers. JWT stripped. Service never sees the token.

Client sends JWT
→ Gateway validates signature, expiry, issuer
→ invalid → 401 here, service never sees request
→ valid → extracts claims
→ injects X-Tenant-Id, X-User-Id, X-Roles
→ strips Authorization header
→ forwards to service

Service trusts injected headers blindly. Safe only because services are unreachable except through gateway — NSG rules and Swarm overlay enforce this.

Option 2 — Gateway forwards, service validates

Gateway passes JWT through. Service validates itself. Defence in depth — even if gateway is bypassed, JWT still required.

Cost: every service duplicates auth logic.

What large systems do — hybrid:

Gateway validates signature and expiry. Services read claims from headers. Authentication at gateway, authorisation at service.

Gateway → is token genuine → yes → forwards with claims headers
Service → reads claims → applies business-level authorisation rules

Custom authentication handler — keeping [Authorize] intact

Student wanted to keep [Authorize] attribute and User.FindFirst() calls unchanged in controllers. Correct instinct.

Solution: custom AuthenticationHandler that reads gateway-injected headers and builds ClaimsPrincipal from them. ASP.NET Core doesn't care how the principal was built — just that it was.

public class GatewayAuthenticationHandler 
    : AuthenticationHandler<AuthenticationSchemeOptions>
{
    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var tenantId = Request.Headers["X-Tenant-Id"].ToString();
        var userId = Request.Headers["X-User-Id"].ToString();
        var roles = Request.Headers["X-Roles"].ToString();

        if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(userId))
            return Task.FromResult(AuthenticateResult.Fail("Missing gateway headers"));

        var claims = new List<Claim>
        {
            new Claim("tenantId", tenantId),
            new Claim(ClaimTypes.NameIdentifier, userId),
        };

        foreach (var role in roles.Split(',', StringSplitOptions.RemoveEmptyEntries))
            claims.Add(new Claim(ClaimTypes.Role, role));

        var identity = new ClaimsIdentity(claims, Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

Registered in Invoice API:

builder.Services.AddAuthentication("GatewayAuth")
    .AddScheme<AuthenticationSchemeOptions, GatewayAuthenticationHandler>(
        "GatewayAuth", _ => { });

Controllers stay completely unchanged:

[Authorize]
[HttpPost("/invoices")]
public async Task<IActionResult> CreateInvoice([FromBody] CreateInvoiceRequest request)
{
    var tenantId = User.FindFirst("tenantId")?.Value;
    var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
    var authContext = new AuthContext(tenantId, userId);
    await _invoiceService.CreateAsync(request, authContext);
    return Ok();
}

Additional hardening — shared internal secret between gateway and services:

context.Request.Headers["X-Gateway-Secret"] = "internal-secret";

// handler verifies it
var gatewaySecret = Request.Headers["X-Gateway-Secret"].ToString();
if (gatewaySecret != _config["Gateway:Secret"])
    return Task.FromResult(AuthenticateResult.Fail("Invalid gateway secret"));

Ensures requests that bypass the gateway and craft the right headers manually are still rejected.


Full request flow for POST /invoices

Client
 POST /invoices with Authorization: Bearer <jwt>

Nginx
 terminates SSL
 forwards to YARP gateway on internal network

YARP Gateway
 UseAuthentication validates JWT signature and expiry
 UseAuthorization checks route policy  Default = must be authenticated
 invalid  401, Invoice API never sees it
 valid  claims injection middleware runs
 injects X-Tenant-Id, X-User-Id, X-Roles
 injects X-Gateway-Secret
 strips Authorization header
 forwards to http://invoice_api:8080/invoices

Invoice API
 GatewayAuthenticationHandler runs
 validates X-Gateway-Secret
 reads X-Tenant-Id, X-User-Id, X-Roles
 builds ClaimsPrincipal
 [Authorize] sees authenticated principal  passes
 controller reads User.FindFirst("tenantId")
 builds AuthContext
 processes invoice
 returns 201

Layer 5 — Cache

Fast in-memory store between services and database. Redis is the standard.

Service  Cache (hit)  return immediately
Service  Cache (miss)  DB  populate cache  return

When to cache:

  • Reference data — product catalogue, tenant config, pricing rules
  • Session data
  • Expensive computed results

When not to cache:

  • Data that changes on every write
  • Data where staleness has consequences — invoice status, payment state

Student's precomputed pricing read model is the same instinct applied at the DB layer. Expensive computation moved out of the request path.

Cache patterns:

  • Cache-aside (lazy loading) — check cache first, on miss read from DB and populate. Most common.
  • Write-through — write to cache and DB simultaneously. Cache always current, higher write latency.
  • Write-behind — write to cache, async flush to DB. Fast writes, risk of data loss on crash.

Layer 6 — Database

Already covered in depth — primary, read replicas, replication lag, row-level locking, optimistic reads pessimistic writes.


Layer 7 — Object Storage

Blob storage for large unstructured files. Not a database. Not a cache.

Invoice PDF generated → stored in Blob Storage → URL returned

Student has Azure Blob Storage behind IDocumentStore interface. Already in use.


Student's stack mapped to all layers

PEPPOL network / Merchant clients
  
DNS (your domain)
  
Nginx (reverse proxy + SSL termination)        in place
  
Docker Swarm ingress (load balancer)           in place
  
YARP Gateway (auth, rate limiting, routing)    designed this session
  
Invoice API / UBL Generator / Relay            in place
  
RabbitMQ (async messaging)                     in place
  
PostgreSQL primary                             in place
  
Azure Blob Storage (UBL XMLs, audit trail)     in place

Not in stack and not needed:

  • CDN — B2B API traffic, no static content served to users
  • Explicit cache layer — pricing read model compensates
  • Read replica — single primary, works at current scale

Database per service — reporting DB

Problem: dashboard needs data from multiple services. In a shared DB — one join query. In database per service — data lives in four different DBs, joins impossible.

Option 1 — API Composition:

Dashboard BFF
→ GET /orders/ORD001          → Order Service
→ GET /invoices?orderId=ORD001 → Invoice Service
→ GET /peppol?orderId=ORD001   → Relay Service
→ joins in memory
→ returns combined response

Works for simple cases. Painful when one service is slow, one is down, or you need to filter across services.

Option 2 — Read model via events (production answer):

Order Service     → OrderCreated, OrderUpdated
Invoice Service   → InvoiceCreated, InvoiceStatusChanged
Relay Service     → SentOverPeppol, WebhookReceived
Payment Service   → PaymentReceived
      ↓
RabbitMQ
      ↓
Read Model Service (consumes all events)
      ↓
Reporting DB (denormalised, one row per invoice)
      ↓
Dashboard BFF reads directly

Write path — services write to their own DBs, publish events. Read path — events flow into reporting DB, BFF reads from there. Two paths completely separate. CQRS at the system level.

Reporting DB structure — flat, denormalised:

CREATE TABLE invoice_dashboard (
    order_id            UUID,
    order_number        VARCHAR,
    customer_name       VARCHAR,
    order_total         DECIMAL,
    order_status        VARCHAR,
    order_created_at    TIMESTAMPTZ,
    invoice_id          UUID,
    invoice_number      VARCHAR,
    invoice_status      VARCHAR,
    invoice_created_at  TIMESTAMPTZ,
    peppol_status       VARCHAR,
    peppol_sent_at      TIMESTAMPTZ,
    webhook_received_at TIMESTAMPTZ,
    payment_status      VARCHAR,
    payment_received_at TIMESTAMPTZ,
    tenant_id           UUID
);

One row per invoice. No joins at query time. Row builds up progressively as events arrive.

Example event handler:

public async Task Handle(OrderCreated evt)
{
    await _db.ExecuteAsync(@"
        INSERT INTO invoice_dashboard
            (order_id, order_number, customer_name, order_total,
             order_status, order_created_at, tenant_id)
        VALUES
            (@orderId, @orderNumber, @customerName, @total,
             'created', @createdAt, @tenantId)
        ON CONFLICT (order_id) DO UPDATE
        SET order_status = @status",
        new { evt.OrderId, evt.OrderNumber, 
              evt.CustomerName, evt.Total, 
              evt.CreatedAt, evt.TenantId });
}

public async Task Handle(SentOverPeppol evt)
{
    await _db.ExecuteAsync(@"
        UPDATE invoice_dashboard
        SET peppol_status = 'sent',
            peppol_sent_at = @sentAt
        WHERE order_id = @orderId",
        new { evt.SentAt, evt.OrderId });
}

Consistency model: eventually consistent. Row incomplete while events in flight. Acceptable for dashboards.

Example dashboard query — cross service filter, no joins:

SELECT *
FROM invoice_dashboard
WHERE tenant_id = @tenantId
AND peppol_sent_at >= @startOfMonth
AND payment_status IS NULL
ORDER BY order_total DESC

Impossible with API composition. Trivial with reporting DB.

Indexing strategy — aggressive, read optimised:

CREATE INDEX idx_dashboard_tenant_created
    ON invoice_dashboard(tenant_id, order_created_at DESC);

CREATE INDEX idx_dashboard_peppol_status
    ON invoice_dashboard(tenant_id, peppol_status);

CREATE INDEX idx_dashboard_payment_status
    ON invoice_dashboard(tenant_id, payment_status);

Transactional DBs — minimal indexes, optimised for writes. Reporting DB — many indexes, optimised for reads.


BFF pattern

Each service is the only thing that touches its own DB. BFF sits outside the microservice stack, faces the user on one side, gateway on the other.

Web Dashboard
  
Dashboard BFF (external facing, registered client with auth server)
  
API Gateway
  
Internal Services (Invoice, UBL, Relay, Payment)
  
Their own DBs

BFF shapes data exactly for the frontend. Different frontends get different BFFs.

Web Dashboard  Dashboard BFF  reads reporting DB
Mobile App     Mobile BFF    calls services, lighter payload

BFF auth — full auth code flow without PKCE

BFF is a confidential client. Has a client secret. Can keep it safe. PKCE not needed — PKCE exists for public clients (SPAs, mobile) that cannot keep secrets.

Step 1 — Angular redirects to Auth Server:

Angular  redirects browser to:
https://auth-server/authorize
  ?client_id=dashboard-bff
  &redirect_uri=https://bff/callback
  &response_type=code
  &scope=invoices.read
  &state=random123

Angular knows client_id only. Public, safe in browser. Client secret never touches Angular.

Step 2 — User logs in at Auth Server:

Login page served by Auth Server. Not Angular. Not BFF. Angular never sees credentials.

Step 3 — Auth Server redirects to BFF callback with auth code:

https://bff/callback?code=abc123&state=random123

Code lands at BFF. Not Angular. Redirect URI points to BFF specifically for this reason.

Student asked why callback can't be on Angular. It can — but then Angular either needs the client secret to exchange the code (exposed in browser) or you use PKCE and tokens live in browser. BFF callback exists to keep the exchange server side.

Student correctly identified: the callback is just a GET endpoint. No UI. No Angular component. Browser hits it, gets a cookie and a redirect back to the dashboard. User sees nothing happen.

Step 4 — BFF exchanges code for tokens:

BFF  POST https://auth-server/token
  client_id=dashboard-bff
  client_secret=supersecret     only BFF knows this
  code=abc123
  grant_type=authorization_code
  redirect_uri=https://bff/callback

Auth Server  access token + refresh token  BFF

Step 5 — BFF establishes session:

[HttpGet("/callback")]
public async Task<IActionResult> Callback(
    [FromQuery] string code,
    [FromQuery] string state)
{
    // exchange code for tokens
    var tokens = await _authServer.ExchangeCodeAsync(code);

    // build claims from the access token or userinfo endpoint
    var claims = new List<Claim>
    {
        new Claim(ClaimTypes.NameIdentifier, tokens.UserId),
        new Claim("tenantId", tokens.TenantId),
        new Claim("access_token", tokens.AccessToken),
        new Claim("refresh_token", tokens.RefreshToken),
    };

    var identity = new ClaimsIdentity(
        claims, 
        CookieAuthenticationDefaults.AuthenticationScheme);

    var principal = new ClaimsPrincipal(identity);

    // this is the correct way — issues the encrypted cookie
    // marks the session as authenticated
    await HttpContext.SignInAsync(
        CookieAuthenticationDefaults.AuthenticationScheme,
        principal,
        new AuthenticationProperties
        {
            IsPersistent = true,
            ExpiresUtc = DateTimeOffset.UtcNow.AddHours(1)
        });

    return Redirect("https://your-angular-app/dashboard");
}

Two authentications happen simultaneously in this exchange:

  • User is authenticated — Auth Server confirmed credentials, issued token representing the user
  • BFF is authenticated as a client — Auth Server only issued the token because BFF presented valid client_id and client_secret

Both established in one flow. Not separate steps.


How Angular knows it's authenticated — /me endpoint

Cookie is HttpOnly. JavaScript cannot read it. Angular cannot extract user info from it directly.

BFF exposes a /me endpoint. Angular calls it on load.

GET https://bff/me
Cookie: session=xyz  ← sent automatically by browser

Response:
{
  "userId": "123",
  "tenantId": "abc",
  "name": "Abhishek",
  "roles": ["admin", "invoicer"]
}

Angular stores this in an AuthService signal. Uses it to drive UI — show/hide nav items, guard routes, check roles.

@Injectable({ providedIn: 'root' })
export class AuthService {
  private currentUser = signal<User | null>(null);

  async loadUser(): Promise<void> {
    const user = await this.http.get<User>('/me').toPromise();
    this.currentUser.set(user);
  }

  isAuthenticated(): boolean {
    return this.currentUser() !== null;
  }
}

If session expired — BFF returns 401 from /me. Angular redirects to login. Auth code flow starts again.


Token storage in BFF

Option 1 — Redis backed session:

Cookie value: session=xyz789   ← opaque ID, meaningless alone
Redis:        xyz789 → { access_token, refresh_token, expiry }

Fast lookups. Tokens never leave server. Session invalidated instantly by deleting Redis key. Production answer.

Option 2 — Encrypted cookie:

Tokens encrypted and stored inside cookie itself. No Redis dependency. ASP.NET Core cookie auth middleware does this by default.

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.Cookie.HttpOnly = true;
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        options.Cookie.SameSite = SameSiteMode.Strict;
        options.ExpireTimeSpan = TimeSpan.FromHours(1);
    });

Student correctly identified: BFF only decrypts the cookie when it needs to make a downstream resource call. Middleware just validates signature and expiry on every request — lightweight. Full decryption only when access token is needed.

Token refresh — transparent to Angular:

Angular → GET /invoices → BFF checks access token → expired
→ BFF uses refresh token → calls Auth Server → new access token
→ BFF updates stored tokens
→ forwards request with new token → Gateway
→ Angular gets response, never knew token expired

Service-to-service auth — BFF calling downstream services

Three patterns:

Pattern 1 — Pass user JWT downstream: BFF forwards user's access token to each service. Simple. Services need JWT validation logic again.

Pattern 2 — Client credentials (canonical enterprise answer): BFF has its own service identity. Authenticates to downstream services as itself using OAuth2 client credentials flow. User context forwarded as separate headers.

BFF  POST /token
  grant_type=client_credentials
  client_id=dashboard-bff
  client_secret=xxx
 Auth Server issues BFF a service token
BFF  calls Invoice Service with service token + X-User-Id header

Pattern 3 — Gateway secret + forwarded claims (fits student's stack): Gateway validates user JWT, injects claims headers plus gateway secret. BFF reads these, forwards same headers to downstream services. Services validate gateway secret via custom auth handler.

Interview framing: describe Pattern 2. Implement Pattern 3.


Clean separation of responsibilities

Auth code flow    → BFF only
Token validation  → API Gateway + internal services
Token issuance    → Auth Server only
User info to UI   → /me endpoint on BFF

What we messed up

Nothing significant in this section. Student's questions were sharp and caught real nuances:

Load balancer vs API Gateway ordering — student correctly pushed back. Two load balancers exist in large systems. Clarified correctly.

Callback on Angular — student asked a fair question. The answer is not that it's impossible — it's that it forces either exposing the client secret or accepting tokens in the browser. Either is a worse tradeoff than BFF callback.

BFF decryption timing — student correctly identified that the BFF doesn't decrypt the cookie on every request, only when calling a downstream resource. This was a more precise understanding than what was initially stated.


Key values and config to remember

Item Value
YARP gateway Only published port in Swarm
Internal service port 8080
Auth scheme name GatewayAuth (custom handler)
Gateway secret header X-Gateway-Secret
Claims headers X-Tenant-Id, X-User-Id, X-Roles
Cookie flags HttpOnly, Secure, SameSite Strict
BFF callback GET endpoint, no UI, just exchange + redirect
/me endpoint Returns user info to Angular after login
Token storage options Redis (production) or encrypted cookie (simpler)
Auth code flow type Without PKCE — BFF is confidential client

Unanswered questions / things to investigate

  • Redis vs encrypted cookie not decided for student's stack — depends on whether a Redis instance is added to Swarm
  • Token refresh implementation in BFF not built out — covered conceptually only
  • Rate limiting implementation in YARP not covered — ASP.NET Core rate limiting middleware applies, needs a session
  • How YARP handles load balancing across multiple instances of the same service — destinations config supports multiple entries, round robin by default
  • Relay → Merchant API refinement — confirmed event-based is right direction, not implemented

What's next

  1. HLD framework — five steps applied to every problem
  2. URL shortener — first drill problem, student drives clarifying questions
  3. Capacity estimation practice — back of envelope
  4. Caching strategies in depth — cache-aside, write-through, write-behind, eviction
  5. Consistent hashing — distribution, virtual nodes
  6. System design problems tier 1 — Twitter feed, WhatsApp, notification system