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
-
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?
-
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?
-
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?
-
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?
-
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?
-
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?
-
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
| Requirement | Target |
|---|---|
| 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 |
| Availability | 99.9% (5 minutes of downtime/month) |
| Data consistency | Eventually consistent for follower counts, strongly consistent for follow/unfollow operations |
| Scalability | Support 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
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /v1/users/{userId}/following | Get list of users that the specified user is following | Required |
| GET | /v1/users/{userId}/followers | Get list of users that are following the specified user | Required |
| POST | /v1/users/{userId}/follow | Follow a target user | Required |
| DELETE | /v1/users/{userId}/follow/{targetUserId} | Unfollow a target user | Required |
| GET | /v1/users/{userId}/following/{targetUserId} | Check if user is following target user | Required |
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
| Code | Description |
|---|---|
| 200 | Success - GET, PUT, PATCH |
| 201 | Created - POST |
| 204 | No Content - DELETE |
| 400 | Bad Request - Invalid input |
| 401 | Unauthorized - Missing or invalid auth |
| 403 | Forbidden - Insufficient permissions |
| 404 | Not Found - Resource doesn't exist |
| 429 | Too Many Requests - Rate limit exceeded |
| 500 | Internal 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 operationsfollow: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-Keyheader 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:
- Primary Key (id) - Clustered index for fast row lookups
- Unique Composite Index (follower_id, followee_id) - Enforces uniqueness and enables fast existence checks
- Secondary Index (followee_id) - Optimizes follower list queries for a specific user
- 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:
- Active Follows: Maintained in primary database with full indexing
- Historical Follows: Archived to data warehouse monthly using ETL process
- 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:
- FollowService - Main entry point for follow/unfollow operations
- UserService - User validation and metadata retrieval
- 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
CompletableFuturefor 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:
- Concurrent follow/unfollow operations on same user pair
- Simultaneous read/write of follow counts
- 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
ConcurrentHashMapwithStampedLock
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:
- Consistent ordering - All operations order user IDs alphabetically or by hash to prevent circular waits
- Timeout configuration - Database transaction timeouts set to 5 seconds
- Async operation queuing - Use bounded thread pools with timeout handling to prevent queue buildup
- Operation batching - Batch multiple follow operations into single transactions when possible
Idempotency Implementation for Concurrent Duplicate Requests
Idempotency key strategy:
- Use
X-Idempotency-Keyheader 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.,
ConcurrentHashMapfor 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_countfollow:user:12345:followers_listfollow:user:12345:is_following:67890follow:user:12345:following_list
TTL Strategy
| Data Type | TTL | Reason |
|---|---|---|
| Following count | 5 minutes | Frequent reads, infrequent updates |
| Followers list | 10 minutes | Moderate read frequency, acceptable staleness |
| Following list | 10 minutes | Moderate read frequency, acceptable staleness |
| Is following check | 2 minutes | High consistency requirement for user actions |
| User profile cache | 1 hour | Stable 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:
- When cache miss occurs, use a distributed lock (Redis SET with NX) to prevent multiple concurrent requests
- Set cache entries with a 50% chance of expiring early (to prevent stampede)
- 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:
- When a follow/unfollow operation occurs, publish
user_followedoruser_unfollowedevents - Cache consumers listen to these events and invalidate relevant cache entries
- Use Redis Pub/Sub for real-time invalidation
- 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:
- Eager Loading: Using JOIN FETCH for related entities
- 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:
- Unit Tests: Mocked database interactions for isolated logic testing
- 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:
- Notification generation - When a user follows another user, we need to notify the followed user (e.g., via email or push notification)
- Analytics tracking - Track follow events for business intelligence and user behavior analysis
- Social graph updates - Update follower/following counts and maintain social graph indices for search and recommendations
- 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:
- Retry Strategy: Messages will be retried up to 3 times with exponential backoff (1s, 2s, 4s)
- DLQ Configuration: Messages that fail after max retries will be moved to a DLQ topic
- 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:
- Idempotency is built-in: The system uses a unique
follow_idand checks for existing follow relationships before processing - User experience is more important than perfect consistency: A duplicate notification is less problematic than missing a follow
- 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:
follow_user- Span for follow creationunfollow_user- Span for follow removalget_followers- Span for follower list retrievalget_following- Span for following list retrieval
Span Attributes:
user.id: User ID (redacted)target.user.id: Target user ID (redacted)operation.name: Operation namefollow.status: "success", "failure", "duplicate"request.duration.ms: Operation duration
Metrics Design
RED Metrics (Rate, Errors, Duration):
| Metric Name | Type | Labels | Description |
|---|---|---|---|
follow_requests_total | Counter | operation, status | Total follow/unfollow requests |
follow_errors_total | Counter | operation, error_type | Follow operation errors |
follow_duration_seconds | Histogram | operation | Operation latency |
active_followers_count | Gauge | user_id | Current follower count |
active_following_count | Gauge | user_id | Current following count |
Additional Metrics:
cache_hit_ratio- Cache hit rate for follower listsdb_query_duration_seconds- Database query latency histogramapi_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.