Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 24

Implementing the RateLimiter Interface

Recommended resources

Hello! Welcome back.

In the previous lessons, you've done the heavy lifting of implementing our core rate limiting algorithms. We started with the basic FixedWindowCounter, moved to the more accurate SlidingWindowCounter, and culminated in a deep dive into TokenBucket, even creating both lock-based and high-performance lock-free versions.

Now, we have a collection of powerful but separate components. A key principle of good software design is to ensure that related components can be used interchangeably. This lesson is about applying that principle to our rate limiter. We will unify all our in-memory algorithms under a common contract.

Your learning outcome for this lesson:

You will ensure all in-memory algorithms conform to the RateLimiter interface. This step is crucial for building a flexible system where you can switch between different limiting strategies based on configuration.



1. The Need for Abstraction: The Strategy Design Pattern

Before we write any code, let's talk about the "why." Your goal is to build a system where you might apply a token bucket for one API key but a fixed window for another, all managed through a central configuration. To achieve this, the part of your code that uses the rate limiter shouldn't need to know the specific details of which algorithm is being used.

This is a classic software design problem solved by the Strategy Design Pattern. This pattern enables selecting an algorithm at runtime. It involves three key components:

  1. The Strategy Interface: A common interface that all related algorithms implement.
  2. Concrete Strategies: The specific implementations of the algorithm (e.g., FixedWindowCounter, TokenBucket).
  3. The Context: The class that uses a strategy. It holds a reference to a Strategy object and interacts with it only through the interface.

The diagrams below illustrate this pattern. The first shows a general structure with a sorting analogy, and the second shows the flow in a Java context.

Class Diagram of Strategy Design Pattern
This UML diagram shows a `SortingContext` that uses a `SortingStrategy` interface. Different sorting algorithms (`BubbleSortStrategy`, `QuickSortStrategy`) are "concrete strategies" that implement this interface.
Strategy Method Design Pattern in Java
Here, the client code interacts with a `Context` class, which delegates the work to a specific `Concrete Class` via the `Strategy Interface`. The client is decoupled from the actual implementation.

For our project:

  • Strategy Interface: This will be our RateLimiter interface.
  • Concrete Strategies: FixedWindowRateLimiter, SlidingWindowRateLimiter, TokenBucketRateLimiter, etc.
  • Context: This will be our RateLimitingInterceptor that you built in Module 1.

2. Defining the RateLimiter Contract

The first step is to define the contract that all our rate limiting algorithms must follow. It needs a single, clear method to answer the question: "Should this request be allowed?"

The article below provides a great starting point for the overall structure, including the interface.

LLD/Machine Coding w/ Implementation: “Rate Limiter”

The article 'LLD/Machine Coding w/ Implementation: “Rate Limiter”' by Shivam Choudhary presents a clear structure for a rate limiting system, starting with the core interface.

Please read the short section 'RateLimiter (interface)'. This defines the simple but powerful contract we will be using.

As you saw, the interface is straightforward:

public interface RateLimiter {
    boolean allowRequest(String userId);
}

For our project, we will eventually be limiting by more than just userId (IP address, API key, etc.). So let's think of the parameter more generically as a key. Our interface will be:

public interface RateLimiter {
    /**
     * Determines if a request should be allowed based on a given key.
     * @param key A unique identifier for the client (e.g., IP address, user ID, API key).
     * @return true if the request is allowed, false otherwise.
     */
    boolean isAllowed(String key);
}

This single method, isAllowed(String key), will be the entry point for all our rate limiting logic.



3. Adapting Your Algorithms to the Interface

Now, let's refactor our existing in-memory algorithms to conform to this RateLimiter interface. Each algorithm will become a "Concrete Strategy."

The same article provides example implementations for each algorithm we've discussed. Please review them, as they clearly demonstrate how to structure each class to implement the interface.

LLD/Machine Coding w/ Implementation: “Rate Limiter”

Let's now study the concrete implementations provided in the same article.

Read the section 'RateLimiter Implementations'. Focus on how each class (FixedWindowRateLimiter, SlidingWindowRateLimiter, TokenBucketRateLimiter) implements the RateLimiter interface and encapsulates its own logic within the allowRequest method.

Let's break down how you would adapt your own implementations based on this structure.

a. FixedWindowRateLimiter

Your first implementation from Module 3 can now be formally structured into a class that implements RateLimiter. The logic you wrote for tracking requests in a ConcurrentHashMap would go inside the isAllowed(String key) method.

// Your refactored class should look similar to this structure.
public class FixedWindowRateLimiter implements RateLimiter {
    private final int maxRequests;
    private final long windowSizeMillis;
    // ... other members like your ConcurrentHashMap ...

    public FixedWindowRateLimiter(int maxRequests, long windowSizeMillis) {
        this.maxRequests = maxRequests;
        this.windowSizeMillis = windowSizeMillis;
        // ...
    }

    @Override
    public boolean isAllowed(String key) {
        // Your logic for checking and incrementing the count for the given key goes here.
        // This includes handling window resets.
        return true; // or false
    }
}

b. SlidingWindowRateLimiter

Similarly, the sliding window logic you developed will be encapsulated in its own class. The implementation in the article uses a Deque of timestamps, which is a common approach. Another, more robust implementation for the sliding window counter is presented in the SlidingWindowRateLimiter.java from the oneuptime.com article you may have seen earlier. Regardless of the internal mechanics, the public-facing structure remains the same:

public class SlidingWindowRateLimiter implements RateLimiter {
    // ... members for maxRequests, windowSize, and data structure to hold timestamps/counts ...

    public SlidingWindowRateLimiter(int maxRequests, long windowSizeMillis) {
        // ... constructor ...
    }

    @Override
    public boolean isAllowed(String key) {
        // Your logic for sliding the window and checking the count goes here.
        return true; // or false
    }
}

c. TokenBucketRateLimiter

This is where the power of the pattern really shines. You've built two versions: a lock-based one and a superior lock-free one. Both can be adapted to fit the same interface.

The TokenBucketRateLimiter in the article uses a synchronized method, making it a simple lock-based implementation. Your more advanced lock-free logic using AtomicLong and CAS loops from the previous lesson would replace the logic inside the isAllowed method.

// Adapting your high-performance, lock-free implementation
public class LockFreeTokenBucketRateLimiter implements RateLimiter {
    private final int capacity;
    private final long refillRatePerSecond;

    // Use a ConcurrentHashMap to store a TokenBucket object for each key.
    private final ConcurrentHashMap<String, TokenBucket> buckets = new ConcurrentHashMap<>();

    // The inner class holding the lock-free logic from the previous lesson.
    private static class TokenBucket {
        // Your AtomicLong tokens, lastRefillTimestamp, etc.
        // Your CAS-based tryConsume() method.
    }
    
    // ... constructor ...

    @Override
    public boolean isAllowed(String key) {
        TokenBucket bucket = buckets.computeIfAbsent(key, k -> new TokenBucket(capacity, refillRatePerSecond));
        return bucket.tryConsume(1); // Assuming a tryConsume method returns boolean
    }
}

By doing this, you've hidden the complex, high-performance logic behind a simple, clean interface. The part of the system that uses the rate limiter doesn't need to know—or care—that it's lock-free. It just calls isAllowed(key).



4. The Payoff: A Flexible System

Now that all algorithms conform to the same interface, you can treat them interchangeably. A different part of your system can now decide which strategy to use for a given request.

To see how this works in practice, take a brief look at the RateLimiterService in the same article.

LLD/Machine Coding w/ Implementation: “Rate Limiter”

Finally, let's see how this unified structure pays off. This section shows a service that manages and delegates to the different rate limiter implementations.

Quickly review the 'RateLimiterFactory' and 'RateLimiterService' sections. You don't need to implement this now (we will build a more advanced version in Module 8), but notice how the RateLimiterService simply gets a RateLimiter object and calls allowRequest(userId) on it, without knowing its concrete type.

The RateLimiterService holds a map of RateLimiter objects. When a request comes in, it retrieves the correct object and calls the single, standard method. This is exactly what our RateLimitingInterceptor will do. This decoupling is the primary benefit of today's lesson and a cornerstone of your final project's design.



Key Takeaways

  • The Strategy Design Pattern is used to create a family of interchangeable algorithms. It decouples the algorithm's user (the Context) from its implementation (the Concrete Strategy).
  • We defined a RateLimiter interface with a single method, isAllowed(String key), to serve as the common contract for all our rate limiting algorithms.
  • All our in-memory algorithms (FixedWindow, SlidingWindow, TokenBucket) can be refactored into classes that implement this interface.
  • This abstraction allows the core logic of your application to remain simple and clean, while the complex, specific algorithm logic is encapsulated within each strategy class.

Next Steps

We have successfully unified our in-memory algorithms. They are now structured, interchangeable components. However, the isAllowed(String key) method begs a question: what exactly is the key? Is it an IP address? A user ID? An API key? A combination?

In the next module, we will answer this by designing and implementing flexible Key Resolution Strategies. You will build a RateLimitKeyResolver that can extract identifiers from various parts of an HTTP request, making your rate limiter truly multi-tenant and versatile.