System Design
Designing a URL Shortener as a Learning Project
A URL shortener in .NET 9, three copies behind nginx, backed by Cassandra and cached with Redis, built to actually understand a system design video instead of just watching it.
I watched a system design video about architecting a URL shortener, and instead of just watching it, I decide to actually build it: a real URL shortener in .NET 9, backed by Cassandra, cached with Redis, sitting behind an nginx load balancer, with a piece running in Azure.
What it actually does
Two endpoints. POST /shorten takes a long URL and returns a short code, something like jxs3y2C. GET /{code} redirects to the original URL. That's the whole surface. Everything interesting is in what makes it fast and able to handle real traffic.
Three copies, no shared memory
The app runs as three identical copies behind nginx, which splits traffic between them. All three copies talk to the same Cassandra database and the same Redis cache, and none of them keep anything in their own memory. Whichever copy answers a request, it sees the same data as the other two, which is what lets me add more copies when traffic grows instead of redesigning anything.
A tiny /whoami endpoint proves the rotation is actually happening:
for i in {1..9}; do curl -s http://localhost:8080/whoami; echo; done
{"server":"a0988b503bdf"} # copy 1
{"server":"da9800fda566"} # copy 2
{"server":"e6299466c10c"} # copy 3
{"server":"a0988b503bdf"} # copy 1 again...
Short codes with Base62
The code needs to be short and unique, so it's generated from a 62-character alphabet, digits plus lowercase plus uppercase:
private const string Alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
public const int CodeLength = 7;
public string Generate()
{
var buffer = new char[CodeLength];
for (int i = 0; i < CodeLength; i++)
{
buffer[i] = Alphabet[Random.Shared.Next(Alphabet.Length)];
}
return new string(buffer);
}
Seven characters out of that alphabet covers about 3.5 trillion possible codes.
Why Cassandra
A shortener only ever looks things up one way: give me the URL for this code. Cassandra is built exactly for that kind of lookup, and it can spread the data across many machines as it grows. It's genuinely overkill for a project this small, a single SQL database would do the job fine, but the point of building this was to actually work with the database that fits the real-world version of the problem.
Adding a cache without touching anything else
Every redirect would otherwise hit the database, even though a popular link gives back the exact same answer every time. Redis keeps the hot links in memory so repeat clicks get answered instantly and the database gets left alone.
The part I like most about how this turned out: the cache is a decorator around the database layer, not a rewrite of it. CachedUrlStore implements the same IUrlStore interface as the plain Cassandra store, checks Redis first, and only falls through to the database on a miss:
public async Task<ShortenedUrl?> GetByCodeAsync(string code)
{
var key = $"url:{code}";
var cached = await _cache.StringGetAsync(key);
if (cached.HasValue)
return JsonSerializer.Deserialize<ShortenedUrl>(cached!);
var entity = await _inner.GetByCodeAsync(code);
if (entity is null)
return null;
await _cache.StringSetAsync(key, JsonSerializer.Serialize(entity), Ttl);
return entity;
}
Swapping between "database only" and "database plus cache" is a one-line change in how the services get wired up. Nothing that calls IUrlStore needs to know or care which one it's actually talking to.
301 vs 302, and why it matters here
A 301 redirect gets cached by the browser, which is fast but means I can never count clicks again after the first one. A 302 always comes back through my server, a bit slower, but it means I can track clicks and change the destination later if I need to. I went with 302 to keep analytics possible.
Moving the cache to Azure
The Redis cache now runs on Azure Cache for Redis instead of a local container. Azure handles the upkeep, and I get less control and a bit of cost in exchange. It connects over TLS only, the password lives in a local secret store instead of the repo, and public access has to be turned on deliberately, which is the right default.
What I'm taking from this
Building it instead of just watching the video is what actually made the pieces stick: how Base62 short codes work, how Cassandra keeps lookups fast at scale, why a cache goes in front of a database and not instead of one, and why keeping the app free of its own memory is what makes load balancing possible at all. Docker Compose starts all six pieces, three app copies, Cassandra, Redis, and nginx, with one command, which by itself taught me more about how these pieces actually fit together than any diagram did.
The next step is moving the database itself to the cloud. Azure Cosmos DB speaks Cassandra's protocol, so that should mean the same code pointed at a different address, and eventually hosting the app itself in Azure Container Apps with real autoscaling instead of three fixed copies on my laptop.