Career OS

Week 8 — Production Patterns: Pagination, Async, Monitoring

Deep dives for this week

Rate limiting’s sliding window is DSA 04; pagination’s SQL reality is in the SQL capstone; timeouts, retries, and connection pools — the production patterns this week only gestures at — are Networking 06.

This Week’s Theme

Your project should stop looking like a tutorial and start looking like production software. Real apps have pagination, rate limiting, structured logging, and health checks.

Daily Breakdown

Day 1: Pagination — Real Data Is Big

Never return unbounded lists. A group with 10,000 expenses will crash the client.

Two approaches:

Offset-based (simple, fine for most cases):

@GetMapping
public Page<ExpenseDTO> getExpenses(
        @PathVariable Long groupId,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size) {
    
    Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
    return expenseRepo.findByGroupId(groupId, pageable).map(this::toDTO);
}

Response:

{
  "content": [...],
  "totalElements": 1547,
  "totalPages": 78,
  "number": 0,
  "size": 20,
  "first": true,
  "last": false
}

Cursor-based (better for real-time feeds):

GET /expenses?cursor=2024-01-15T10:30:00&limit=20
  • No “page 47” — just “give me 20 items after this timestamp”
  • Doesn’t break when new items are inserted (offset pagination does)
  • Used by Twitter, Slack, most real-time feeds

Implement offset pagination this week. Know cursor pagination exists for interviews.

Day 2: Rate Limiting

Why: Without rate limiting, one bad actor can overload your entire system.

Approaches:

AlgorithmHow It WorksProsCons
Fixed Window100 requests per minute, window resetsSimpleBurst at window boundary
Sliding Window100 requests in any 60-second windowSmoothMore complex
Token BucketBucket fills at steady rate, each request takes a tokenHandles bursts gracefullySlightly complex

The sliding window row is exactly the pattern from DSA 04 — Sliding Window — same subtract-what-leaves, add-what-enters mechanic, applied to requests-per-minute instead of subarrays. This is what “DSA shows up in real systems” looks like.

Simple implementation with Bucket4j:

@Component
public class RateLimitFilter extends OncePerRequestFilter {
    
    private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
    
    private Bucket createBucket() {
        return Bucket.builder()
            .addLimit(Bandwidth.classic(100, Refill.intervally(100, Duration.ofMinutes(1))))
            .build();
    }
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
            HttpServletResponse response, FilterChain chain) {
        
        String clientId = request.getRemoteAddr(); // Or use authenticated userId
        Bucket bucket = buckets.computeIfAbsent(clientId, k -> createBucket());
        
        if (bucket.tryConsume(1)) {
            chain.doFilter(request, response);
        } else {
            response.setStatus(429); // Too Many Requests
            response.getWriter().write("{\"error\": \"Rate limit exceeded\"}");
        }
    }
}

Day 3: Structured Logging — Debug Production Like a Pro

Bad logging:

System.out.println("Error occurred"); // Useless
logger.info("Processing request"); // What request? For whom?

Good logging:

logger.info("Creating expense: groupId={}, amount={}, paidBy={}", 
    groupId, request.amount(), request.paidByUserId());

logger.error("Expense creation failed: groupId={}, reason={}", 
    groupId, ex.getMessage(), ex);

Add MDC (Mapped Diagnostic Context) for request tracing:

@Component
public class RequestIdFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
            HttpServletResponse response, FilterChain chain) {
        String requestId = UUID.randomUUID().toString().substring(0, 8);
        MDC.put("requestId", requestId);
        response.setHeader("X-Request-Id", requestId);
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.clear();
        }
    }
}

Now every log line includes the request ID. When a user reports “my payment failed,” you can trace the entire request through your logs.

Day 4: Health Checks + Actuator

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
management.endpoints.web.exposure.include=health,info,metrics
management.endpoint.health.show-details=always

Custom health check:

@Component
public class DatabaseHealthIndicator implements HealthIndicator {
    @Override
    public Health health() {
        try {
            jdbcTemplate.queryForObject("SELECT 1", Integer.class);
            return Health.up().withDetail("database", "PostgreSQL responding").build();
        } catch (Exception e) {
            return Health.down().withException(e).build();
        }
    }
}

GET /actuator/health → tells you if your app and all its dependencies are alive.

Every production system needs this. Load balancers use it to decide whether to route traffic to your server.

Day 5: Streams & Functional Java — Clean Code

Refactor your service layer to use Java streams where appropriate:

// Calculate who owes whom in a group
public List<BalanceDTO> calculateBalances(Long groupId) {
    List<Expense> expenses = expenseRepo.findByGroupId(groupId);
    
    Map<Long, BigDecimal> netBalances = expenses.stream()
        .flatMap(expense -> {
            Stream<Map.Entry<Long, BigDecimal>> paid = Stream.of(
                Map.entry(expense.getPaidBy().getId(), expense.getAmount()));
            Stream<Map.Entry<Long, BigDecimal>> owed = expense.getSplits().stream()
                .map(split -> Map.entry(split.getUser().getId(), split.getAmount().negate()));
            return Stream.concat(paid, owed);
        })
        .collect(Collectors.groupingBy(
            Map.Entry::getKey,
            Collectors.reducing(BigDecimal.ZERO, Map.Entry::getValue, BigDecimal::add)
        ));
    
    return netBalances.entrySet().stream()
        .map(e -> new BalanceDTO(e.getKey(), e.getValue()))
        .sorted(Comparator.comparing(BalanceDTO::amount).reversed())
        .toList();
}

Weekend: Month 2 Checkpoint

Your project should now have:

  • JWT authentication (register, login, protected endpoints)
  • Authorization (users can only access their own data)
  • PostgreSQL with proper indexes (verified with EXPLAIN ANALYZE)
  • Redis caching on balance calculations
  • Pagination on list endpoints
  • Rate limiting
  • Structured logging with request IDs
  • Health check endpoint
  • DSA modules 0105 done, DSA 06 — Binary Search this weekend
  • 30+ DSA problems solved
  • Can whiteboard a basic system design

This is a production-grade backend. Most mid-level engineers don’t have all of this in their personal projects.

Resources

WhatWhereTime
Spring PaginationBaeldung “Spring Data Pagination”15 min
Rate limitingBaeldung “Rate Limiting with Bucket4j”20 min
Structured loggingBaeldung “SLF4J MDC”15 min
Spring ActuatorSpring docs “Production-ready Features”20 min