Skip to main content

Follow System Design - Scalable Social Media Following Feature

Comprehensive low-level design for a scalable follow system with real-time updates, consistency models, and efficient data structures for social media platforms.

A robust follow system is essential for social media platforms, requiring careful consideration of data consistency, scalability, and real-time update mechanisms to handle millions of concurrent users and their relationships.

1. Problem Statement & Requirements

Clarifying questions

  1. What is the scale expectation for the follow system?

    • How many users are we expecting to support (e.g., millions of active users)?
    • What is the expected rate of follows/unfollows per second?
  2. What consistency requirements exist for follow relationships?

    • Should follows be strongly consistent or eventually consistent?
    • Are there any real-time use cases (e.g., live notifications) that require immediate consistency?
  3. What entities are being followed?

    • Are we only following users, or also content (e.g., posts, topics)?
    • Do we need to support following multiple entity types?
  4. What is the read/write ratio for follow data?

    • Is it primarily reads (e.g., "Who is following me?") or writes (e.g., "I'm following X")?
    • How does this ratio vary across different user segments?
  5. What latency sensitivity is expected?

    • What are the p50/p95/p99 latency targets for follow/unfollow operations?
    • Are there any real-time UI expectations that require sub-100ms responses?
  6. What authentication and authorization model is in place?

    • Is the system using JWT/OAuth2 for authentication?
    • Are there any role-based access controls (RBAC) or user permissions to consider?
  7. Are there external integrations or dependencies?

    • Do we need to integrate with other systems (e.g., analytics, notifications)?
    • Is there a requirement for audit logging or compliance reporting?

Functional requirements

  • Users can follow other users
  • Users can unfollow other users
  • Users can view their list of followed users (followers)
  • Users can view the list of users they are following (following)
  • Users can check if they are following a specific user
  • Users can view the count of followers and following for any user
  • System supports soft-delete of follow relationships (user can unfollow but data is retained for analytics)
  • System supports bulk follow/unfollow operations (optional)

Non-functional requirements

RequirementTarget
p50 latency for follow/unfollow< 50ms
p95 latency for follow/unfollow< 200ms
p99 latency for follow/unfollow< 500ms
Throughput (ops/sec)> 10,000 follow/unfollow operations/sec
Availability99.9% (5 minutes of downtime/month)
Data consistencyEventually consistent for follower counts, strongly consistent for follow/unfollow operations
ScalabilitySupport for 100M+ users with horizontal sharding

Constraints and assumptions

  • Assumption: Users are identified by unique IDs (UUID or auto-increment)
  • Assumption: The system will be built using a microservices architecture with a dedicated "Follow Service"
  • Assumption: No real-time follower count updates are required (can be eventually consistent)
  • Constraint: Follow relationships must support soft-delete to maintain analytics and audit trails
  • Assumption: Users can follow other users only (no content or topic following in this scope)

Out of scope

  • Following content, topics, or groups
  • Real-time follower count updates (e.g., UI showing live follower counts)
  • Bulk follow/unfollow operations beyond single-user actions
  • Follow recommendations or suggestions (e.g., "Suggested for you")
  • Notifications or triggers when a user is followed
  • Analytics or reporting on follow behavior (e.g., follow rate, retention)
  • Cross-service integration for other systems like notifications or analytics
  • Rate limiting or throttling of follow actions (handled at API gateway level)

2. API Design

API Style Selection

For a follow system, I'll select REST API style with the following justification:

  • Simple and well-understood by developers
  • Supports standard HTTP methods (GET, POST, DELETE)
  • Easy to integrate with existing frontend frameworks
  • Clear resource-based URLs that align with the domain model
  • Good tooling support (Swagger, Postman, etc.)

Endpoint Definitions

MethodPathDescriptionAuth
GET/v1/users/{userId}/followingGet list of users that the specified user is followingRequired
GET/v1/users/{userId}/followersGet list of users that are following the specified userRequired
POST/v1/users/{userId}/followFollow a target userRequired
DELETE/v1/users/{userId}/follow/{targetUserId}Unfollow a target userRequired
GET/v1/users/{userId}/following/{targetUserId}Check if user is following target userRequired

Request and Response Schemas

Follow Request Body

{
  "targetUserId": "uuid-string"
}

Follow Response Body

{
  "id": "uuid-string",
  "followerId": "uuid-string",
  "followingId": "uuid-string",
  "createdAt": "2023-12-01T10:30:00Z"
}

User Following List Response

{
  "users": [
    {
      "id": "uuid-string",
      "username": "johndoe",
      "displayName": "John Doe",
      "avatarUrl": "https://example.com/avatar.jpg"
    }
  ],
  "pagination": {
    "cursor": "cursor-string",
    "hasMore": true
  }
}

Error Response Format

{
  "error": {
    "code": "USER_NOT_FOUND",
    "message": "User with ID abc-123 not found",
    "timestamp": "2023-12-01T10:30:00Z",
    "requestId": "req-uuid-string"
  }
}

HTTP Status Codes

CodeDescription
200Success - GET, PUT, PATCH
201Created - POST
204No Content - DELETE
400Bad Request - Invalid input
401Unauthorized - Missing or invalid auth
403Forbidden - Insufficient permissions
404Not Found - Resource doesn't exist
429Too Many Requests - Rate limit exceeded
500Internal Server Error - Unexpected server error

API Versioning Strategy

Version the API using path prefix: /v1/ with future versions as /v2/, etc.

Pagination Design

Using cursor-based pagination with the following advantages:

  • Consistent results even when data changes during pagination
  • Better performance for large datasets
  • More predictable behavior than offset-based approaches

Authentication and Authorization

All endpoints require JWT Bearer token authentication with the following scopes:

  • follow:read - for GET operations
  • follow:write - for POST/DELETE operations

Authorization checks:

  • User can only follow/unfollow their own account
  • User can only read their own following/follower lists

Rate Limiting

Rate limiting per endpoint:

  • GET /following/followers: 100 requests/minute
  • POST /follow: 50 requests/minute
  • DELETE /follow: 50 requests/minute

Rate limiting enforced via Redis with sliding window algorithm.

Idempotency Design

For mutation endpoints, implement idempotency key strategy:

  • Clients send Idempotency-Key header with each request
  • Server stores operation result in cache for 5 minutes
  • If same key is used within window, return cached result
  • This prevents duplicate follow/unfollow operations from causing issues
// Idempotency key validation example
public class IdempotencyService {
    private final RedisTemplate<String, String> redisTemplate;
    
    public boolean isIdempotentRequest(String key) {
        return redisTemplate.hasKey("idempotency:" + key);
    }
    
    public void storeIdempotentResult(String key, String result) {
        redisTemplate.opsForValue().set(
            "idempotency:" + key, 
            result, 
            Duration.ofMinutes(5)
        );
    }
}

3. Data Model Design

Entity-Relationship Diagram

erDiagram
    USER {
        string id PK
        string username
        string email
        timestamp created_at
        timestamp updated_at
    }

    FOLLOW {
        string id PK
        string follower_id FK
        string followee_id FK
        timestamp created_at
        timestamp updated_at
    }

    USER ||--o{ FOLLOW : "follows"
    USER ||--o{ FOLLOW : "is_followed_by"

Table Schema Definitions

Users Table

CREATE TABLE users (
    id CHAR(36) PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    is_active BOOLEAN DEFAULT TRUE,
    
    INDEX idx_username (username),
    INDEX idx_email (email),
    INDEX idx_created_at (created_at)
);

Follows Table

CREATE TABLE follows (
    id CHAR(36) PRIMARY KEY,
    follower_id CHAR(36) NOT NULL,
    followee_id CHAR(36) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE,
    FOREIGN KEY (followee_id) REFERENCES users(id) ON DELETE CASCADE,
    UNIQUE KEY uk_follower_followee (follower_id, followee_id),
    INDEX idx_followee (followee_id),
    INDEX idx_created_at (created_at)
);

Primary Key Strategy

UUID v4 is selected for the following reasons:

  • Distributed system friendly (no coordination required)
  • No sequential patterns that could be exploited for enumeration attacks
  • Better distribution across sharding keys
  • Eliminates the need for a centralized ID generator

Index Design

Follows Table Indices:

  1. Primary Key (id) - Clustered index for fast row lookups
  2. Unique Composite Index (follower_id, followee_id) - Enforces uniqueness and enables fast existence checks
  3. Secondary Index (followee_id) - Optimizes follower list queries for a specific user
  4. Secondary Index (created_at) - Enables time-based follow list queries and pagination

Index Justification:

  • The composite unique index prevents duplicate follows while maintaining referential integrity
  • The followee_id index is critical for follower list queries, which will be frequent read operations
  • The created_at index supports chronological follow lists and analytics

Data Partitioning / Sharding Strategy

Shard by User ID (UUID):

  • Hash-based sharding on the first 4 bytes of UUID to ensure even distribution
  • Each shard contains users and their follow relationships
  • Shard key: hash(substring(user_id, 1, 4)) % shard_count

Rationale:

  • Even distribution of users and follow relationships
  • Reduces hotspots in follower list queries
  • Enables horizontal scaling with minimal cross-shard queries

Soft Delete vs. Hard Delete Decision

Soft Delete: Follows are soft-deleted using a deleted_at timestamp field.

ALTER TABLE follows ADD COLUMN deleted_at TIMESTAMP NULL;

Justification:

  • Audit trail requirements for analytics and compliance
  • Ability to restore follows in case of accidental deletion
  • Support for user account recovery scenarios
  • Better data integrity for analytics and reporting

Audit Trail Design

Follow Events Table:

CREATE TABLE follow_events (
    id CHAR(36) PRIMARY KEY,
    follow_id CHAR(36),
    user_id CHAR(36) NOT NULL,
    action ENUM('FOLLOW', 'UNFOLLOW') NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    INDEX idx_user_action (user_id, action),
    INDEX idx_created_at (created_at)
);

Data Archival Strategy and Retention Policy

Archival Strategy:

  1. Active Follows: Maintained in primary database with full indexing
  2. Historical Follows: Archived to data warehouse monthly using ETL process
  3. Retention Policy:
    • 2 years for active follow relationships (for analytics)
    • 5 years for audit trail data (compliance requirement)

Archival Process:

  • Scheduled nightly job to move follows older than 2 years to data warehouse
  • Soft-deleted follows are moved to archive table with full metadata
  • Audit trail is maintained in separate historical tables

Additional Considerations

Data Consistency:

  • Follows table maintains referential integrity through foreign key constraints
  • User activity counts (followers/following) are maintained in user table as denormalized fields for read performance

Schema Versioning:

  • All schema changes are version-controlled with database migration scripts
  • Schema evolution supports backward compatibility for read operations

4. Class Design & Service Layer

classDiagram
    class FollowService {
        +followUser(userId, targetUserId)
        +unfollowUser(userId, targetUserId)
        +getFollowers(userId, page, size)
        +getFollowing(userId, page, size)
        +isFollowing(userId, targetUserId)
    }
    
    class FollowRepository {
        +save(follow)
        +delete(follow)
        +exists(userId, targetUserId)
        +findFollowers(userId, page, size)
        +findFollowing(userId, page, size)
    }
    
    class Follow {
        -userId
        -targetUserId
        -createdAt
        +getUserId()
        +getTargetUserId()
        +getCreatedAt()
    }
    
    class UserService {
        +getUserById(userId)
        +userExists(userId)
    }
    
    class EventPublisher {
        +publish(event)
    }
    
    class FollowEvent {
        -userId
        -targetUserId
        -eventType
        -timestamp
    }
    
    class FollowState {
        +FOLLOWING
        +NOT_FOLLOWING
    }
    
    FollowService --> FollowRepository : uses
    FollowService --> UserService : validates
    FollowService --> EventPublisher : publishes
    Follow --> FollowEvent : emits
stateDiagram-v2
    [*] --> NOT_FOLLOWING
    NOT_FOLLOWING --> FOLLOWING : followUser()
    FOLLOWING --> NOT_FOLLOWING : unfollowUser()

Service Layer Overview

The follow system service layer handles core business logic for user following relationships. The key services are:

  1. FollowService - Main entry point for follow/unfollow operations
  2. UserService - User validation and metadata retrieval
  3. EventPublisher - Asynchronous event publishing for audit trails

Service Class Structure and Responsibilities

public interface FollowService {
    boolean followUser(String userId, String targetUserId);
    boolean unfollowUser(String userId, String targetUserId);
    List<User> getFollowers(String userId, int page, int size);
    List<User> getFollowing(String userId, int page, int size);
    boolean isFollowing(String userId, String targetUserId);
}

public class FollowServiceImpl implements FollowService {
    private final FollowRepository followRepository;
    private final UserService userService;
    private final EventPublisher eventPublisher;
    private final ObjectMapper objectMapper;

    public FollowServiceImpl(FollowRepository followRepository, 
                           UserService userService, 
                           EventPublisher eventPublisher) {
        this.followRepository = followRepository;
        this.userService = userService;
        this.eventPublisher = eventPublisher;
    }

    @Override
    @Transactional
    public boolean followUser(String userId, String targetUserId) {
        if (userId == null || targetUserId == null) {
            throw new IllegalArgumentException("User IDs cannot be null");
        }
        
        if (userId.equals(targetUserId)) {
            throw new IllegalArgumentException("Cannot follow self");
        }

        // Validate users exist
        if (!userService.userExists(userId)) {
            throw new UserNotFoundException("User not found: " + userId);
        }
        
        if (!userService.userExists(targetUserId)) {
            throw new UserNotFoundException("Target user not found: " + targetUserId);
        }

        // Check if already following
        if (followRepository.exists(userId, targetUserId)) {
            return false;
        }

        // Create follow relationship
        Follow follow = new Follow(userId, targetUserId);
        followRepository.save(follow);

        // Publish event
        FollowEvent event = new FollowEvent(userId, targetUserId, "FOLLOW", 
                                          Instant.now());
        eventPublisher.publish(event);

        return true;
    }

    @Override
    @Transactional
    public boolean unfollowUser(String userId, String targetUserId) {
        if (userId == == null || targetUserId == null) {
            throw new IllegalArgumentException("User IDs cannot be null");
        }

        if (!followRepository.exists(userId, targetUserId)) {
            return false;
        }

        Follow follow = new Follow(userId, targetUserId);
        followRepository.delete(follow);

        // Publish event
        FollowEvent event = new FollowEvent(userId, targetUserId, "UNFOLLOW", 
                                          Instant.now());
        eventPublisher.publish(event);

        return true;
    }

    @Override
    public List<User> getFollowers(String userId, int page, int size) {
        return followRepository.findFollowers(userId, page, size)
                              .stream()
                              .map(follow -> userService.getUserById(follow.getTargetUserId()))
                              .collect(Collectors.toList());
    }

    @Override
    public List<User> getFollowing(String userId, int page, int size) {
        return followRepository.findFollowing(userId, page, size)
                              .stream()
                              .map(follow -> userService.getUserById(follow.getTargetUserId()))
                              .collect(Collectors.toList());
    }

    @Override
    public boolean isFollowing(String userId, String targetUserId) {
        return followRepository.exists(userId, targetUserId);
    }
}

Business Logic Encapsulation Patterns

Strategy Pattern for Event Publishing

public interface EventPublisher {
    void publish(FollowEvent event);
}

public class AsyncEventPublisher implements EventPublisher {
    private final KafkaTemplate<String, FollowEvent> kafkaTemplate;
    
    @Override
    public void publish(FollowEvent event) {
        kafkaTemplate.send("follow-events", event);
    }
}

public class SyncEventPublisher implements EventPublisher {
    private final AuditLogger auditLogger;
    
    @Override
    public void publish(FollowEvent event) {
        auditLogger.log(event);
    }
}

Critical-Path Method Implementation

@Transactional
public boolean followUser(String userId, String targetUserId) {
    // Input validation and user existence checks
    if (userId == null || targetUserId == null) {
        throw new IllegalArgumentException("User IDs cannot be null");
    }
    
    if (userId.equals(targetUserId)) {
        throw new IllegalArgumentException("Cannot follow self");
    }

    // Validate users exist - this is a read operation that should not block writes
    if (!userService.userExists(userId)) {
        throw new UserNotFoundException("User not found: " + userId);
    }
    
    if (!userService.userExists(targetUserId)) {
        throw new UserNotFoundException("Target user not found: " + targetUserId);
    }

    // Optimistic locking for concurrent access - use database-level constraints
    try {
        Follow follow = new Follow(userId, targetUserId);
        
        // Attempt to create the follow relationship - database will handle uniqueness
        followRepository.save(follow);
        
        // Publish event asynchronously to avoid blocking the main operation
        FollowEvent event = new FollowEvent(userId, targetUserId, "FOLLOW", 
                                          Instant.now());
        eventPublisher.publish(event);
        
        return true;
    } catch (DataIntegrityViolationException e) {
        // Handle concurrent creation - if already exists, return false
        return false;
    } catch (Exception e) {
        // Log and rethrow for proper error handling
        logger.error("Failed to follow user {} -> {}", userId, targetUserId, e);
        throw new FollowOperationException("Failed to create follow relationship", e);
    }
}

Transaction Boundary Design

  • Single transaction scope: Each follow/unfollow operation is wrapped in a single database transaction
  • Read isolation: Uses READ_COMMITTED isolation level to prevent dirty reads
  • Idempotency: Database constraints ensure that duplicate follow attempts don't create duplicates
  • Consistency guarantees: All database operations within a single follow operation are atomic

Service Interface Contracts

public interface FollowService {
    /**
     * Creates a follow relationship between two users.
     * @param userId the follower's ID
     * @param targetUserId the user being followed's ID
     * @return true if follow was created, false if already following
     * @throws UserNotFoundException when either user doesn't exist
     * @throws IllegalArgumentException when input is invalid
     */
    boolean followUser(String userId, String targetUserId);
    
    /**
     * Removes a follow relationship between two users.
     * @param userId the follower's ID
     * @param targetUserId the user being followed's ID
     * @return true if follow was removed, false if not following
     */
    boolean unfollowUser(String userId, String targetUserId);
    
    /**
     * Retrieves followers for a user with pagination.
     * @param userId the target user's ID
     * @param page page number (0-indexed)
     * @param size items per page
     * @return list of followers
     */
    List<User> getFollowers(String userId, int page, int size);
    
    /**
     * Retrieves following users for a user with pagination.
     * @param userId the target user's ID
     * @param page page number (0-indexed)
     * @param size items per page
     * @return list of followed users
     */
    List<User> getFollowing(String userId, int page, int size);
    
    /**
     * Checks if a user is following another user.
     * @param userId the follower's ID
     * @param targetUserId the user being followed's ID
     * @return true if following, false otherwise
     */
    boolean isFollowing(String userId, String targetUserId);
}

Saga / Orchestration Pattern

For distributed systems, the follow operation would be orchestrated using a saga pattern:

public class FollowSaga {
    private final EventPublisher eventPublisher;
    
    public void executeFollowSaga(String userId, String targetUserId) {
        try {
            // Step 1: Create follow relationship in database
            followService.followUser(userId, targetUserId);
            
            // Step 2: Update user statistics in separate service
            userStatsService.updateFollowerCount(targetUserId, 1);
            
            // Step 3: Send notification to target user
            notificationService.sendFollowNotification(targetUserId, userId);
            
        } catch (Exception e) {
            // Compensating actions - rollback if needed
            handleFollowFailure(userId, targetUserId);
            throw new SagaException("Failed to complete follow saga", e);
        }
    }
}

Domain Object / Value Object Design

public class Follow {
    private final String userId;
    private final String targetUserId;
    private final Instant createdAt;

    public Follow(String userId, String targetUserId) {
        this.userId = Objects.requireNonNull(userId);
        this.targetUserId = Objects.requireNonNull(targetUserId);
        this.createdAt = Instant.now();
    }

    // Getters and equals/hashCode methods
    public String getUserId() { return userId; }
    public String getTargetUserId() { return targetUserId; }
    public Instant getCreatedAt() { return createdAt; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Follow)) return false;
        Follow follow = (Follow) o;
        return Objects.equals(userId, follow.userId) && 
               Objects.equals(targetUserId, follow.targetUserId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(userId, targetUserId);
    }
}

public class FollowEvent {
    private final String userId;
    private final String targetUserId;
    private final String eventType;
    private final Instant timestamp;

    public FollowEvent(String userId, String targetUserId, String eventType, Instant timestamp) {
        this.userId = userId;
        this.targetUserId = targetUserId;
        this.eventType = eventType;
        this.timestamp = timestamp;
    }

    // Getters
}

5. Concurrency & Thread Safety

Concurrency Model

The follow system will use a thread-safe, asynchronous processing model with:

  • Java's CompletableFuture for async operations
  • Thread pool configuration with fixed-size executors (2x CPU cores for I/O bound operations)
  • Non-blocking I/O operations using Reactor or Vert.x for high-concurrency scenarios
  • Per-request thread isolation to prevent cross-request state pollution

Race Condition Prevention Strategy

Race conditions in the follow system can occur during:

  1. Concurrent follow/unfollow operations on same user pair
  2. Simultaneous read/write of follow counts
  3. Concurrent updates to user's follow lists

Prevention mechanisms:

  • Atomic operations using database-level atomic increments/decrements
  • Optimistic locking with version fields in user follow stats table
  • Database transaction isolation levels set to READ COMMITTED or SERIALIZABLE for critical operations
  • Application-level mutexes for user-specific operations using ConcurrentHashMap with StampedLock

Optimistic vs. Pessimistic Locking Selection

Optimistic locking selected for the following reasons:

  • High read-to-write ratio (users follow/unfollow less frequently than they read their feeds)
  • Low contention in typical usage patterns
  • Better performance under concurrent loads
  • Follow operations are idempotent and can safely retry on conflict

Implementation pattern:

// Pessimistic locking would use SELECT FOR UPDATE, but we prefer optimistic
// using version field in database and retry logic on failure
public class FollowService {
    private static final int MAX_RETRY_ATTEMPTS = 3;
    
    public boolean followUser(String followerId, String followeeId) {
        for (int attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) {
            try {
                return executeFollowAttempt(followerId, followeeId);
            } catch (OptimisticLockException e) {
                if (attempt == MAX_RETRY_ATTEMPTS - 1) {
                    throw new FollowServiceException("Failed to follow after retries", e);
                }
                // Exponential backoff with jitter
                Thread.sleep(100 * (1L << attempt) + ThreadLocalRandom.current().nextInt(50));
            }
        }
        return false;
    }
}

Deadlock Prevention Strategy

Deadlock prevention in follow operations:

  1. Consistent ordering - All operations order user IDs alphabetically or by hash to prevent circular waits
  2. Timeout configuration - Database transaction timeouts set to 5 seconds
  3. Async operation queuing - Use bounded thread pools with timeout handling to prevent queue buildup
  4. Operation batching - Batch multiple follow operations into single transactions when possible

Idempotency Implementation for Concurrent Duplicate Requests

Idempotency key strategy:

  • Use X-Idempotency-Key header in requests
  • Store key in Redis with TTL of 1 hour to prevent duplicate processing
  • For follow/unfollow operations, check if operation already exists before processing
public class IdempotencyService {
    private final RedisTemplate<String, String> redisTemplate;
    
    public boolean isIdempotentRequest(String key) {
        String existing = redisTemplate.opsForValue().get(key);
        if (existing != null) {
            return true; // Already processed
        }
        redisTemplate.opsForValue().set(key, "processed", 1, TimeUnit.HOURS);
        return false;
    }
}

Concurrency Control in User Follow Lists

For read-heavy operations, implement:

  • Read-copy-update pattern for user follow lists to avoid blocking reads
  • Immutable collections with copy-on-write semantics for user's follow list in memory
  • Thread-local caches for frequently accessed follow relationships

Thread Safety in Service Layer

All service methods are designed to be stateless and thread-safe:

  • No instance variables storing mutable state
  • Use of immutable collections and value objects
  • Thread-safe concurrent data structures where needed (e.g., ConcurrentHashMap for in-memory caches)
  • Proper synchronization of shared resources like rate limiters and counters

Load Handling Strategy

To handle high concurrent loads:

  • Use semaphore-based rate limiting to control concurrent follow operations per user
  • Implement bulk operation support for batch follows (e.g., "follow 100 users at once")
  • Use asynchronous processing for non-critical operations like notifications
  • Implement request queuing with bounded buffers to prevent system overload

Transaction Isolation Considerations

When using database transactions:

  • Follow operations require READ COMMITTED isolation level to prevent dirty reads
  • For consistency-critical operations (like updating user stats), use SERIALIZABLE when necessary
  • Implement proper transaction rollback handling to maintain data consistency across concurrent operations

6. Sequence Diagrams for Key Flows

sequenceDiagram
    participant User as User (Actor)
    participant API as FollowService API
    participant Service as FollowService
    participant Repo as FollowRepository
    participant DB as Database
    participant Cache as Redis Cache

    User->>API: POST /follows
    API->>Service: followUser(userId, targetUserId)
    Service->>Repo: save(follow)
    Repo->>DB: INSERT INTO follows (follower_id, followee_id, created_at)
    Repo->>Cache: SET follow:{userId}:{targetUserId} = true
    Service->>API: return success
    API->>User: 201 Created
sequenceDiagram
    participant User as User (Actor)
    participant API as FollowService API
    participant Service as FollowService
    participant Repo as FollowRepository
    participant DB as Database
    participant Cache as Redis Cache

    User->>API: DELETE /follows/{targetUserId}
    API->>Service: unfollowUser(userId, targetUserId)
    Service->>Repo: delete(follow)
    Repo->>DB: DELETE FROM follows WHERE follower_id = ? AND followee_id = ?
    Repo->>Cache: DEL follow:{userId}:{targetUserId}
    Service->>API: return success
    API->>User: 200 OK
sequenceDiagram
    participant User as User (Actor)
    participant API as FollowService API
    participant Service as FollowService
    participant Repo as FollowRepository
    participant DB as Database
    participant Cache as Redis Cache
    participant EventPublisher as Event Publisher
    participant NotificationService as Notification Service

    User->>API: POST /follows
    API->>Service: followUser(userId, targetUserId)
    Service->>Repo: save(follow)
    Repo->>DB: INSERT INTO follows (follower_id, followee_id, created_at)
    Repo->>Cache: SET follow:{userId}:{targetUserId} = true
    Service->>EventPublisher: publish(FollowCreatedEvent)
    EventPublisher->>NotificationService: send notification
    Service->>API: return success
    API->>User: 201 Created

7. Caching Design

Cache Strategy Selection

For the Follow system, we'll use a cache-aside pattern with write-through behavior for critical operations. This approach provides the best balance between consistency and performance for a follow system where immediate consistency is required for user actions.

Cache Key Design

Cache keys will follow a structured namespace pattern:

{namespace}:{entity_type}:{entity_id}:{operation}

Examples:

  • follow:user:12345:following_count
  • follow:user:12345:followers_list
  • follow:user:12345:is_following:67890
  • follow:user:12345:following_list

TTL Strategy

Data TypeTTLReason
Following count5 minutesFrequent reads, infrequent updates
Followers list10 minutesModerate read frequency, acceptable staleness
Following list10 minutesModerate read frequency, acceptable staleness
Is following check2 minutesHigh consistency requirement for user actions
User profile cache1 hourStable data, less frequent updates

Cache Eviction Policy

We'll use LRU (Least Recently Used) eviction policy with a secondary TTL-based cleanup. This ensures that hot data stays in cache while preventing memory exhaustion from stale entries.

Cache Stampede Prevention

We'll implement probabilistic early expiry and distributed locking:

  1. When cache miss occurs, use a distributed lock (Redis SET with NX) to prevent multiple concurrent requests
  2. Set cache entries with a 50% chance of expiring early (to prevent stampede)
  3. Implement circuit breaker pattern for cache failures

Distributed Cache Architecture

Using Redis Cluster with:

  • 3 master nodes for data distribution
  • 3 replica nodes for high availability
  • Redis Sentinel for automatic failover
  • Cache sharding based on user ID hash

Cache Invalidation Strategy

Event-driven invalidation will be the primary strategy:

  1. When a follow/unfollow operation occurs, publish user_followed or user_unfollowed events
  2. Cache consumers listen to these events and invalidate relevant cache entries
  3. Use Redis Pub/Sub for real-time invalidation
  4. Fallback to TTL-based invalidation for stale entries

Cache Implementation Example (Java)

@Component
public class FollowCacheService {
    private final RedisTemplate<String, Object> redisTemplate;
    private final ObjectMapper objectMapper;
    
    public void invalidateUserFollowingCache(Long userId) {
        String keyPattern = "follow:user:" + userId + ":*";
        
        // Use Redis SCAN for efficient pattern matching
        Set<String> keys = redisTemplate.keys(keyPattern);
        if (!keys.isEmpty()) {
            redisTemplate.delete(keys);
        }
    }
    
    public Boolean isUserFollowing(Long userId, Long targetUserId) {
        String cacheKey = "follow:user:" + userId + ":is_following:" + targetUserId;
        
        // Try to get from cache with optimistic locking
        String lockKey = "lock:" + cacheKey;
        try {
            if (redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", Duration.ofSeconds(1))) {
                Boolean cachedValue = (Boolean) redisTemplate.opsForValue().get(cacheKey);
                if (cachedValue != null) {
                    return cachedValue;
                }
                
                // Cache miss - fetch from DB and populate cache
                Boolean result = fetchFromDatabase(userId, targetUserId);
                redisTemplate.opsForValue().set(cacheKey, result, Duration.ofMinutes(2));
                return result;
            } else {
                // Wait and retry
                Thread.sleep(50);
                return (Boolean) redisTemplate.opsForValue().get(cacheKey);
            }
        } finally {
            // Release lock
            redisTemplate.delete(lockKey);
        }
    }
    
    public void invalidateFollowOperation(Long userId, Long targetUserId) {
        // Invalidate all relevant cache entries
        Set<String> keysToInvalidate = new HashSet<>();
        keysToInvalidate.add("follow:user:" + userId + ":is_following:" + targetUserId);
        keysToInvalidate.add("follow:user:" + userId + ":following_count");
        keysToInvalidate.add("follow:user:" + userId + ":following_list");
        keysToInvalidate.add("follow:user:" + targetUserId + ":followers_count");
        keysToInvalidate.add("follow:user:" + targetUserId + ":followers_list");
        
        redisTemplate.delete(keysToInvalidate);
    }
}

8. Repository / Data Access Layer

Repository Pattern Implementation

The repository pattern is implemented to abstract data access logic and provide a clean separation between domain objects and database operations. Each entity has its own repository interface that defines the contract for data access operations.

public interface FollowRepository {
    Follow save(Follow follow);
    Optional<Follow> findByFollowerIdAndFollowingId(String followerId, String followingId);
    List<Follow> findByFollowerId(String followerId);
    List<Follow> findByFollowingId(String followingId);
    void delete(Follow follow);
    long countByFollowingId(String followingId);
    boolean existsByFollowerIdAndFollowingId(String followerId, String followingId);
}

ORM vs. Raw Query Decisions

For the follow system, we use a combination of ORM and raw queries:

  • ORM Usage: For basic CRUD operations and simple queries that don't require complex joins or performance optimization
  • Raw Queries: For high-performance analytical queries and complex aggregations that benefit from database-specific optimizations
@Repository
public class FollowRepositoryImpl implements FollowRepository {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    @Override
    public Follow save(Follow follow) {
        if (follow.getId() == null) {
            entityManager.persist(follow);
        } else {
            entityManager.merge(follow);
        }
        return follow;
    }
    
    @Override
    public Optional<Follow> findByFollowerIdAndFollowingId(String followerId, String followingId) {
        return entityManager.createQuery(
            "SELECT f FROM Follow f WHERE f.followerId = :followerId AND f.followingId = :followingId",
            Follow.class)
            .setParameter("followerId", followerId)
            .setParameter("followingId", followingId)
            .getResultStream()
            .findFirst();
    }
    
    @Override
    public List<Follow> findByFollowingId(String followingId) {
        return entityManager.createQuery(
            "SELECT f FROM Follow f WHERE f.followingId = :followingId ORDER BY f.createdAt DESC",
            Follow.class)
            .setParameter("followingId", followingId)
            .setMaxResults(1000) // Prevent unbounded results
            .getResultList();
    }
    
    @Override
    public long countByFollowingId(String followingId) {
        return entityManager.createQuery(
            "SELECT COUNT(f) FROM Follow f WHERE f.followingId = :followingId",
            Long.class)
            .setParameter("followingId", followingId)
            .getSingleResult();
    }
    
    // Raw query for performance-critical analytics
    @Override
    public List<FollowStats> getFollowStats(String userId) {
        String sql = """
            SELECT 
                f.following_id as followingId,
                COUNT(*) as followCount,
                MAX(f.created_at) as lastFollowedAt
            FROM follows f 
            WHERE f.follower_id = :userId 
            GROUP BY f.following_id
            ORDER BY followCount DESC
            LIMIT 100
            """;
            
        return entityManager.createNativeQuery(sql, "FollowStats")
            .setParameter("userId", userId)
            .getResultList();
    }
}

Read vs. Write Separation

The follow system implements read-write separation using CQRS principles where:

  • Read Operations: Optimized for fast retrieval of follow relationships and statistics
  • Write Operations: Handle creation, deletion, and updates with proper transaction handling
public interface FollowReadRepository {
    List<Follow> findByFollowingId(String followingId, Pageable pageable);
    long countByFollowingId(String followingId);
    List<String> getFollowingIds(String followerId, Pageable pageable);
}

public interface FollowWriteRepository {
    Follow save(Follow follow);
    void delete(String followerId, String followingId);
    boolean exists(String followerId, String followingId);
}

Connection Pool Configuration

Connection pool configuration is tuned for the follow system's usage patterns:

# application.yml
spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000
      validation-timeout: 5000
      leak-detection-threshold: 60000

N+1 Query Prevention Strategy

To prevent N+1 query problems, we implement:

  1. Eager Loading: Using JOIN FETCH for related entities
  2. Batch Loading: Fetching multiple follow relationships in batches
@Query("SELECT f FROM Follow f JOIN FETCH f.follower u WHERE f.followingId = :userId")
List<Follow> findByFollowingIdWithFollower(@Param("userId") String userId);

@Query("SELECT f FROM Follow f JOIN FETCH f.following u WHERE f.followerId = :userId")
List<Follow> findByFollowerIdWithFollowing(@Param("userId") String userId);

Batch Operation Design

Batch operations are implemented for bulk follow/unfollow operations:

@Repository
public class BatchFollowRepository {
    
    public void batchCreateFollows(List<Follow> follows) {
        String sql = """
            INSERT INTO follows (id, follower_id, following_id, created_at) 
            VALUES (?, ?, ?, ?)
            """;
            
        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql)) {
            
            for (Follow follow : follows) {
                stmt.setString(1, follow.getId());
                stmt.setString(2, follow.getFollowerId());
                stmt.setString(3, follow.getFollowingId());
                stmt.setTimestamp(4, Timestamp.valueOf(follow.getCreatedAt()));
                stmt.addBatch();
            }
            
            stmt.executeBatch();
        } catch (SQLException e) {
            throw new DataAccessException("Failed to batch create follows", e);
        }
    }
}

Transactional Boundaries

Each operation that modifies the follow relationship is wrapped in a transaction:

@Service
@Transactional
public class FollowService {
    
    @Transactional(readOnly = true)
    public List<Follow> getFollowers(String userId, Pageable pageable) {
        return followReadRepository.findByFollowingId(userId, pageable);
    }
    
    @Transactional
    public Follow createFollow(String followerId, String followingId) {
        // Validate user existence and business rules
        validateFollowRequest(followerId, followingId);
        
        Follow follow = new Follow();
        follow.setId(UUID.randomUUID().toString());
        follow.setFollowerId(followerId);
        follow.setFollowingId(followingId);
        follow.setCreatedAt(Instant.now());
        
        return followRepository.save(follow);
    }
    
    @Transactional
    public void deleteFollow(String followerId, String followingId) {
        Follow follow = followRepository.findByFollowerIdAndFollowingId(followerId, followingId)
            .orElseThrow(() -> new FollowNotFoundException("Follow not found"));
        
        followRepository.delete(follow);
    }
}

Data Access Layer Testing Strategy

The repository layer is tested with:

  1. Unit Tests: Mocked database interactions for isolated logic testing
  2. Integration Tests: Real database connections with test containers for end-to-end validation
@ExtendWith(SpringExtension.class)
@SpringBootTest
@TestPropertySource(properties = "spring.datasource.url=jdbc:h2:mem:testdb")
class FollowRepositoryTest {
    
    @Autowired
    private FollowRepository followRepository;
    
    @Test
    void shouldCreateFollow() {
        Follow follow = new Follow();
        follow.setId("1");
        follow.setFollowerId("user1");
        follow.setFollowingId("user2");
        
        Follow saved = followRepository.save(follow);
        
        assertThat(saved.getId()).isEqualTo("1");
    }
    
    @Test
    void shouldFindFollowByUserIds() {
        Follow follow = new Follow();
        follow.setId("1");
        follow.setFollowerId("user1");
        follow.setFollowingId("user2");
        
        followRepository.save(follow);
        
        Optional<Follow> found = followRepository.findByFollowerIdAndFollowingId("user1", "user2");
        
        assertThat(found).isPresent();
    }
}

9. Asynchronous Processing & Messaging

Event-driven boundaries in this feature

The follow system will use a hybrid synchronous/asynchronous approach. The core follow and unfollow operations will be handled synchronously to ensure immediate consistency for the user's follow state. However, we'll use asynchronous processing for:

  1. Notification generation - When a user follows another user, we need to notify the followed user (e.g., via email or push notification)
  2. Analytics tracking - Track follow events for business intelligence and user behavior analysis
  3. Social graph updates - Update follower/following counts and maintain social graph indices for search and recommendations
  4. Background cleanup - Handle soft-deleted follows or data archiving

The decision to make these asynchronous is based on the fact that they don't block the primary user experience and can be processed later without affecting the core functionality.

Message schema design with sample payload

{
  "version": "1.0",
  "event_type": "user.followed",
  "timestamp": "2023-10-15T14:30:00Z",
  "user_id": "uuid-12345",
  "target_user_id": "uuid-67890",
  "follow_id": "uuid-follow-abcde",
  "metadata": {
    "source": "web-app",
    "ip_address": "192.168.1.100"
  }
}

We'll use Avro schema for type-safe message contracts with versioning support:

{
  "type": "record",
  "name": "FollowEvent",
  "fields": [
    {"name": "version", "type": "string"},
    {"name": "event_type", "type": "string"},
    {"name": "timestamp", "type": "string", "logicalType": "timestamp-millis"},
    {"name": "user_id", "type": "string"},
    {"name": "target_user_id", "type": "string"},
    {"name": "follow_id", "type": "string"},
    {"name": "metadata", "type": {
      "type": "map",
      "values": "string"
    }}
  ]
}

Dead letter queue strategy and poison message handling

We'll implement a dead-letter queue (DLQ) pattern for failed message processing:

  1. Retry Strategy: Messages will be retried up to 3 times with exponential backoff (1s, 2s, 4s)
  2. DLQ Configuration: Messages that fail after max retries will be moved to a DLQ topic
  3. Poison Message Handling:
    • Messages that fail consistently (same error pattern) will be flagged for manual review
    • DLQ messages will include original message context and error details
    • A monitoring dashboard will surface these for manual intervention

At-least-once vs. exactly-once delivery

We'll implement at-least-once delivery for the follow system because:

  1. Idempotency is built-in: The system uses a unique follow_id and checks for existing follow relationships before processing
  2. User experience is more important than perfect consistency: A duplicate notification is less problematic than missing a follow
  3. Simpler implementation: Avoids complexity of distributed transaction coordination

Retry policy for consumers

public class FollowEventRetryPolicy {
    private static final int MAX_RETRIES = 3;
    private static final long INITIAL_DELAY_MS = 1000;
    private static final double BACKOFF_MULTIPLIER = 2.0;
    
    public boolean shouldRetry(int attempt, Exception error) {
        if (attempt >= MAX_RETRIES) return false;
        
        // Don't retry on known unrecoverable errors
        if (error instanceof ValidationException || 
            error instanceof UserNotFoundException) {
            return false;
        }
        
        return true;
    }
    
    public long getDelay(int attempt) {
        return (long) (INITIAL_DELAY_MS * Math.pow(BACKOFF_MULTIPLIER, attempt));
    }
}

Outbox pattern for reliable event publishing

We'll implement the outbox pattern to ensure eventual consistency between database writes and message publishing:

@Service
public class FollowEventPublisher {
    
    @Autowired
    private EventOutboxRepository outboxRepository;
    
    @Autowired
    private MessageProducer messageProducer;
    
    public void publishFollowEvent(Follow follow) {
        // 1. Create outbox entry
        OutboxEvent event = OutboxEvent.builder()
            .eventId(UUID.randomUUID().toString())
            .eventType("user.followed")
            .payload(buildEventPayload(follow))
            .status(OutboxStatus.PENDING)
            .createdAt(Instant.now())
            .build();
            
        outboxRepository.save(event);
        
        // 2. Publish to message broker
        try {
            messageProducer.publish(event.getEventId(), event.getPayload());
            outboxRepository.updateStatus(event.getId(), OutboxStatus.SENT);
        } catch (Exception e) {
            // Mark as failed for retry processing
            outboxRepository.updateStatus(event.getId(), OutboxStatus.FAILED);
        }
    }
}

Sequence diagram for async flows

sequenceDiagram
    participant User
    participant API
    participant DB
    participant Outbox
    participant MessageBroker
    participant NotificationService
    participant AnalyticsService
    
    User->>API: POST /follows
    API->>DB: Create follow record
    DB-->>API: Return follow ID
    API->>Outbox: Save event to outbox table
    Outbox-->>API: Confirm save
    API-->>User: Return success response
    
    loop Retry logic (if needed)
        Outbox->>MessageBroker: Publish follow event
        MessageBroker-->>Outbox: Acknowledge receipt
        Outbox->>Outbox: Update status to SENT
    end
    
    MessageBroker->>NotificationService: Send notification event
    NotificationService-->>MessageBroker: Acknowledge
    MessageBroker->>AnalyticsService: Send analytics event
    AnalyticsService-->>MessageBroker: Acknowledge

10. Error Handling Design

Error Classification Hierarchy

The error handling strategy follows a hierarchical classification system to enable proper routing and response generation:

Error
├── ValidationException (400)
│   ├── InvalidInputException
│   └── ConstraintViolationException
├── BusinessLogicException (409)
│   ├── AlreadyExistsException
│   ├── InsufficientPermissionsException
│   └── ResourceConflictException
├── InfrastructureException (500)
│   ├── DatabaseException
│   ├── ExternalServiceException
│   └── TimeoutException
└── AuthorizationException (401/403)
    ├── InvalidTokenException
    └── InsufficientScopeException

Error Propagation Strategy

Errors follow a layered propagation model:

  • Validation errors are caught and transformed at the API boundary
  • Business logic errors are handled at service layer and re-thrown as domain-specific exceptions
  • Infrastructure errors are wrapped in appropriate service-level exceptions or retried where applicable
  • Authorization errors are immediately handled at middleware level

Retry Strategy with Exponential Backoff and Jitter

For transient infrastructure errors, a retry mechanism is implemented:

public class RetryableOperation {
    private static final int MAX_RETRIES = 3;
    private static final long BASE_DELAY_MS = 1000;
    private static final double JITTER_FACTOR = 0.5;

    public <T> T executeWithRetry(Supplier<T> operation) {
        int attempts = 0;
        while (attempts < MAX_RETRIES) {
            try {
                return operation.get();
            } catch (InfrastructureException e) {
                if (attempts >= MAX_RETRIES - 1) {
                    throw e;
                }
                
                long delay = (long) (BASE_DELAY_MS * Math.pow(2, attempts) 
                    * (1 + JITTER_FACTOR * (Math.random() - 0.5)));
                
                try {
                    Thread.sleep(delay);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    throw new InfrastructureException("Retry interrupted", ie);
                }
                
                attempts++;
            }
        }
        throw new InfrastructureException("Max retries exceeded");
    }
}

Circuit Breaker Design

For external service dependencies, a circuit breaker pattern is implemented:

public class CircuitBreaker {
    private final int failureThreshold = 5;
    private final long timeoutMs = 60000;
    private final int resetTimeoutMs = 30000;
    
    private volatile CircuitState state = CircuitState.CLOSED;
    private int failureCount = 0;
    private long lastFailureTime = 0;
    
    public enum CircuitState {
        CLOSED, OPEN, HALF_OPEN
    }
    
    public <T> T execute(Supplier<T> operation) {
        switch (state) {
            case CLOSED:
                return executeClosed(operation);
            case OPEN:
                return executeOpen(operation);
            case HALF_OPEN:
                return executeHalfOpen(operation);
            default:
                throw new IllegalStateException("Unknown circuit state");
        }
    }
    
    private <T> T executeClosed(Supplier<T> operation) {
        try {
            T result = operation.get();
            failureCount = 0; // Reset on success
            return result;
        } catch (Exception e) {
            failureCount++;
            lastFailureTime = System.currentTimeMillis();
            
            if (failureCount >= failureThreshold) {
                state = CircuitState.OPEN;
            }
            
            throw e;
        }
    }
    
    private <T> T executeOpen(Supplier<T> operation) {
        if (System.currentTimeMillis() - lastFailureTime > resetTimeoutMs) {
            state = CircuitState.HALF_OPEN;
            return executeHalfOpen(operation);
        }
        
        throw new ExternalServiceException("Circuit breaker is OPEN");
    }
    
    private <T> T executeHalfOpen(Supplier<T> operation) {
        try {
            T result = operation.get();
            state = CircuitState.CLOSED;
            return result;
        } catch (Exception e) {
            state = CircuitState.OPEN;
            throw e;
        }
    }
}

Fallback Behavior Design

Fallbacks are implemented for critical external dependencies:

public class FallbackService {
    private final ExternalUserService externalUserService;
    
    public User getUserWithFallback(String userId) {
        try {
            return externalUserService.getUser(userId);
        } catch (ExternalServiceException e) {
            // Log the failure for monitoring
            logger.warn("External user service failed, using fallback", e);
            
            // Return default or cached user data
            return createDefaultUser(userId);
        }
    }
    
    private User createDefaultUser(String userId) {
        return new User(userId, "Anonymous", "anonymous@example.com");
    }
}

Error Logging Format and Alerting Thresholds

Structured logging follows this format:

{
  "timestamp": "2023-10-15T14:30:00Z",
  "trace_id": "abc123def456",
  "span_id": "xyz789uvw012",
  "user_id": "user-12345",
  "error_code": "FOLLOW_001",
  "error_message": "User cannot follow themselves",
  "error_type": "VALIDATION_ERROR",
  "http_status": 400,
  "component": "FollowService",
  "request_path": "/api/v1/follow",
  "request_method": "POST"
}

Alerting thresholds:

  • Critical (P0): Database connection failures, circuit breaker OPEN state
  • High (P1): Validation errors exceeding 100/minute, authorization failures
  • Medium (P2): Business logic errors exceeding 50/minute, external service timeouts
  • Low (P3): Infrastructure warnings below threshold

Error Response Format

Standardized JSON error response:

{
  "error": {
    "code": "FOLLOW_001",
    "message": "Cannot follow yourself",
    "details": {
      "userId": "user-12345",
      "targetUserId": "user-12345"
    },
    "timestamp": "2023-10-15T14:30:00Z"
  }
}

Error Handling in Service Layer

@Service
public class FollowService {
    
    public void followUser(String followerId, String targetUserId) {
        try {
            // Validate inputs
            if (followerId == null || targetUserId == null) {
                throw new InvalidInputException("User IDs cannot be null");
            }
            
            if (followerId.equals(targetUserId)) {
                throw new ValidationException("Cannot follow yourself");
            }
            
            // Business logic validation
            if (hasUserAlreadyFollowed(followerId, targetUserId)) {
                throw new AlreadyExistsException("Already following user");
            }
            
            // Transaction boundary
            followUserTransaction(followerId, targetUserId);
            
        } catch (ValidationException e) {
            logger.warn("Validation error in followUser", e);
            throw e;
        } catch (DatabaseException e) {
            logger.error("Database error in followUser", e);
            throw new InfrastructureException("Failed to process follow request", e);
        } catch (Exception e) {
            logger.error("Unexpected error in followUser", e);
            throw new InfrastructureException("Internal server error", e);
        }
    }
}

11. Security Design

Input Validation and Sanitization Strategy

All API inputs undergo strict validation using Jakarta Bean Validation (JSR-380) with custom constraints for user IDs and follow relationships. Input sanitization is handled through Jackson's built-in XSS protection and custom filters that strip dangerous HTML tags from any text fields. For example, user handle validation uses regex pattern ^[a-zA-Z0-9_]{3,20}$ to prevent injection attacks.

SQL Injection Prevention

All database queries are constructed using parameterized prepared statements with Hibernate's Criteria API to prevent SQL injection. Raw SQL is never used for user-controlled inputs. Stored procedures are avoided entirely in favor of ORM-based query construction.

Authentication Middleware Design

Authentication is handled via JWT tokens passed in Authorization header with Bearer schema. A custom filter intercepts all requests and validates the JWT signature using RS256 algorithm with public key from JWKS endpoint. Token expiration is checked and refresh tokens are managed via separate secure HTTP-only cookies.

Authorization Enforcement

RBAC-based authorization is enforced at service layer using Spring Security annotations. Users can only follow/unfollow targets they have permission to access (e.g., public profiles, friends, or verified accounts). Permission checks are performed via a dedicated authorization service that validates user roles and resource ownership.

Sensitive Data Encryption

All sensitive data at rest is encrypted using AES-256-GCM with 256-bit keys managed by AWS KMS. Data in transit is protected using TLS 1.3 with HSTS header enforcement. User IDs and follow relationship data are encrypted at rest using the same key rotation policy as other sensitive PII.

PII Handling and Masking Strategy

User identifiers are masked in logs using a deterministic obfuscation algorithm that preserves uniqueness while preventing direct correlation. Follow relationships are never logged in plain text - instead, they're represented as encrypted hashes of user IDs. All PII fields are marked with appropriate sensitivity labels for audit purposes.

Secrets Management

Secrets including JWT signing keys, database credentials, and external API tokens are managed through AWS Secrets Manager with automatic rotation every 90 days. The application fetches secrets at startup and caches them in memory with automatic refresh on expiration.

Audit Logging Design

All follow/unfollow actions are logged to a centralized audit trail with structured JSON format containing:

  • timestamp
  • user_id (obfuscated)
  • target_user_id (obfuscated)
  • action_type (FOLLOW/UNFOLLOW)
  • ip_address (masked to /24)
  • user_agent (truncated)
  • session_id (hashed)

Audit logs are retained for 90 days with automatic archiving to S3 Glacier for long-term storage. All log entries are signed using HMAC-SHA256 to prevent tampering and ensure audit integrity.

12. Testing Strategy

Unit Testing Scope and Coverage Targets (%)

  • Service Layer: 85% code coverage with focus on business logic and edge cases
  • Repository Layer: 90% coverage for all CRUD operations with parameterized test cases
  • Domain Objects: 100% coverage for value objects and immutable entities
  • Exception Handling: 100% coverage for all error paths and validation scenarios
  • Integration Points: 95% coverage for external dependency mocks (e.g., cache, event publisher)

Integration Testing Strategy

  • Database Integration: Use Testcontainers with PostgreSQL 13+ for end-to-end DB tests
  • External Service Mocks: WireMock for external API dependencies (e.g., user service, notification service)
  • Event System: Embedded Kafka or Redis Streams for async flow testing
  • Caching Layer: In-memory Redis mock using RedisMock or embedded Redis for cache behavior validation
  • Cross-Service Boundaries: Use embedded service containers (e.g., Spring Boot Test with @Import) to simulate real-world integration

Contract Testing

  • Tool: Pact (consumer-driven contract testing)
  • Scope: API contracts between Follow service and User service, Notification service
  • Versioning Strategy: Contract versioned with semantic versioning (v1.0, v1.1) to support backward compatibility
  • Testing Frequency: Run contract tests in CI pipeline on every pull request and release branch

Performance Testing Tool

  • Tool: k6 (open-source load testing tool) with JMeter for complex scenarios
  • Key Scenarios:
    • Concurrent follow/unfollow operations (1000+ concurrent users)
    • Batch follow operations (500 users following 1000 entities)
    • High-volume read paths (10k+ followers query)
    • Cache warm-up and hit rate validation under load
  • Target Metrics:
    • p50 latency < 50ms for follow operations
    • p95 latency < 200ms for follow operations
    • Throughput > 10k ops/sec for read-heavy paths

Test Data Management Strategy

  • Fixtures: YAML-based fixtures with predefined user profiles, follow relationships, and metadata for consistent testing
  • Factories: Java-based factory classes (e.g., UserFactory, FollowFactory) for generating test data with realistic distribution
  • Seeding: Use database migration scripts (Flyway) to seed test environments with realistic data sets
  • Data Isolation: Use database transaction isolation in unit tests and clean test databases per test suite
  • Data Generation Strategy: Use Faker libraries for generating realistic user names, emails, and timestamps while maintaining deterministic test behavior

Test Environment Setup

  • Local Development: Docker Compose setup with PostgreSQL, Redis, and Kafka for local testing
  • CI/CD Pipeline: Use GitHub Actions with parallel test execution, caching of dependencies, and artifact storage for test reports
  • Test Data Lifecycle: Automated cleanup of test data after each test suite run to prevent cross-contamination
  • Test Assertion Strategy: Use AssertJ for fluent assertions and Mockito for stubbing external dependencies

Test Reporting and Analysis

  • Coverage Reports: Jacoco integration with SonarQube for continuous code coverage monitoring
  • Performance Metrics: k6 test results stored in Grafana dashboards with alerting thresholds for latency and error rates
  • Test Failures: Automated Slack notifications on test failures with stack trace and test execution context
  • Regression Testing: Run full test suite on every code change with failure analysis and root cause identification tools

13. Observability Design

Structured Logging Schema

All logs follow a structured JSON format with consistent fields:

{
  "timestamp": "2023-10-27T14:30:45.123Z",
  "level": "INFO",
  "service": "follow-service",
  "trace_id": "1234567890abcdef1234567890abcdef",
  "span_id": "abcdef1234567890abcdef1234567890",
  "user_id": "user-123",
  "entity_id": "follow-456",
  "operation": "followUser",
  "request_id": "req-789",
  "message": "User followed target user",
  "duration_ms": 45,
  "error_code": null,
  "error_message": null,
  "stack_trace": null
}

PII masking rules:

  • User IDs are logged as user-{id} (redacted)
  • Email addresses are masked to **@**.com
  • Full names are truncated to first character: J***

Distributed Tracing Instrumentation

Using OpenTelemetry with custom spans:

Core Operations:

  1. follow_user - Span for follow creation
  2. unfollow_user - Span for follow removal
  3. get_followers - Span for follower list retrieval
  4. get_following - Span for following list retrieval

Span Attributes:

  • user.id: User ID (redacted)
  • target.user.id: Target user ID (redacted)
  • operation.name: Operation name
  • follow.status: "success", "failure", "duplicate"
  • request.duration.ms: Operation duration

Metrics Design

RED Metrics (Rate, Errors, Duration):

Metric NameTypeLabelsDescription
follow_requests_totalCounteroperation, statusTotal follow/unfollow requests
follow_errors_totalCounteroperation, error_typeFollow operation errors
follow_duration_secondsHistogramoperationOperation latency
active_followers_countGaugeuser_idCurrent follower count
active_following_countGaugeuser_idCurrent following count

Additional Metrics:

  • cache_hit_ratio - Cache hit rate for follower lists
  • db_query_duration_seconds - Database query latency histogram
  • api_request_count - Request count by endpoint

Health Check Endpoint Design

GET /health
GET /ready  
GET /live
GET /startup

Response Format:

{
  "status": "healthy",
  "checks": {
    "database": {"status": "healthy", "duration_ms": 12},
    "cache": {"status": "healthy", "duration_ms": 5},
    "message_queue": {"status": "healthy", "duration_ms": 8}
  },
  "timestamp": "2023-10-27T14:30:45.123Z"
}

Health Check Logic:

  • /live - Basic service availability (memory check)
  • /ready - Service ready to serve traffic (DB, cache connections)
  • /health - Full health including external dependencies
  • /startup - Startup validation (config, secrets)

Alerting Thresholds

Critical Alerts (on-call):

  • follow_errors_total > 100/5m (critical)
  • follow_duration_seconds.p99 > 500ms (warning)
  • db_query_duration_seconds.p99 > 1000ms (warning)
  • Cache hit ratio < 70% (warning)

Warning Alerts:

  • follow_errors_total > 50/5m (warning)
  • follow_requests_total < 100/5m (warning - potential outage)
  • active_followers_count > 1M per user (potential data skew)

Escalation Path:

  • Warning → Slack notification + PagerDuty
  • Critical → On-call engineer + Slack channel + SMS alert

14. Open Questions, Risks & Trade-offs

Decisions requiring product / engineering alignment

  • Follow vs. Subscribe distinction: Should the system support different types of follow relationships (e.g., "follow user" vs. "subscribe to topic") with different behaviors and permissions? This requires product alignment on whether to build a unified follow system or separate systems.
  • Cross-platform follow behavior: Should following between platforms (e.g., web, mobile) be consistent? Do we need to support cross-platform follow relationships or treat each platform as independent?
  • Notification behavior on follow: Should following trigger immediate notifications to the followed user? How do we handle notification volume scaling?

Known trade-offs and justifications

  • Consistency vs. Performance: Using eventual consistency for follow relationships to improve read performance (e.g., allowing reads to bypass transactional consistency) trades immediate consistency for better latency and throughput. This is acceptable for most social features where slight delays in follow state propagation are acceptable.
  • Indexing vs. Storage: Creating composite indexes for common query patterns (e.g., follower_id + created_at) improves read performance but increases storage overhead and write latency. The trade-off is acceptable for frequently accessed follow data.
  • Caching vs. Cache Invalidation: Using a distributed cache (Redis) for follow counts and recent follows improves read performance but introduces complexity in cache invalidation strategies. The system uses event-driven invalidation to balance performance and consistency.
  • Soft delete vs. Hard delete: Soft-deleting follows (setting deleted_at) allows for recovery and audit trail but increases storage overhead. Hard deletion is more efficient but eliminates possibility of recovery or historical analysis.
  • Monolithic vs. Microservice: The follow system is designed as a monolithic component within a larger social platform, avoiding the complexity of inter-service communication. This trade-off simplifies development but increases coupling.

Risks and mitigation strategies

  • High write volume: The follow system may experience high write volumes during peak usage (e.g., trending content or viral posts). Mitigation includes using batch operations for bulk follows, implementing rate limiting, and using async processing for non-critical follow operations.
  • Cache stampede: Concurrent requests to fetch follow counts or recent follows may cause cache misses and overwhelming database load. Mitigation includes probabilistic early expiration, distributed locks, and using Redis's built-in atomic operations.
  • Data skew: Some users may have significantly more followers than others, leading to hotspots in database reads. Mitigation includes partitioning by user ID and using read replicas for high-volume users.
  • Race conditions in concurrent follow/unfollow: Multiple simultaneous requests to follow or unfollow the same user could cause inconsistent state. Mitigation includes optimistic locking and idempotency keys to ensure atomicity.
  • Privacy and data access: Follow relationships can be sensitive, especially in private or restricted contexts. Mitigation includes strict access control checks and audit logging for all follow actions.

Technical debt acknowledged

  • Limited support for cross-platform follow: The current design assumes a single platform context and does not yet support cross-platform or multi-tenant follow relationships, which may require future refactoring.
  • Simplified notification system: The current design does not include a robust notification queue or prioritization system, which could become a bottleneck for high-volume follow scenarios.
  • No support for batch operations in API: While the system supports individual follow/unfollow, batch operations (e.g., "follow 50 users") are not optimized for performance and may require future enhancement.
  • Limited analytics support: The system does not currently provide detailed analytics or insights into follow behavior, which could be a future enhancement requiring additional data modeling and indexing.
  • No support for follow relationship types: The system treats all follows as identical, but future requirements may need to distinguish between different follow types (e.g., "follow for content", "follow for notifications"). This would require a more complex data model and service layer design.

Was this article helpful?

Topics in this article

More from Backend LLD

  • Backend HLD

    API Rate Limiter Design and Implementation

    Comprehensive backend system design for rate limiting APIs with token bucket and sliding window algorithms, including distributed architecture considerations.

  • Backend LLD

    CQRS Framework Design and Implementation

    Comprehensive low-level design for a Command Query Responsibility Segregation (CQRS) framework with event sourcing, read/write model separation, and distributed consistency patterns.

Stay in the Loop

Get curated DSA patterns, coding problems, frontend and backend LLD/HLD, and AI/ML System Design insights delivered straight to your inbox. No spam, just focused interview and engineering content.