Cracking the LLD Interview: Designing a Rate Limiter with Domain-Driven Design
Most aggregates remember everything. This one forgets on purpose — and that forgetting IS the algorithm.
Cracking the LLD Interview: Designing a Rate Limiter with Domain-Driven Design
Most aggregates remember everything. This one forgets on purpose — and that forgetting IS the algorithm.

Introduction
The Rate Limiter is one of the most frequently asked LLD interview questions — and one of the most commonly botched. Candidates either jump straight to “use a token bucket” without modeling the domain, or they build a counter that doesn’t account for the time dimension that makes rate limiting fundamentally different from ordinary counting.
Here’s the thing most designs miss: a rate limiter isn’t a single component. It’s three concerns pretending to be one. There’s the policy (who gets what limit), the algorithm (how the limit is enforced over time), and the counter (how much has been consumed). Candidates who conflate these three produce rigid designs that can’t swap algorithms, can’t handle tiered pricing, and can’t explain their trade-offs to an interviewer.
Domain-Driven Design gives us the tools to separate these concerns cleanly. The policy is an aggregate that changes slowly. The counter is an aggregate that changes on every single request — and whose state decays with time. The algorithm is a Strategy that operates on the counter using the policy’s rules. This separation isn’t academic; it’s exactly what an interviewer wants to see.
In this article — we’ll design a production-grade rate limiter in Go. By the end, you’ll have a design that supports multiple algorithms (fixed window, sliding window, token bucket), tiered policies, multi-dimensional limiting, and clean extensibility.
Core Insight: The rate limiter’s defining design challenge is the time-decaying state problem. Unlike most aggregates that accumulate state, the rate limiter’s counter actively forgets — old requests fall off the window, tokens silently refill. This temporal behavior isn’t a side effect; it’s the core invariant. The algorithm’s job is to define how the forgetting happens, and the Strategy pattern makes that definition swappable.
Phase 1 — Domain Discovery
1.1 Clarifying Questions for the Interviewer

1.2 Requirements
Functional Requirements (FR)
- FR-1: Evaluate whether an incoming request should be allowed or denied based on rate limit rules
- FR-2: Support multiple rate-limiting algorithms (fixed window, sliding window log, sliding window counter, token bucket)
- FR-3: Track request consumption per rate-limit key (combination of client ID, resource, and dimension)
- FR-4: Support tiered rate-limit policies (e.g., free: 100 req/min, premium: 1000 req/min)
- FR-5: Return rate-limit metadata with each decision (remaining quota, reset time, retry-after)
- FR-6: Allow policy creation, update, and assignment to client tiers
- FR-7: Support multi-dimensional limiting (same request can be limited by IP and by user ID independently)
- FR-8: Automatically expire stale counters when their window passes
Non-Functional Requirements (NFR)
- NFR-1: Sub-millisecond decision latency for the hot path (
AllowRequest) - NFR-2: Counter state must survive process restarts (persistent or distributed store)
- NFR-3: Consistent under concurrent requests — two requests arriving simultaneously when one slot remains must not both be allowed
- NFR-4: Counters for inactive keys should be evictable to bound memory usage
Out-of-Scope (OOS)
- OOS-1: DDoS mitigation (rate limiting is one layer, not the whole solution)
- OOS-2: Web Application Firewall (WAF) rules
- OOS-3: Request queuing or throttling (we allow or deny, not delay)
- OOS-4: Billing integration (we enforce limits, not meter for billing)
- OOS-5: Real-time analytics dashboards on rate-limit data
1.3 Invariants

1.4 User Stories
- As an API gateway, I want to check if an incoming request is within its rate limit so I can allow or reject it before hitting the backend.
- As a platform engineer, I want to create rate-limit policies with different thresholds for different tiers so free and paid users get appropriate limits.
- As an API consumer, I want to see my remaining quota and reset time in response headers so I can pace my requests.
- As a platform engineer, I want to switch a policy’s algorithm from fixed window to token bucket without changing client integrations.
- As an operations engineer, I want stale counters to expire automatically so memory usage stays bounded.
- As a platform engineer, I want to rate-limit a single user across multiple dimensions (per-endpoint and global) so no single endpoint hogs the global quota.
1.5 Edge Cases

1.6 Ubiquitous Language Glossary

Phase 2 — Domain Modeling
2.1 Nouns and Verbs Extraction
Nouns (from requirements and user stories):

Verbs (behaviors):

2.2 Entity vs Value Object — Identity Reasoning
Why RateLimitPolicy is an Entity, not a VO: A policy has a unique ID (e.g., "policy-free-tier"). Two policies with identical thresholds (100 req/min) are still distinct if they serve different tiers. The policy is mutable — an admin can change the max requests from 100 to 150. Identity matters because counters reference policies by ID.
Why RateLimitCounter is an Entity, not a VO: Each counter is uniquely identified by its key-policy combination. It mutates on every request — the count increments, tokens decrement, timestamps are appended. Two counters for different keys with the same consumption state are not interchangeable.
Why RateLimitKey is a VO, not an Entity: A key is defined entirely by its three components: clientID, resource, and dimension. It has no lifecycle, no mutation. If two requests produce the same (clientID, resource, dimension) tuple, they should resolve to the same counter. Value equality is the only thing that matters.
Why RateLimitResult is a VO, not an Entity: A result is a snapshot — {allowed: true, remaining: 42, resetAt: 1722345600}. It's immutable, has no identity, and is defined entirely by its fields. You never ask "which result" — you ask "what was the result."
Why WindowState and BucketState are VOs: These capture a moment in the counter’s life. When the algorithm evaluates a request, it reads the current state, computes a new state, and replaces the old one. The old state is discarded, not mutated. This replacement-over-mutation pattern is the hallmark of value objects.
2.3 Value Object Immutability
// RateLimitKey — immutable, equality by components
type RateLimitKey struct {
clientID string
resource string
dimension string
}
func NewRateLimitKey(clientID, resource, dimension string) (RateLimitKey, error) {
if clientID == "" {
return RateLimitKey{}, ErrEmptyClientID
}
if resource == "" {
return RateLimitKey{}, ErrEmptyResource
}
if dimension == "" {
return RateLimitKey{}, ErrEmptyDimension
}
return RateLimitKey{
clientID: clientID,
resource: resource,
dimension: dimension,
}, nil
}
// Value equality — two keys with the same components are equal
func (k RateLimitKey) Equal(other RateLimitKey) bool {
return k.clientID == other.clientID &&
k.resource == other.resource &&
k.dimension == other.dimension
}
// String representation for use as storage key
func (k RateLimitKey) String() string {
return k.clientID + ":" + k.resource + ":" + k.dimension
}
func (k RateLimitKey) ClientID() string { return k.clientID }
func (k RateLimitKey) Resource() string { return k.resource }
func (k RateLimitKey) Dimension() string { return k.dimension }
// RateLimitResult — immutable snapshot of a decision
type RateLimitResult struct {
allowed bool
remaining int
limit int
resetAtUnix int64
retryAfterMs int64
}
func NewAllowedResult(remaining, limit int, resetAtUnix int64) RateLimitResult {
return RateLimitResult{
allowed: true,
remaining: remaining,
limit: limit,
resetAtUnix: resetAtUnix,
}
}
func NewDeniedResult(limit int, retryAfterMs int64, resetAtUnix int64) RateLimitResult {
return RateLimitResult{
allowed: false,
remaining: 0,
limit: limit,
resetAtUnix: resetAtUnix,
retryAfterMs: retryAfterMs,
}
}
// Value equality
func (r RateLimitResult) Equal(other RateLimitResult) bool {
return r.allowed == other.allowed &&
r.remaining == other.remaining &&
r.limit == other.limit &&
r.resetAtUnix == other.resetAtUnix
}
func (r RateLimitResult) Allowed() bool { return r.allowed }
func (r RateLimitResult) Remaining() int { return r.remaining }
func (r RateLimitResult) Limit() int { return r.limit }
func (r RateLimitResult) ResetAtUnix() int64 { return r.resetAtUnix }
func (r RateLimitResult) RetryAfterMs() int64 { return r.retryAfterMs }
// Window — immutable configuration VO
type Window struct {
duration time.Duration
maxRequests int
}
func NewWindow(duration time.Duration, maxRequests int) (Window, error) {
if duration <= 0 {
return Window{}, ErrInvalidWindowDuration // INV-10
}
if maxRequests <= 0 {
return Window{}, ErrInvalidMaxRequests // INV-10
}
return Window{duration: duration, maxRequests: maxRequests}, nil
}
func (w Window) Equal(other Window) bool {
return w.duration == other.duration && w.maxRequests == other.maxRequests
}
func (w Window) Duration() time.Duration { return w.duration }
func (w Window) MaxRequests() int { return w.maxRequests }
2.4 Anemic vs Rich Model
Anemic (what to avoid):
// BAD: Anemic counter — service does all the work
type RateLimitCounter struct {
Key string
Count int
WindowStart int64
Tokens float64
LastRefill int64
}
// Service manipulates counter fields directly
func (s *RateLimitService) Allow(counter *RateLimitCounter, policy *Policy) bool {
if policy.Algorithm == "token_bucket" {
elapsed := time.Now().Unix() - counter.LastRefill
counter.Tokens += float64(elapsed) * policy.RefillRate
if counter.Tokens > float64(policy.Capacity) {
counter.Tokens = float64(policy.Capacity)
}
if counter.Tokens >= 1 {
counter.Tokens--
return true
}
return false
}
// ... more algorithm logic leaked into service
}
Rich (what we want):
// GOOD: Rich counter — behavior belongs to the aggregate
func (c *RateLimitCounter) TryConsume(policy *RateLimitPolicy, algorithm RateLimitAlgorithm, now time.Time) RateLimitResult {
// INV-4: Only consume if allowed — algorithm decides
result := algorithm.Evaluate(c, policy, now)
if result.Allowed() {
// Consumption happens INSIDE the aggregate
c.recordConsumption(now)
c.version++
}
// INV-4: Denied requests do NOT call recordConsumption
return result
}
The rich model ensures that the consumption invariant (INV-4: denied requests don’t consume) is enforced inside the aggregate, not scattered across service methods. The algorithm is injected as a strategy — the counter doesn’t know which algorithm is running, only that the algorithm returned a decision.
Phase 3 — Aggregate Design
3.1 Strategic Domain Classification

3.2 Consistency Boundary Analysis

This gives us two aggregates:
- RateLimitPolicy Aggregate — Root:
RateLimitPolicy. ContainsWindowandTokenBucketConfigas VOs. Cold path — changes rarely. - RateLimitCounter Aggregate — Root:
RateLimitCounter. ContainsWindowState,BucketState,TimestampLogas VOs. Hot path — changes on every allowed request.
3.3 Four Aggregate Rules — Applied

3.4 Aggregate Implementation — RateLimitPolicy
type AlgorithmType int
const (
AlgorithmFixedWindow AlgorithmType = iota
AlgorithmSlidingWindowLog
AlgorithmSlidingWindowCounter
AlgorithmTokenBucket
)
type TokenBucketConfig struct {
capacity int
refillRate float64 // tokens per second
}
func NewTokenBucketConfig(capacity int, refillRate float64) (TokenBucketConfig, error) {
if capacity <= 0 {
return TokenBucketConfig{}, ErrInvalidBucketCapacity // INV-9
}
if refillRate <= 0 {
return TokenBucketConfig{}, ErrInvalidRefillRate // INV-9
}
return TokenBucketConfig{capacity: capacity, refillRate: refillRate}, nil
}
func (c TokenBucketConfig) Capacity() int { return c.capacity }
func (c TokenBucketConfig) RefillRate() float64 { return c.refillRate }
func (c TokenBucketConfig) Equal(other TokenBucketConfig) bool {
return c.capacity == other.capacity && c.refillRate == other.refillRate
}
type RateLimitPolicy struct {
id string
name string
tier string
window Window
algorithmType AlgorithmType
bucketConfig *TokenBucketConfig // nil for window-based algorithms
version int
events []DomainEvent
}
// INV-10: policy must have valid limits
func NewRateLimitPolicy(
id, name, tier string,
window Window,
algorithmType AlgorithmType,
bucketConfig *TokenBucketConfig,
) (*RateLimitPolicy, error) {
// INV-9: token bucket must have valid config
if algorithmType == AlgorithmTokenBucket && bucketConfig == nil {
return nil, ErrMissingBucketConfig
}
return &RateLimitPolicy{
id: id,
name: name,
tier: tier,
window: window,
algorithmType: algorithmType,
bucketConfig: bucketConfig,
}, nil
}
// INV-10: updates must maintain valid limits
func (p *RateLimitPolicy) UpdateLimits(window Window) {
p.window = window
p.version++
p.recordEvent(PolicyUpdatedEvent{PolicyID: p.id, NewWindow: window})
}
func (p *RateLimitPolicy) ChangeAlgorithm(algo AlgorithmType, bucketConfig *TokenBucketConfig) error {
if algo == AlgorithmTokenBucket && bucketConfig == nil {
return ErrMissingBucketConfig
}
p.algorithmType = algo
p.bucketConfig = bucketConfig
p.version++
p.recordEvent(PolicyAlgorithmChangedEvent{PolicyID: p.id, NewAlgorithm: algo})
return nil
}
func (p *RateLimitPolicy) recordEvent(e DomainEvent) { p.events = append(p.events, e) }
func (p *RateLimitPolicy) DomainEvents() []DomainEvent { return p.events }
func (p *RateLimitPolicy) ClearEvents() { p.events = nil }
func (p *RateLimitPolicy) ID() string { return p.id }
func (p *RateLimitPolicy) Name() string { return p.name }
func (p *RateLimitPolicy) Tier() string { return p.tier }
func (p *RateLimitPolicy) Window() Window { return p.window }
func (p *RateLimitPolicy) AlgorithmType() AlgorithmType { return p.algorithmType }
func (p *RateLimitPolicy) BucketConfig() *TokenBucketConfig { return p.bucketConfig }
func (p *RateLimitPolicy) Version() int { return p.version }
3.5 Aggregate Implementation — RateLimitCounter
type RateLimitCounter struct {
id string
key RateLimitKey
policyID string
// Window-based state
windowStart time.Time
requestCount int64
// Sliding window log state
timestamps []time.Time
// Token bucket state
tokens float64
lastRefillAt time.Time
// Previous window count (for sliding window counter algorithm)
prevWindowCount int64
version int
events []DomainEvent
}
func NewRateLimitCounter(id string, key RateLimitKey, policyID string) *RateLimitCounter {
return &RateLimitCounter{
id: id,
key: key,
policyID: policyID,
}
}
// Initialize for token bucket — called once when counter is created for a bucket policy
func (c *RateLimitCounter) InitTokenBucket(capacity int, now time.Time) {
c.tokens = float64(capacity) // start full — INV-3 satisfied at creation
c.lastRefillAt = now
}
// Initialize for window — called once when counter is created for a window policy
func (c *RateLimitCounter) InitWindow(now time.Time) {
c.windowStart = now
c.requestCount = 0
}
// TryConsume — the core method. Delegates to the algorithm strategy.
// INV-4: only consumes if the algorithm allows the request.
func (c *RateLimitCounter) TryConsume(
policy *RateLimitPolicy,
algorithm RateLimitAlgorithm,
now time.Time,
) RateLimitResult {
result := algorithm.Evaluate(c, policy, now)
if result.Allowed() {
c.version++
c.recordEvent(RequestAllowedEvent{
Key: c.key,
PolicyID: c.policyID,
Remaining: result.Remaining(),
})
} else {
c.recordEvent(RateLimitExceededEvent{
Key: c.key,
PolicyID: c.policyID,
RetryAfterMs: result.RetryAfterMs(),
})
}
return result
}
// --- State accessors for algorithms (algorithms read/write counter state) ---
func (c *RateLimitCounter) WindowStart() time.Time { return c.windowStart }
func (c *RateLimitCounter) RequestCount() int64 { return c.requestCount }
func (c *RateLimitCounter) Timestamps() []time.Time { return c.timestamps }
func (c *RateLimitCounter) Tokens() float64 { return c.tokens }
func (c *RateLimitCounter) LastRefillAt() time.Time { return c.lastRefillAt }
func (c *RateLimitCounter) PrevWindowCount() int64 { return c.prevWindowCount }
// State mutators — called by algorithms through the aggregate
func (c *RateLimitCounter) SetWindowStart(t time.Time) { c.windowStart = t }
func (c *RateLimitCounter) SetRequestCount(n int64) { c.requestCount = n }
func (c *RateLimitCounter) IncrementRequestCount() { c.requestCount++ }
func (c *RateLimitCounter) SetTimestamps(ts []time.Time) { c.timestamps = ts }
func (c *RateLimitCounter) AppendTimestamp(t time.Time) { c.timestamps = append(c.timestamps, t) }
func (c *RateLimitCounter) SetTokens(t float64) { c.tokens = t }
func (c *RateLimitCounter) SetLastRefillAt(t time.Time) { c.lastRefillAt = t }
func (c *RateLimitCounter) SetPrevWindowCount(n int64) { c.prevWindowCount = n }
func (c *RateLimitCounter) recordEvent(e DomainEvent) { c.events = append(c.events, e) }
func (c *RateLimitCounter) DomainEvents() []DomainEvent { return c.events }
func (c *RateLimitCounter) ClearEvents() { c.events = nil }
func (c *RateLimitCounter) ID() string { return c.id }
func (c *RateLimitCounter) Key() RateLimitKey { return c.key }
func (c *RateLimitCounter) PolicyID() string { return c.policyID }
func (c *RateLimitCounter) Version() int { return c.version }
3.6 Domain Events
type DomainEvent interface {
EventName() string
OccurredAt() time.Time
}
type RequestAllowedEvent struct {
Key RateLimitKey
PolicyID string
Remaining int
occurredAt time.Time
}
func (e RequestAllowedEvent) EventName() string { return "RequestAllowed" }
func (e RequestAllowedEvent) OccurredAt() time.Time { return e.occurredAt }
type RateLimitExceededEvent struct {
Key RateLimitKey
PolicyID string
RetryAfterMs int64
occurredAt time.Time
}
func (e RateLimitExceededEvent) EventName() string { return "RateLimitExceeded" }
func (e RateLimitExceededEvent) OccurredAt() time.Time { return e.occurredAt }
type PolicyUpdatedEvent struct {
PolicyID string
NewWindow Window
occurredAt time.Time
}
func (e PolicyUpdatedEvent) EventName() string { return "PolicyUpdated" }
func (e PolicyUpdatedEvent) OccurredAt() time.Time { return e.occurredAt }
type PolicyAlgorithmChangedEvent struct {
PolicyID string
NewAlgorithm AlgorithmType
occurredAt time.Time
}
func (e PolicyAlgorithmChangedEvent) EventName() string { return "PolicyAlgorithmChanged" }
func (e PolicyAlgorithmChangedEvent) OccurredAt() time.Time { return e.occurredAt }
type CounterExpiredEvent struct {
Key RateLimitKey
PolicyID string
occurredAt time.Time
}
func (e CounterExpiredEvent) EventName() string { return "CounterExpired" }
func (e CounterExpiredEvent) OccurredAt() time.Time { return e.occurredAt }
Phase 4 — Bounded Contexts
4.1 Context Identification

Why two contexts? The enforcement path and the management path have radically different performance profiles and change cadences. The enforcement context is hit millions of times per minute and optimizes for latency. The policy management context is hit a few times per day and optimizes for correctness and auditability. Coupling them would mean policy management concerns (validation, audit logging) pollute the hot path.
Could you keep them in one context? Yes — and in a 45-minute interview, that’s perfectly acceptable. The trade-off: simpler topology but the policy aggregate’s consistency requirements (optimistic locking for admin edits) would be mixed with the counter aggregate’s very different concurrency needs (atomic increments under extreme load). Acknowledge this trade-off to the interviewer.
4.2 Context Map

4.3 Integration Patterns

4.4 Policy Caching — The Enforcement Context’s Local Copy
The enforcement context needs policy data on every request, but it must not query the policy management database on the hot path. The solution is a locally cached read model:
// PolicyProvider — local cache in the enforcement context
type PolicyProvider interface {
GetPolicy(ctx context.Context, policyID string) (*RateLimitPolicy, error)
RefreshPolicy(policyID string) error // called when PolicyUpdatedEvent is received
}
// InMemoryPolicyProvider — caches policies for fast lookup
type InMemoryPolicyProvider struct {
mu sync.RWMutex
policies map[string]*RateLimitPolicy
source PolicyRepository // reads from the management context's store
}
func (p *InMemoryPolicyProvider) GetPolicy(ctx context.Context, policyID string) (*RateLimitPolicy, error) {
p.mu.RLock()
policy, ok := p.policies[policyID]
p.mu.RUnlock()
if ok {
return policy, nil
}
// Cache miss — load from source
policy, err := p.source.FindByID(ctx, policyID)
if err != nil {
return nil, ErrPolicyNotFound
}
p.mu.Lock()
p.policies[policyID] = policy
p.mu.Unlock()
return policy, nil
}
This is the enforcement context’s own model of what a policy is — it doesn’t import the management context’s aggregate directly. In a simple implementation they share the same struct, but in a larger system they’d be separate projections.
Phase 5 — Application Service Design
5.1 Repository Interfaces (defined in domain layer)
// PolicyRepository — for the policy management context
type PolicyRepository interface {
FindByID(ctx context.Context, id string) (*RateLimitPolicy, error)
FindByTier(ctx context.Context, tier string) (*RateLimitPolicy, error)
Save(ctx context.Context, policy *RateLimitPolicy) error
Delete(ctx context.Context, id string) error
}
// CounterRepository — for the rate enforcement context
type CounterRepository interface {
FindByKeyAndPolicy(ctx context.Context, key RateLimitKey, policyID string) (*RateLimitCounter, error)
Save(ctx context.Context, counter *RateLimitCounter) error
Delete(ctx context.Context, id string) error
}
// PolicyResolver — domain service that maps a key to its applicable policy
type PolicyResolver interface {
Resolve(ctx context.Context, key RateLimitKey) (*RateLimitPolicy, error)
}
PolicyResolver Implementation — Tiered Resolution with Fallback
The PolicyResolver is the component that answers: given an incoming request's key, which policy applies? This is a domain service, not a repository — it contains resolution logic, not just data access.
The resolution strategy follows a specificity cascade: try the most specific match first (exact client + exact resource + exact dimension), then progressively broader matches, and finally fall back to a default.
// TieredPolicyResolver — resolves policy by cascading specificity
// Resolution order (INV-1: exactly one policy must be selected):
// 1. Exact match: (clientID, resource, dimension) → policy
// 2. Resource wildcard: (clientID, "*", dimension) → policy
// 3. Tier-based: clientTier → policy
// 4. Default: global default policy
type TieredPolicyResolver struct {
assignmentRepo PolicyAssignmentRepository
policyProvider PolicyProvider // cached policy lookup
clientTierRepo ClientTierRepository
defaultPolicy *RateLimitPolicy
}
func NewTieredPolicyResolver(
assignmentRepo PolicyAssignmentRepository,
policyProvider PolicyProvider,
clientTierRepo ClientTierRepository,
defaultPolicy *RateLimitPolicy,
) *TieredPolicyResolver {
return &TieredPolicyResolver{
assignmentRepo: assignmentRepo,
policyProvider: policyProvider,
clientTierRepo: clientTierRepo,
defaultPolicy: defaultPolicy,
}
}
// Resolve — INV-1: returns exactly one policy for any valid key
func (r *TieredPolicyResolver) Resolve(ctx context.Context, key RateLimitKey) (*RateLimitPolicy, error) {
// Step 1: Exact match — (clientID, resource, dimension) → policyID
policyID, err := r.assignmentRepo.FindPolicyID(ctx, key.ClientID(), key.Resource(), key.Dimension())
if err == nil && policyID != "" {
return r.policyProvider.GetPolicy(ctx, policyID)
}
// Step 2: Resource wildcard — (clientID, "*", dimension)
policyID, err = r.assignmentRepo.FindPolicyID(ctx, key.ClientID(), "*", key.Dimension())
if err == nil && policyID != "" {
return r.policyProvider.GetPolicy(ctx, policyID)
}
// Step 3: Tier-based — look up the client's tier, find the tier's policy
tier, err := r.clientTierRepo.GetTier(ctx, key.ClientID())
if err == nil && tier != "" {
policy, err := r.policyProvider.GetPolicyByTier(ctx, tier)
if err == nil {
return policy, nil
}
}
// Step 4: Default — global fallback (never nil)
if r.defaultPolicy != nil {
return r.defaultPolicy, nil
}
return nil, ErrPolicyNotFound
}
// Supporting interfaces for the resolver
type PolicyAssignmentRepository interface {
FindPolicyID(ctx context.Context, clientID, resource, dimension string) (string, error)
}
type ClientTierRepository interface {
GetTier(ctx context.Context, clientID string) (string, error)
}
// Extended PolicyProvider to support tier-based lookup
type PolicyProvider interface {
GetPolicy(ctx context.Context, policyID string) (*RateLimitPolicy, error)
GetPolicyByTier(ctx context.Context, tier string) (*RateLimitPolicy, error)
RefreshPolicy(policyID string) error
}
Why a cascade? In production, most requests resolve at step 3 (tier-based) or step 4 (default). Steps 1 and 2 handle overrides — a specific client might get a custom limit on a specific endpoint (e.g., a partner API consumer with elevated limits on /api/bulk-import). The cascade lets you layer specificity without touching the default path.
Why is this a domain service, not a repository? Repositories do simple CRUD — FindByID, Save. The resolver contains business logic: the specificity cascade, the wildcard matching, the tier fallback. That logic belongs in the domain layer as a service, not in the infrastructure layer as a repository. The resolver uses repositories, but the resolution strategy itself is domain knowledge.
5.2 DTOs (separate from domain)
// Request DTOs
type AllowRequestDTO struct {
ClientID string `json:"client_id"`
Resource string `json:"resource"`
Dimension string `json:"dimension"`
}
type CreatePolicyRequest struct {
Name string `json:"name"`
Tier string `json:"tier"`
MaxRequests int `json:"max_requests"`
WindowSeconds int `json:"window_seconds"`
Algorithm string `json:"algorithm"` // "fixed_window", "sliding_log", "sliding_counter", "token_bucket"
BucketCap int `json:"bucket_capacity,omitempty"`
RefillRate float64 `json:"refill_rate,omitempty"`
}
type UpdatePolicyRequest struct {
PolicyID string `json:"policy_id"`
MaxRequests int `json:"max_requests"`
WindowSeconds int `json:"window_seconds"`
}
// Response DTOs
type AllowResponseDTO struct {
Allowed bool `json:"allowed"`
Remaining int `json:"remaining"`
Limit int `json:"limit"`
ResetAtUnix int64 `json:"reset_at_unix"`
RetryAfterMs int64 `json:"retry_after_ms,omitempty"`
}
type PolicyResponseDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Tier string `json:"tier"`
MaxRequests int `json:"max_requests"`
WindowSeconds int `json:"window_seconds"`
Algorithm string `json:"algorithm"`
}
5.3 Application Service — One Method per Use Case
type RateLimitAppService struct {
policyRepo PolicyRepository
counterRepo CounterRepository
policyResolver PolicyResolver
algorithmFactory AlgorithmFactory
idGen IDGenerator
}
// Use Case: Evaluate a request (the HOT PATH)
func (s *RateLimitAppService) AllowRequest(
ctx context.Context,
req AllowRequestDTO,
) (*AllowResponseDTO, error) {
// Step 1: Build the rate-limit key
key, err := NewRateLimitKey(req.ClientID, req.Resource, req.Dimension)
if err != nil {
return nil, err
}
// Step 2: Resolve which policy applies (INV-1)
policy, err := s.policyResolver.Resolve(ctx, key)
if err != nil {
// No policy found — fail open (allow with no limit metadata)
return &AllowResponseDTO{Allowed: true, Remaining: -1}, nil
}
// Step 3: Find or create the counter for this key+policy
counter, err := s.counterRepo.FindByKeyAndPolicy(ctx, key, policy.ID())
if err != nil {
// Counter doesn't exist yet — create one
counter = NewRateLimitCounter(s.idGen.Generate(), key, policy.ID())
if policy.AlgorithmType() == AlgorithmTokenBucket {
counter.InitTokenBucket(policy.BucketConfig().Capacity(), time.Now())
} else {
counter.InitWindow(time.Now())
}
}
// Step 4: Get the algorithm strategy
algorithm := s.algorithmFactory.Create(policy.AlgorithmType())
// Step 5: Evaluate — domain logic happens inside the counter aggregate
now := time.Now()
result := counter.TryConsume(policy, algorithm, now)
// Step 6: Persist updated counter
if err := s.counterRepo.Save(ctx, counter); err != nil {
return nil, err
}
return &AllowResponseDTO{
Allowed: result.Allowed(),
Remaining: result.Remaining(),
Limit: result.Limit(),
ResetAtUnix: result.ResetAtUnix(),
RetryAfterMs: result.RetryAfterMs(),
}, nil
}
// Use Case: Create a new policy (COLD PATH)
func (s *RateLimitAppService) CreatePolicy(
ctx context.Context,
req CreatePolicyRequest,
) (*PolicyResponseDTO, error) {
window, err := NewWindow(
time.Duration(req.WindowSeconds)*time.Second,
req.MaxRequests,
)
if err != nil {
return nil, err // INV-10 enforced inside NewWindow
}
algoType := parseAlgorithmType(req.Algorithm)
var bucketConfig *TokenBucketConfig
if algoType == AlgorithmTokenBucket {
cfg, err := NewTokenBucketConfig(req.BucketCap, req.RefillRate)
if err != nil {
return nil, err // INV-9 enforced inside NewTokenBucketConfig
}
bucketConfig = &cfg
}
policy, err := NewRateLimitPolicy(
s.idGen.Generate(),
req.Name,
req.Tier,
window,
algoType,
bucketConfig,
)
if err != nil {
return nil, err
}
if err := s.policyRepo.Save(ctx, policy); err != nil {
return nil, err
}
return &PolicyResponseDTO{
ID: policy.ID(),
Name: policy.Name(),
Tier: policy.Tier(),
MaxRequests: window.MaxRequests(),
WindowSeconds: int(window.Duration().Seconds()),
Algorithm: req.Algorithm,
}, nil
}
// Use Case: Update policy limits
func (s *RateLimitAppService) UpdatePolicy(
ctx context.Context,
req UpdatePolicyRequest,
) error {
policy, err := s.policyRepo.FindByID(ctx, req.PolicyID)
if err != nil {
return ErrPolicyNotFound
}
window, err := NewWindow(
time.Duration(req.WindowSeconds)*time.Second,
req.MaxRequests,
)
if err != nil {
return err
}
// INV-6: change takes effect on next window, not retroactively
policy.UpdateLimits(window)
return s.policyRepo.Save(ctx, policy)
}
5.4 API Endpoints — Named After Use Cases

The primary endpoint (/ratelimit/check) is a POST because it has side effects — it modifies counter state. This is not a read operation. An interviewer might challenge this — explain that while the decision is read-like, the consumption is a write, and idempotency concerns (duplicate checks must not double-consume) make POST appropriate.
Phase 6 — LLD Conversion
6.1 Class/Struct Diagram

6.2 Algorithm Implementations — The Strategy Pattern in Action
Fixed Window Algorithm:
type FixedWindowAlgorithm struct{}
func (a *FixedWindowAlgorithm) Evaluate(
counter *RateLimitCounter,
policy *RateLimitPolicy,
now time.Time,
) RateLimitResult {
window := policy.Window()
windowStart := counter.WindowStart()
windowEnd := windowStart.Add(window.Duration())
// Has the window expired? Reset.
if now.After(windowEnd) || now.Equal(windowEnd) {
counter.SetWindowStart(now)
counter.SetRequestCount(0)
windowEnd = now.Add(window.Duration())
}
// INV-2: check if within limit
if counter.RequestCount() >= int64(window.MaxRequests()) {
retryAfter := windowEnd.Sub(now).Milliseconds()
return NewDeniedResult(window.MaxRequests(), retryAfter, windowEnd.Unix())
}
// Allowed — consume (INV-4: increment only on allow)
counter.IncrementRequestCount()
remaining := window.MaxRequests() - int(counter.RequestCount())
return NewAllowedResult(remaining, window.MaxRequests(), windowEnd.Unix())
}
Sliding Window Log Algorithm:
type SlidingWindowLogAlgorithm struct{}
func (a *SlidingWindowLogAlgorithm) Evaluate(
counter *RateLimitCounter,
policy *RateLimitPolicy,
now time.Time,
) RateLimitResult {
window := policy.Window()
windowStart := now.Add(-window.Duration())
// Prune timestamps outside the window
pruned := make([]time.Time, 0, len(counter.Timestamps()))
for _, ts := range counter.Timestamps() {
if ts.After(windowStart) {
pruned = append(pruned, ts)
}
}
counter.SetTimestamps(pruned)
// INV-2: check if within limit
if len(pruned) >= window.MaxRequests() {
// Earliest timestamp determines when a slot opens
earliest := pruned[0]
retryAfter := earliest.Add(window.Duration()).Sub(now).Milliseconds()
resetAt := now.Add(window.Duration()).Unix()
return NewDeniedResult(window.MaxRequests(), retryAfter, resetAt)
}
// Allowed — record timestamp
counter.AppendTimestamp(now)
remaining := window.MaxRequests() - len(counter.Timestamps())
resetAt := now.Add(window.Duration()).Unix()
return NewAllowedResult(remaining, window.MaxRequests(), resetAt)
}
Sliding Window Counter Algorithm:
type SlidingWindowCounterAlgorithm struct{}
func (a *SlidingWindowCounterAlgorithm) Evaluate(
counter *RateLimitCounter,
policy *RateLimitPolicy,
now time.Time,
) RateLimitResult {
window := policy.Window()
windowStart := counter.WindowStart()
windowEnd := windowStart.Add(window.Duration())
// Has the window rolled over? Shift.
if now.After(windowEnd) || now.Equal(windowEnd) {
counter.SetPrevWindowCount(counter.RequestCount())
counter.SetRequestCount(0)
counter.SetWindowStart(windowEnd) // align to boundary
windowStart = counter.WindowStart()
windowEnd = windowStart.Add(window.Duration())
}
// INV-8: Interpolate — weight the previous window's count by overlap fraction
elapsed := now.Sub(windowStart)
overlapFraction := 1.0 - (float64(elapsed) / float64(window.Duration()))
if overlapFraction < 0 {
overlapFraction = 0
}
weightedCount := float64(counter.PrevWindowCount())*overlapFraction +
float64(counter.RequestCount())
// INV-2: check interpolated count against limit
if weightedCount >= float64(window.MaxRequests()) {
retryAfter := windowEnd.Sub(now).Milliseconds()
return NewDeniedResult(window.MaxRequests(), retryAfter, windowEnd.Unix())
}
// Allowed — increment current window count
counter.IncrementRequestCount()
remaining := window.MaxRequests() - int(weightedCount) - 1
if remaining < 0 {
remaining = 0
}
return NewAllowedResult(remaining, window.MaxRequests(), windowEnd.Unix())
}
Token Bucket Algorithm:
type TokenBucketAlgorithm struct{}
func (a *TokenBucketAlgorithm) Evaluate(
counter *RateLimitCounter,
policy *RateLimitPolicy,
now time.Time,
) RateLimitResult {
cfg := policy.BucketConfig()
// Refill tokens based on elapsed time
elapsed := now.Sub(counter.LastRefillAt()).Seconds()
newTokens := counter.Tokens() + elapsed*cfg.RefillRate()
// INV-3: cap at maximum capacity
if newTokens > float64(cfg.Capacity()) {
newTokens = float64(cfg.Capacity())
}
counter.SetTokens(newTokens)
counter.SetLastRefillAt(now)
// INV-2: check if we have at least one token
if counter.Tokens() < 1.0 {
// When will one token be available?
deficit := 1.0 - counter.Tokens()
retryAfterSec := deficit / cfg.RefillRate()
retryAfterMs := int64(retryAfterSec * 1000)
return NewDeniedResult(cfg.Capacity(), retryAfterMs, 0)
}
// Allowed — consume one token (INV-4: only on allow)
counter.SetTokens(counter.Tokens() - 1.0)
remaining := int(counter.Tokens())
return NewAllowedResult(remaining, cfg.Capacity(), 0)
}
Algorithm Factory:
type AlgorithmFactory struct{}
func (f *AlgorithmFactory) Create(algoType AlgorithmType) RateLimitAlgorithm {
switch algoType {
case AlgorithmFixedWindow:
return &FixedWindowAlgorithm{}
case AlgorithmSlidingWindowLog:
return &SlidingWindowLogAlgorithm{}
case AlgorithmSlidingWindowCounter:
return &SlidingWindowCounterAlgorithm{}
case AlgorithmTokenBucket:
return &TokenBucketAlgorithm{}
default:
return &FixedWindowAlgorithm{} // safe default
}
}
6.3 Algorithm Trade-off Summary

6.4 Database Schema
-- Policy aggregate (relational — changes rarely)
CREATE TABLE rate_limit_policies (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
tier VARCHAR(50) NOT NULL,
max_requests INTEGER NOT NULL CHECK (max_requests > 0),
window_seconds INTEGER NOT NULL CHECK (window_seconds > 0),
algorithm_type VARCHAR(30) NOT NULL,
bucket_capacity INTEGER,
refill_rate DOUBLE PRECISION,
version INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_policies_tier ON rate_limit_policies(tier);
-- Policy-to-client mapping (which clients get which policy)
CREATE TABLE policy_assignments (
client_id VARCHAR(100) NOT NULL,
resource VARCHAR(200) NOT NULL DEFAULT '*',
dimension VARCHAR(50) NOT NULL DEFAULT 'client_id',
policy_id VARCHAR(36) NOT NULL REFERENCES rate_limit_policies(id),
PRIMARY KEY (client_id, resource, dimension)
);
-- Domain events outbox
CREATE TABLE domain_events (
id BIGSERIAL PRIMARY KEY,
aggregate_type VARCHAR(50) NOT NULL,
aggregate_id VARCHAR(36) NOT NULL,
event_type VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
occurred_at TIMESTAMP NOT NULL DEFAULT NOW(),
published BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_events_unpublished ON domain_events(published, occurred_at)
WHERE published = FALSE;
Counter storage — Redis (not relational):
Counters are the hot path. They’re accessed on every request and need sub-millisecond latency. Redis is the natural fit. The counter repository implementation uses Redis data structures:
// RedisCounterRepository — infrastructure layer implementation
// Key format: "rl:{clientID}:{resource}:{dimension}:{policyID}"
// Fixed Window / Sliding Counter: Redis HASH
// field "count" → current window request count
// field "window_start" → Unix timestamp of window start
// field "prev_count" → previous window count (sliding counter)
// TTL: 2× window duration (auto-cleanup)
// Sliding Window Log: Redis SORTED SET
// member: request UUID, score: Unix timestamp
// ZRANGEBYSCORE to prune, ZCARD to count
// TTL: 2× window duration
// Token Bucket: Redis HASH
// field "tokens" → available tokens (float)
// field "last_refill" → Unix timestamp of last refill
// No TTL needed — bucket self-manages via refill
Why not a relational database for counters? At 10,000 requests/second, each incrementing a counter, a relational database would buckle under write contention and locking overhead. Redis provides atomic HINCRBY, ZADD, and Lua scripting for compound operations — all in single-digit microseconds.
6.5 Sequence Diagrams
Flow 1: AllowRequest — Happy Path (Fixed Window)

Flow 2: AllowRequest — Rate Limit Exceeded (Token Bucket)

Flow 3: Policy Update Mid-Window

6.6 Design Patterns Summary

6.7 Error Catalog

var (
ErrRateLimitExceeded = errors.New("rate limit exceeded")
ErrPolicyNotFound = errors.New("rate limit policy not found")
ErrInvalidWindowDuration = errors.New("window duration must be positive")
ErrInvalidMaxRequests = errors.New("max requests must be positive")
ErrInvalidBucketCapacity = errors.New("bucket capacity must be positive")
ErrInvalidRefillRate = errors.New("refill rate must be positive")
ErrMissingBucketConfig = errors.New("token bucket algorithm requires bucket configuration")
ErrEmptyClientID = errors.New("client ID cannot be empty")
ErrEmptyResource = errors.New("resource cannot be empty")
ErrEmptyDimension = errors.New("dimension cannot be empty")
ErrCounterVersionConflict = errors.New("counter version conflict — concurrent modification")
ErrStoreUnavailable = errors.New("counter store unavailable")
)
6.8 Folder Structure
rate-limiter/
├── domain/
│ ├── policy.go # RateLimitPolicy aggregate root
│ ├── counter.go # RateLimitCounter aggregate root
│ ├── key.go # RateLimitKey value object
│ ├── result.go # RateLimitResult value object
│ ├── window.go # Window, TokenBucketConfig value objects
│ ├── algorithm.go # RateLimitAlgorithm interface
│ ├── events.go # Domain events
│ ├── errors.go # Sentinel errors mapped to invariants
│ ├── ports.go # PolicyRepository, CounterRepository, PolicyAssignmentRepository
│ ├── policy_resolver.go # TieredPolicyResolver domain service
│ └── types.go # AlgorithmType enum
│
├── application/
│ ├── ratelimit_service.go # RateLimitAppService — all use cases
│ ├── dto.go # Request/Response DTOs
│ └── id_generator.go # IDGenerator interface
│
├── infrastructure/
│ ├── persistence/
│ │ ├── postgres_policy_repo.go # PolicyRepository (Postgres)
│ │ ├── postgres_assignment_repo.go # PolicyAssignmentRepository (Postgres)
│ │ └── redis_counter_repo.go # CounterRepository (Redis)
│ ├── algorithms/
│ │ ├── fixed_window.go
│ │ ├── sliding_window_log.go
│ │ ├── sliding_window_counter.go
│ │ ├── token_bucket.go
│ │ └── factory.go # AlgorithmFactory
│ ├── cache/
│ │ └── policy_provider.go # InMemoryPolicyProvider
│ └── uuid_generator.go
│
└── presentation/
└── http/
├── router.go
├── ratelimit_handler.go # POST /ratelimit/check
└── policy_handler.go # CRUD /policies
Concurrency and Consistency
Does concurrency matter here? Absolutely — this is the one LLD problem where concurrency is a first-class concern, not an afterthought.
The core race condition: Two requests arrive simultaneously for the same key. Both read the counter as count=99, max=100. Both see 1 slot remaining. Both increment to 100. Both are allowed. But only one should have been — the limit was 100, not 101.
Solution 1: Atomic operations (Redis). Redis HINCRBY is atomic. The counter repo's Save method doesn't do read-then-write — it issues a single HINCRBY 1 and reads the new value. If the new value exceeds the limit, it decrements back and returns denied. This eliminates the race entirely for fixed-window counters.
// Atomic increment in Redis — no version check needed
func (r *RedisCounterRepo) AtomicIncrement(ctx context.Context, key string, max int) (int64, bool) {
newCount, _ := r.client.HIncrBy(ctx, key, "count", 1).Result()
if newCount > int64(max) {
r.client.HIncrBy(ctx, key, "count", -1) // rollback
return newCount - 1, false // denied
}
return newCount, true // allowed
}
Solution 2: Lua scripting (Redis — compound operations). For token bucket and sliding window, the evaluate-and-update logic involves multiple fields. A Redis Lua script executes atomically:
-- Token bucket: refill + check + consume in one atomic script
local tokens = tonumber(redis.call('HGET', KEYS[1], 'tokens') or ARGV[1])
local last_refill = tonumber(redis.call('HGET', KEYS[1], 'last_refill') or ARGV[2])
local now = tonumber(ARGV[2])
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[3])
-- Refill
local elapsed = now - last_refill
tokens = math.min(capacity, tokens + elapsed * rate)
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_refill', now)
return {1, math.floor(tokens)} -- allowed, remaining
else
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_refill', now)
local deficit = 1 - tokens
local retry_ms = math.ceil(deficit / rate * 1000)
return {0, retry_ms} -- denied, retry_after
end
Solution 3: Optimistic concurrency (non-Redis stores). If using a relational database or in-memory store, the counter’s version field provides optimistic locking. On conflict, the application service retries the full evaluate cycle. This is the fallback for stores without atomic compound operations.
Locking strategy recommendation: Atomic operations (Redis HINCRBY or Lua scripts) for production. Optimistic concurrency (version field) for testing and non-Redis stores. Pessimistic locking is overkill — the operation is fast enough that retries under optimistic concurrency are rare.
Scalability Considerations
This is an LLD problem, so keep scalability lightweight. But the rate limiter is one of the few LLD topics where scalability is almost always a follow-up.
“What if we have millions of keys?” Redis handles millions of keys natively. Use key expiration (TTL = 2× window duration) to bound memory. Keys for inactive clients auto-evict. This is why CounterExpiredEvent exists — for audit trails, not for manual cleanup.
“What about a distributed rate limiter across multiple API gateway instances?” This is the main scalability challenge. All instances must share counter state — which is why Redis (or any shared atomic store) is the infrastructure choice. The domain layer is storage-agnostic; the CounterRepository interface doesn't change.
“What if Redis becomes a single point of failure?” Redis Cluster with hash-slot sharding by rate-limit key. Each key maps to exactly one shard. Or — fail-open: if Redis is unavailable, allow all requests and set a degraded flag. This is a policy decision, not a domain concern.
“Can the sliding window log algorithm scale?” Not well — it stores every timestamp, so memory grows linearly with request rate. At 10,000 req/sec with a 60-second window, that’s 600,000 entries per key. The sliding window counter algorithm was invented to solve this — O(1) memory with ~99.7% accuracy.
Common Interview Follow-up Questions
Q: “Why not just use a simple counter with TTL?” A simple counter with TTL is essentially the fixed window algorithm — and it has the well-known boundary burst problem. At the boundary of two windows, a client can make 2× the allowed requests. The sliding window counter fixes this with a weighted interpolation between windows, using only O(1) additional memory.
Q: “How would you implement multi-dimensional limiting?” Evaluate the request against multiple keys independently. For example, the same request generates key (user:123, /api/orders, user_id) and key (10.0.0.1, /api/orders, ip). Both must return Allowed for the request to proceed. The application service chains checks — if the first denies, skip the rest.
Q: “How does the token bucket handle bursts?” That’s its superpower. If a client is idle for a while, tokens accumulate up to the capacity. The client can then burst up to the capacity in rapid succession. After the burst, they’re limited to the refill rate. This is correct behavior — the client “earned” those tokens through inactivity.
Q: “What happens if the policy changes while requests are in flight?” INV-6 handles this. The policy update takes effect on the next window reset for existing counters. In-flight windows continue with the old limits. This prevents a limit reduction from retroactively denying requests that were already counted as allowed under the old limit.
Q: “How would you add per-endpoint limiting on top of per-user limiting?” This is multi-dimensional limiting (see above). The RateLimitKey already supports resource as a dimension — /api/orders and /api/users generate different keys. The policy assignment table maps (clientID, resource, dimension) → policyID, so different endpoints can have different policies for the same client.
Q: “Why did you choose a POST for the check endpoint instead of a GET?” The AllowRequest operation has side effects — it modifies counter state (increments count or decrements tokens). A GET must be idempotent and safe. Since this endpoint changes server state on success, POST is correct. Additionally, if a load balancer retries a failed GET, it could double-consume the quota.
Mistakes to Avoid
Conflating the policy with the algorithm. Many candidates define the limit and the enforcement logic in one class. “This policy allows 100 requests per minute using a fixed window” is a policy. “How to count requests within a window” is an algorithm. They change independently — a policy can switch from fixed window to token bucket without changing its limits.
Ignoring the time dimension. A rate limiter isn’t a counter — it’s a time-aware counter. If your Counter struct has no concept of windows, timestamps, or refill times, you've built a request counter, not a rate limiter. The temporal state is the core domain concept.
Storing per-request timestamps at scale. The sliding window log stores every timestamp. At high throughput (10K+ req/sec), this consumes significant memory per key. Candidates who choose this algorithm without discussing the memory trade-off lose points. Mention the sliding window counter as the practical alternative.
Forgetting INV-4: denied requests must not consume. If a denied request decrements a token or increments a counter, clients can “drain” their quota by sending bursts that all get denied. The aggregate must enforce that consumption happens only on allowed requests.
No strategy for store unavailability. “What if Redis is down?” is a guaranteed follow-up. If your design has no answer, you’re stuck. Decide: fail-open (allow all, lose limiting) or fail-closed (deny all, lose availability). State the trade-off.
Over-engineering the domain model. Rate limiters are a performance-critical hot path. If your DDD design adds ten layers of abstraction between the HTTP handler and the Redis HINCRBY, you've prioritized purity over practicality. Keep the domain model clean but the hot path short.
Final Design Summary
The rate limiter is built around two aggregates across two bounded contexts, with four interchangeable algorithm strategies:
The RateLimitPolicy aggregate (Policy Management context) owns the configuration — what limits apply, which algorithm to use, and which tier a client belongs to. It enforces validity invariants (positive limits, valid bucket config) and emits events when policies change.
The RateLimitCounter aggregate (Rate Enforcement context) owns the consumption state — how many requests have been made, when the window started, how many tokens remain. It enforces the critical runtime invariant: denied requests must not consume. The counter delegates the allow/deny decision to an injected Strategy — it doesn’t know which algorithm is running.
The four algorithm strategies (Fixed Window, Sliding Window Log, Sliding Window Counter, Token Bucket) implement the same RateLimitAlgorithm interface. Each has different accuracy, memory, and burst-handling characteristics. The AlgorithmFactory selects the right strategy based on the policy's configuration. Swapping algorithms requires changing a single policy field — no counter changes, no service changes.
The hot path (AllowRequest) touches three things: a cached policy (in-memory), a counter (Redis), and an algorithm (pure computation). No database queries, no cross-service calls. Sub-millisecond.
Every design decision maps to an invariant. Every invariant maps to code. Every algorithm trade-off is explicit. This is a design you can whiteboard, explain, and defend in 45 minutes.
Conclusion
The Rate Limiter is one of the few LLD problems where the algorithm is the design decision — not an implementation detail. Most LLD problems have one right way to calculate something; the rate limiter has four, each with distinct trade-offs that the interviewer will probe.
Five practical takeaways for your next interview:
- Separate policy, algorithm, and counter. These are three distinct concerns with different change cadences and consistency needs. The policy changes monthly. The algorithm changes yearly. The counter changes on every request. If they’re in one class, you can’t evolve any of them independently.
- The Strategy pattern isn’t optional — it’s the system’s identity. The rate limiter’s primary value proposition is enforcing limits using a specific algorithm. Making that algorithm swappable via a Strategy interface isn’t a nice-to-have — it’s the core architectural decision. Start with the interface, then implement.
- Time-decaying state is a different beast. Unlike most aggregates that accumulate facts, the counter’s state expires. Old requests fall off the window. Tokens silently refill. Design your aggregate around this — don’t treat it as a regular counter with a cleanup job.
- Know the trade-off table cold. Fixed window: simple but bursty. Sliding log: accurate but memory-heavy. Sliding counter: the sweet spot. Token bucket: burst-friendly. An interviewer will ask “why this algorithm?” — have the one-line trade-off ready.
- Concurrency is the hidden boss fight. The rate limiter is one of the few LLD problems where “two requests arrive simultaneously” isn’t a theoretical concern — it’s the normal case. Have an answer: Redis atomics, Lua scripting, or optimistic concurrency. Don’t wave it away.
메타데이터
- post_id
- 8ed60c43da7e
- slug
- cracking-the-lld-interview-designing-a-rate-limiter-with-domain-driven-design-8ed60c43da7e
- url
- https://medium.com/@shubham.patel191295/cracking-the-lld-interview-designing-a-rate-limiter-with-domain-driven-design-8ed60c43da7e
- canonical_url
- https://medium.com/@shubham.patel191295/cracking-the-lld-interview-designing-a-rate-limiter-with-domain-driven-design-8ed60c43da7e
- author_url
- https://medium.com/@shubham.patel191295
- status
- ok
- fetched_at
- 2026-08-11 18:06:04