ET Dufitumukiza
Case Study .NET 9 React Fly.io Cloudflare

Rentals Hub — Building a rental marketplace for Rwanda

An end-to-end case study: domain modelling, distributed .NET API, React frontend, full observability stack, and production deployment — built for the East African market.

Live at

rentalsrw.com ↗

Source

GitHub ↗

Status

🟢 Production

The Problem

Rwanda's rental market — houses and cars — runs almost entirely through word of mouth, social media posts, and WhatsApp groups. There was no centralised platform tailored to how Rwandans actually rent: direct owner contact, phone-first, multi-language, and no need for in-app payments.

The goal was a listings-first platform where owners post, renters browse, and contact happens directly via WhatsApp, SMS, or phone — with the platform getting out of the way.

Architecture

System diagram

  ┌──────────────────────────────────────────────────┐
  │                 Browser / Mobile                 │
  └───────────────────────┬──────────────────────────┘
                          │ HTTPS
                          ▼
  ┌──────────────────────────────────────────────────┐
  │          Cloudflare (WAF + DDoS + CDN)           │
  │   ┌─────────────────────┐  ┌───────────────────┐ │
  │   │   CF Workers        │  │      CF R2        │ │
  │   │   (React SPA)       │  │   (Image store)   │ │
  │   └─────────────────────┘  └───────────────────┘ │
  └────────────────────┬─────────────────────────────┘
                       │ X-Origin-Secret header
                       ▼
  ┌──────────────────────────────────────────────────┐
  │         Fly.io  (2 machines, cdg + ams)          │
  │  ┌──────────────────────────────────────────┐    │
  │  │           ASP.NET Core API               │    │
  │  │  Rate limiting · JWT · Profanity filter  │    │
  │  │  OpenTelemetry · Health checks · CORS    │    │
  │  └──────────┬──────────────────┬────────────┘    │
  └─────────────┼──────────────────┼─────────────────┘
                │                  │
   ┌────────────▼──────┐  ┌────────▼────────────┐
   │  Neon PostgreSQL  │  │    Redis (Upstash)  │
   │  (pooler mode)    │  │  Cache + OTP store  │
   └───────────────────┘  └─────────────────────┘
                │
   ┌────────────▼──────────────┐
   │     Grafana Cloud         │
   │  OTLP endpoint            │
   │  Loki · Tempo · Prom      │
   └───────────────────────────┘

API

ASP.NET Core (.NET 9), EF Core, Repository pattern, Clean Architecture

Frontend

React 18 + TypeScript, i18next (4 langs), Leaflet maps, Vite

Database

Neon PostgreSQL (serverless, connection pooler)

Cache

Redis via Upstash — distributed cache + OTP store

Storage

Cloudflare R2 — S3-compatible image storage

Hosting

Fly.io (API) · Cloudflare Workers (SPA) · Cloudflare Pages (portfolio)

Deep Dive 1: Cross-Replica Cache Invalidation

Services/ListingCacheInvalidator.cs

Fly.io runs two machines in different regions. Each machine has its own in-process IMemoryCache. When an owner updates a listing on Machine A, Machine B still serves stale data — cache keys tracked on A are invisible to B.

The solution: a Redis-backed key registry. Every time a cache key is added to the in-process HashSet, it's also written to a shared Redis key (_listing_cache_key_registry) as a JSON-encoded set. On invalidation, each machine reads the registry from Redis, merges it with its own local set, then removes every key from both caches.

ListingCacheInvalidator.cs — InvalidateAllAsync
public async Task InvalidateAllAsync()
{
    HashSet<string> keys;
    lock (_lock) { keys = new HashSet<string>(_keys); }

    // Merge keys from all other instances via Redis registry
    var registryJson = await _cache.GetStringAsync(_registryKey);
    if (registryJson is not null)
    {
        var remoteKeys = JsonSerializer.Deserialize<HashSet<string>>(registryJson);
        if (remoteKeys is not null) keys.UnionWith(remoteKeys);
    }

    // Remove every tracked key from the distributed cache
    var removeTasks = keys.Select(k => _cache.RemoveAsync(k));
    await Task.WhenAll(removeTasks).ConfigureAwait(false);

    lock (_lock) { _keys.Clear(); }
    await _cache.RemoveAsync(_registryKey).ConfigureAwait(false);
}

Redis updates are fire-and-forget (non-awaited) — if Redis is temporarily unavailable, the in-process cache still invalidates locally. Eventual consistency is acceptable here; the alternative (distributed locks) would add latency to every write path.

Deep Dive 2: OpenTelemetry Golden Signals

Services/RentalsHubMeter.cs · Telemetry/PhoneRedactionProcessor.cs

The goal was to know — before users complain — whether the platform is healthy. I implemented the four Golden Signals (Latency, Traffic, Errors, Saturation) using a custom OpenTelemetry Meter with 7 instruments:

rentals.search.total

Counter

rentals.listing.views.total

Counter

rentals.contact.clicks.total

Counter

rentals.listing.created.total

Counter

rentals.listing.create_failed.total

Counter

rentals.listings.active

UpDownCounter

rentals.search.duration.ms

Histogram

A GDPR concern: phone numbers appear in listing queries and log context. The solution is a custom PhoneRedactionProcessor — an OpenTelemetry BaseProcessor<Activity> that scans tag values for E.164 patterns and replaces them with [REDACTED] before any data leaves the process.

Metrics are scraped by Fly.io's internal Prometheus collector at /metrics and shipped to Grafana Cloud via OTLP. The dashboard has 16 panels across 6 rows covering search throughput, contact conversion rate, DB connection pool health, and memory saturation — with alert rules for OOM risk.

Deep Dive 3: Security — Origin Validation + Rate Limiting

Middleware/CloudflareProxyMiddleware.cs · Extensions/BuilderExtensions.cs

Fly.io exposes the origin server directly to the internet. Without mitigation, attackers can bypass Cloudflare's WAF entirely and DDoS the origin. The fix: every request must carry an X-Origin-Secret header injected by a Cloudflare Transform Rule (server-side, invisible to clients). Requests without the correct secret get a 403 at the middleware layer, before any application code runs.

Rate limiting is two-tiered: ASP.NET Core's built-in fixed-window limiter handles IP-based burst protection fast, then a Redis-backed limiter keys by JWT NameIdentifier for authenticated endpoints. This prevents a scenario where many users share a corporate IP and one misbehaving client blocks everyone else.

Defense layers (request lifecycle)

1. Cloudflare WAF (DDoS, bot management)
2. X-Origin-Secret check (middleware, 403 if missing)
3. HTTPS enforcement (UseAppHttpsRedirectionIfProd)
4. IP rate limit (ASP.NET Core, fixed window)
5. User rate limit (Redis, JWT NameIdentifier)
6. JWT validation (ClockSkew = 0)
7. Claim-scoped authorization ([Authorize] + owner id check)
8. Input validation + profanity filtering (ListingRequestValidator)

Honest Tradeoffs

CSV image storage

Images stored as a comma-separated string in Listing.Images. Simple for MVP; migration path to a normalised ListingImage join table is clear.

Thin test coverage

37 unit tests covering repositories and validation. The architecture is fully testable (DI, interfaces, in-memory EF Core). E2E coverage is the next investment.

No code splitting

React SPA loads as a single bundle. React.lazy() and dynamic imports would improve initial load on slow connections — important for the target market.

Nullable OwnerId

Guid? on Listing for backward compatibility with pre-auth data. Nullable checks are explicit throughout; a migration to required FK is planned.