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.
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.
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.