←All posts
Misc

my first go at system design (i now hate elevators 😱)

β—†14 min read
System DesignSoftware Engineering

"The process of defining the architecture, components, modules, interfaces, and data for a system to satisfy specific functional and technical requirements: System Design."

The phrase has always had a bit of a daunting atmosphere to it β€” at least in my eyes. Arguably more important than being able to write the code itself, planning ahead and creating the architecture can easily make or break the scalability, reusability, efficiency, and basically every other important thing you can think of for an application. Now, I'd like to say I'm happy with how my intuition has progressed as I've gained more experience, learning to ask the right questions and consider tradeoffs as I'm exposed to varying levels of mature applications, experienced developers, and varying requirements.

BUT β€” on the fly? During an interview… chills. Which is exactly why today I will try to remedy this through a self-attempt compare and contrast approach. I've chosen three common mock system design problems, each with increasing difficulty, and I'll take a shot at each β€” seeing what I did right or wrong and hopefully improving as I go. This could be very embarrassing of course, as this will expose my internal thinking, but hey, the only way to get better is by being honest with yourself, right? Let's start πŸ™‚.


1. URL Shortener

"Design a URL shortening service like bit.ly. Users should be able to submit a long URL and get back a short one. When someone visits the short URL, they should be redirected to the original."

I know asking clarifying questions is always the best first step, so what questions should I ask to make this scenario and its parameters clearer?

As I think about my initial approach, I let clarifications naturally come up. The "real" URL itself needs to be stored no matter what, so my mind goes to the main data structure being a hashmap β€” keys being the custom URL and the value being the real URL. This leads me to my first question.

β†’ Is the shortened/custom URL chosen by the user, or is it generated automatically?

I'll assume the URL is chosen by the user β€” it shouldn't make too much of a difference, but good to know. Since our database is in a key-value format, a good database layer would be something like Redis, which implements a key-value store. Now let me think about the classes I'll design to handle this problem.

Controller class: whenever a user pings our service we need a class to handle incoming requests. The only information we need in their payload is the raw URL they'd like shortened and (optionally, otherwise auto-generated) the custom URL they'd like to shorten it to. A GET mapping method receives this request and passes it to our service class, which handles all the logic.

Service class: this class stores our logic. We need to do a few quick checks. First, is the custom URL the user requested already taken? We query our repository class for this. If the custom URL already exists, we return a message back to the controller prompting the user for a different one. Otherwise, we call our repository class method to insert a new key-value pairing.

Repository class: this class maps to our database and is responsible for all data access methods β€” querying for existing custom URLs and inserting new key-value pairs of the form custom_url : original_url.

Now that our classes for logically inserting into our database are complete, what actually happens when a user types this custom URL into their browser? I'm not yet an expert on the exact pipeline, but we'd need some proxy implementation to intercept the user's request and β€” before querying the entire web for it (if that's how it works 😭) β€” call our repository class to scan the database for whether the URL exists as a key. If so, we reroute their request to the associated value (the full URL); otherwise their request is processed normally.

My one remaining question: the key-value pairings are stored in our database, but is there a way to also store this information on the user's local device such as in RAM? If the user is using a browser connected to our database this makes sense, but if we want the URL shortener to work regardless of browser it would have to be at the device level β€” not too sure on this one. Let's compare and see how we did.

Feedback

Eesh… well, I love getting feedback. And there's no better place than the safe confines of this blog post to get it and improve!

Good πŸ™‚

  • Asking questions first. While I only asked one (likely should've been more), the question about custom vs. auto-generated URLs is a real one to ask.
  • The key-value store as the main data structure was good intuition.
  • Recognizing both the read path and the write path β€” even if I didn't go deep on the read path (which is arguably the more important piece). The write path is about ingesting the data. The read path β€” what actually happens once a user clicks the shortened URL β€” carries the majority of the traffic.

To Improve Upon

  • Early-career engineers tend to focus on the ingestion side: how does data get in and how do I store it? System design is more focused on how the system serves consumers at scale. After designing the write path, always ask: what happens when someone actually uses this thing? The read path is typically the consumer-facing scaling challenge.
  • I assumed users would always pick a custom URL, but auto-generation is the more common scenario. This requires thinking through a hash function (e.g. MD5) and how to handle collisions β€” either by retrying with different predefined shortened URLs or appending something to the URL until a unique value is found.
  • Scaling math. I didn't even consider this, but rough ballpark estimates are expected β€” they show you're considering real constraints. The key thing to memorize: 1 day β‰ˆ 100,000 seconds. Assuming 100 million writes a day with a 10:1 read-to-write ratio, that's about 10,000 reads per second β€” making caching critical.
  • My caching intuition was right but needed more precision: every GET request to a shortened URL should hit the service layer, which first checks a cache (sitting between service and database) for the mapping. On a cache hit, redirect via 301 (permanent β€” browser caches this and never hits your server again) or 302 (temporary). On a miss, query the persistent database, populate the cache, then redirect. Since the cache is finite, LRU (least recently used) eviction is a common strategy.
  • I mentioned Redis as the database layer, but Redis is the cache layer β€” not as durable. Something like DynamoDB is the right persistent key-value store here.
  • I went into controller/service/repo class design, which is code-level thinking. System design stays at the component and architectural level.

Polished Answer

Clarifying questions:

  • Will users custom-generate their shortened URLs or should it be auto-generated? β†’ Mostly auto-generated.
  • How many writes/uses can we expect per day? β†’ Let's say 10 million.

Write path: A user sends a POST request to our service layer with the URL they'd like to shorten. To auto-generate the shortened URL, we use a hash function and handle collisions by pre-generating a pool of unique shortened URLs to use on demand. For our persistent database, DynamoDB works well β€” it's a NoSQL key-value store and there aren't complex relationships to model.

Read path: 1 day β‰ˆ 100,000 seconds, so 10 million writes/day β‰ˆ 100 writes/second. Assuming a 10:1 read-to-write ratio and hot URL clustering (Γ  la the Pareto principle), we're at roughly 1,000 reads/second β€” making caching important. Assuming ~500 bytes per record with 10 million daily writes, we're at ~5 GB/day of data growth, which is reasonable and signals storage isn't a concern.

A key-value cache sits between our service and database. On each GET request to a shortened URL, the service checks the cache first. On a hit, it returns a redirect response. On a miss, it queries DynamoDB, populates the cache, and then redirects. Whether to use 301 or 302 is a tradeoff: 301 offloads server load since the browser caches it permanently, while 302 keeps every request hitting your servers β€” useful for analytics.


2. Elevator

"Design an elevator system for a building. The system should efficiently manage multiple elevators, respond to floor requests, and move passengers to their destinations."

Assume: 10 floors, 3 elevators. Passengers can call an elevator from any floor and select a destination floor once inside. Goal: move people efficiently, minimize wait time.

My first question:

β†’ Can we just build stairs and a ramp? (Just kidding β€” but in all seriousness, it's always worth asking if there's a simpler solution that's more understandable and easier to maintain!) β†’ "No, that's not what we asked and now you're fired."

β†’ When passengers are outside an elevator, their only option is to call one, and floor selection happens from inside? β†’ Yes.

The components involved: the elevator itself, and an elevator controller to manage requests. No database needed since there's no persistent information required. Because of the self-contained nature of this problem rather than involving distributed systems, I'll focus more on the logic.

Elevator controller β€” needs to distinguish between two request types and hold references to all elevators:

  • A list of incoming requests, each of type InternalRequest (inside elevator) or ExternalRequest (outside the elevator)
  • A list of elevators

Elevator β€” needs to track:

  • An integer representing the current floor
  • A boolean isMoving
  • An integer representing the goal destination (null if not moving)

Request types:

  • InternalRequest: integer fields for floor requested from and floor requested to
  • ExternalRequest: integer field only for floor requested from

Now for what happens when a user actually interacts with the system. Say someone outside the elevator presses the button. The controller receives this external request with the floor the user is on. How does it decide which elevator to send? A few algorithm options, each with tradeoffs:

  1. Closest idle elevator: among all stationary elevators, the one closest to the called floor is summoned.

    • Pros: Efficiency β€” elevators travel the minimum distance.
    • Cons: Potential starvation. If users on floors 4 and 5 continually call elevator 2, a user on floor 1 may wait indefinitely since they're never the "closest."
  2. Sweep up and down: elevators move all the way up and then all the way down, stopping only at floors with active requests. Whichever elevator passes the requested floor first serves it.

    • Pros: No starvation β€” everyone is guaranteed pickup eventually.
    • Cons: Inefficiency β€” a user boarding on floor 3 going to floor 2 has to wait for the elevator to travel up to 10 first. Also lots of wasted movement.
  3. Time-based queue: requests are added to a queue in order received. Elevators serve requests in round-robin fashion.

    • Pros: No deadlocks, requests served in a "fair" order.
    • Cons: Not efficient. If one elevator's queue is [10, 1, 9] it's bouncing all over the place. Also leads to load imbalance β€” one elevator could take on all the work by chance.

I'll choose option 3 β€” no deadlocks, and the tradeoff of occasional inefficiency in exchange for elevators not perpetually moving seems worth it to me.

So, a user sends an external or internal request, it's added to the queue in our controller, and using round-robin our controller instructs the corresponding elevator to serve it.

Feedback

Well… it seems I blundered a bit. I didn't pick the algorithm literally nicknamed the elevator algorithm πŸ˜ͺ.

Option 2 actually works best β€” the catch is it only needs to go up to the farthest floor requested in its current direction among pending requests, not all the way to the top unconditionally. To implement this properly, I was missing a key field.

What I needed: a direction field on the elevator (not just isMoving). And external requests also need a direction attribute β€” which direction the user wants to go. For internal requests this can be computed (is the destination above or below the current floor?).

Polished algorithm: external requests are first assigned to the closest idle elevator. Once an elevator is serving an external request, the user sends an internal request for the destination floor β€” which sets the elevator's direction. Any subsequent external requests along the way that match the elevator's direction are picked up, with the elevator's target being the farthest pending request in its current direction. External requests not along the route or going the opposite direction are ignored until the elevator reverses.

For thread safety β€” if users press buttons simultaneously β€” a thread-safe queue handles concurrent pushes without race conditions. Edge cases: ignore external requests to a floor the elevator is already on, and internal requests for the floor the elevator is currently at.


3. Rate Limiter

"Design a rate limiter. The system should limit the number of requests a user can make to an API within a given time window."

Assume: 100 requests per user per hour. Thousands of users simultaneously. Once a user hits the limit, additional requests are rejected until the window resets.

My first question: Is the time window fixed (resets on the hour) or rolling (starts from the user's first request)? β†’ I'll assume fixed reset times, every hour.

My first immediate thought is that the rate limiter must be a proxy, sitting between the user request and the API. Because we need to handle concurrency, horizontal scaling β€” multiple servers running our application β€” is smart for durability. This raises the question of how to route users to the right server.

For this I'd implement a hash ring: the ring represents integers 0 to 2^32 - 1; each user ID and server ID is hashed onto this ring, and every user is directed to the server closest to them in the clockwise direction.

Now, we need a database to store how many requests each user has made within the past hour. Given the relatively simple data (a user ID and a request count), NoSQL is sufficient. I'm tempted to add a cache, but I need both reads and writes and I'm not sure a cache supports write-back capabilities.

Final flow: a user sends a request, intercepted by the server closest to their hashed user ID. The server makes a database call to update the request count for that user and gets back the new number. If it exceeds 100, return an error. If below 100, return a 302 redirect to the actual API (choosing 302 so the request still hits our servers for this check). Each hour at the fixed reset time, request counts for all users are reset to 0.

Feedback

The main things I could've improved on are the rate-limiting algorithm and the database choice.

Better algorithm: a token bucket. Users start with a set number of tokens, refilled incrementally at a fixed rate. If a user's bucket is empty, the request is rejected. This handles bursty traffic more gracefully than a hard window reset.

Better database choice: Redis supports reads, writes, and even TTL (time-to-live) expiration β€” which handles the cleanup of inactive users automatically. The follow-up question is whether we still need a durable persistent database. For accuracy-critical APIs (e.g. financial transactions), yes β€” keep a persistent backing store. For most general-purpose APIs though, a Redis restart causing users to get a fresh token count isn't catastrophic, so Redis alone (with optional persistence-to-disk enabled) avoids managing an extra system.

Better questions to ask: whether the time window is fixed or rolling, and whether this rate limiter is for a general-purpose API or something more critical (which changes the durability requirements).


Conclusion

Well, that was my brief but insightful first stab at system design. Clearly this is a rabbit hole one could dive down for a while, but I believe this approach helped me onboard at a broad level to what this area of study is about and some guiding principles along the way. Very likely I'll return to this article to add more examples or findings I encounter along my software engineering journey 🌈, but for now, this article will have to suffice!