Skip to main content

Reminder Service Low-Level Design - Backend System Architecture

Comprehensive backend LLD for a scalable reminder service with cron-based scheduling, notification delivery, and fault-tolerant processing.

This document presents a production-ready low-level design for a reminder service that handles millions of scheduled notifications with high availability and fault tolerance across distributed systems.

1. Problem Statement & Requirements

Clarifying questions

  1. What is the scale expectation for this reminder service?

    • How many active reminders can we expect to handle concurrently?
    • What is the peak rate of reminder creation and delivery?
  2. What consistency requirements exist for reminders?

    • Should all reminders be delivered exactly once, or is at-least-once acceptable?
    • Are there any strict SLA requirements for delivery timing?
  3. What are the key entities and their relationships in this system?

    • Are reminders tied to users, or can they be shared across users?
    • Do reminders have dependencies on other entities like events, tasks, or calendar items?
  4. What is the read/write ratio for reminders?

    • Is this primarily a write-heavy system (e.g., frequent creation) or read-heavy (e.g., frequent querying)?
    • What is the expected ratio of reminder creation vs. retrieval?
  5. How sensitive is the system to latency?

    • Are reminders time-sensitive (e.g., 5 minutes before an event), or can they be delivered within a few hours?
    • Is there a maximum acceptable delivery delay?
  6. What authentication and authorization model is in place?

    • Will reminders be tied to user sessions, OAuth2 tokens, or API keys?
    • Can users access reminders created by others?
  7. Are there any external integrations or dependencies?

    • Will reminders trigger notifications through email, SMS, push, or in-app?
    • Is there integration with calendar systems (e.g., Google Calendar, Outlook)?

Functional requirements

  • Create Reminder: Users can create a reminder with a title, description, trigger time, and optional recurrence pattern.
  • List Reminders: Users can retrieve all their reminders or filter by date/time range.
  • Update Reminder: Users can modify existing reminders (e.g., title, time, recurrence).
  • Delete Reminder: Users can delete a reminder or cancel all future occurrences.
  • Trigger Reminder: System automatically delivers a reminder at its trigger time (e.g., via push notification, email).
  • Recurring Reminders: Support for daily, weekly, monthly, or custom recurring patterns.
  • Reminder Scheduling: Reminders can be scheduled for future times (e.g., 5 minutes from now).

Non-functional requirements

  • Latency Targets:
    • p50: < 100ms for read operations (list, get)
    • p95: < 500ms for read operations
    • p99: < 1s for read operations
    • p50/p95/p99 for write operations: < 200ms / 500ms / 1s
  • Throughput:
    • 10,000 reminder creations per second (peak)
    • 50,000 reminder reads per second (peak)
  • Availability:
    • 99.9% uptime (28.8 minutes of downtime/month max)
  • Delivery Reliability:
    • At-least-once delivery guarantee for reminder triggers

Constraints and assumptions

  • Assumptions:

    • Users are authenticated via JWT tokens or OAuth2.
    • Reminder delivery is handled by a separate notification service (e.g., FCM, Twilio).
    • Reminders are stored in a single region for simplicity (multi-region support can be added later).
    • The system will not support reminders more than 10 years in the future.
  • Constraints:

    • Reminder creation and retrieval must be fast enough to support real-time user interaction.
    • System must handle up to 10 million active reminders across all users.
    • Reminder triggers must be accurate within ±5 minutes (for non-critical reminders) or ±1 minute (for time-sensitive ones).

Out of scope

  • User management: Creation, deletion, or modification of user accounts.
  • Calendar integration: Sync with external calendar systems (e.g., Google Calendar, Outlook).
  • Multi-language support: Reminder content is in a single language (e.g., English).
  • Webhooks or third-party integrations: Reminder triggers only support internal notification methods.
  • Advanced scheduling logic: Complex scheduling like "first Monday of the month" is not supported.
  • User preferences and settings: Notification method, timezone, or reminder tone customization is not part of this design.
  • Analytics dashboard: Reporting or analytics on reminder usage is out of scope.

2. API Design

API Style Selection

gRPC with Protocol Buffers is selected for the Reminder service due to:

  • Strong typing and automatic code generation
  • Efficient binary serialization with lower payload sizes than JSON
  • Built-in support for streaming and bidirectional communication
  • Native support for versioning through proto file management
  • Better performance characteristics for high-throughput scenarios

Endpoint Definitions

MethodPathDescriptionAuthRate Limit
CreateReminderPOST /v1/remindersCreates a new reminderJWT100/hour
GetReminderGET /v1/reminders/{id}Retrieves a reminder by IDJWT1000/hour
UpdateReminderPUT /v1/reminders/{id}Updates an existing reminderJWT50/hour
DeleteReminderDELETE /v1/reminders/{id}Deletes a reminder by IDJWT50/hour
ListRemindersGET /v1/remindersLists user's reminders with paginationJWT100/hour
TriggerReminderPOST /v1/reminders/{id}/triggerForces immediate trigger of a reminderJWT20/hour
GetReminderHistoryGET /v1/reminders/{id}/historyGets reminder trigger historyJWT50/hour

Request and Response Schemas

CreateReminderRequest

message CreateReminderRequest {
  string title = 1;
  string description = 2;
  int64 trigger_time = 3; // Unix timestamp
  string timezone = 4;
  repeated string recipients = 5;
  ReminderType type = 6;
  map<string, string> metadata = 7;
}

enum ReminderType {
  EMAIL = 0;
  SMS = 1;
  PUSH_NOTIFICATION = 2;
}

CreateReminderResponse

message CreateReminderResponse {
  string id = 1;
  string title = 2;
  int64 created_at = 3;
  int64 trigger_time = 4;
}

GetReminderResponse

message GetReminderResponse {
  string id = 1;
  string title = 2;
  string description = 3;
  int64 trigger_time = 4;
  string timezone = 5;
  repeated string recipients = 6;
  ReminderType type = 7;
  int64 created_at = 8;
  int64 updated_at = 9;
  bool is_active = 10;
}

HTTP Status Codes and Error Response Format

Status CodeDescription
200Success
201Created
400Bad Request
401Unauthorized
403Forbidden
404Not Found
429Rate Limited
500Internal Server Error

Standard error response:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Trigger time must be in future",
    "details": [
      {
        "field": "trigger_time",
        "message": "Must be in future"
      }
    ]
  }
}

API Versioning Strategy

Versioned using path prefix: /v1/reminders with support for future versions like /v2/reminders. Versioning strategy allows for breaking changes to be introduced without affecting existing clients.

Pagination Design

Cursor-based pagination is used because:

  • More consistent and predictable than offset-based pagination
  • Handles data changes during pagination better
  • Provides stable results for users
message ListRemindersRequest {
  string cursor = 1;
  int32 page_size = 2; // Default: 20, Max: 100
}

Authentication and Authorization

All endpoints require JWT Bearer token with scopes:

  • reminder:read - for GET operations
  • reminder:write - for CREATE/UPDATE/DELETE operations

Rate Limiting Strategy

Rate limiting implemented per user with:

  • Token bucket algorithm for burst handling
  • Per-user limits to prevent abuse
  • Sliding window for better fairness

Idempotency Design

For mutation endpoints, idempotency key is supported via header:

X-Idempotency-Key: <uuid>

If same key is used within 24 hours, operation is idempotent and returns same result.

3. Data Model Design

Entity-Relationship Diagram

erDiagram
    USER ||--o{ REMINDER : "creates"
    REMINDER ||--o{ REMINDER_EVENT : "has"
    REMINDER_EVENT ||--o{ REMINDER_NOTIFICATION : "triggers"
    REMINDER ||--o{ REMINDER_LOG : "logs"
    
    USER {
        string id PK
        string email
        string name
        timestamp created_at
        timestamp updated_at
    }
    
    REMINDER {
        string id PK
        string user_id FK
        string title
        string description
        timestamp trigger_time
        string status
        string recurrence_pattern
        timestamp created_at
        timestamp updated_at
        timestamp deleted_at
    }
    
    REMINDER_EVENT {
        string id PK
        string reminder_id FK
        timestamp event_time
        string event_type
        string status
        timestamp created_at
    }
    
    REMINDER_NOTIFICATION {
        string id PK
        string reminder_event_id FK
        string channel
        string status
        timestamp sent_at
        timestamp created_at
    }
    
    REMINDER_LOG {
        string id PK
        string reminder_id FK
        string log_level
        string message
        timestamp created_at
    }

Table Schema Definitions

Users Table

CREATE TABLE users (
    id VARCHAR(36) PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    name VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    INDEX idx_email (email),
    INDEX idx_created_at (created_at)
);

Reminders Table

CREATE TABLE reminders (
    id VARCHAR(36) PRIMARY KEY,
    user_id VARCHAR(36) NOT NULL,
    title VARCHAR(255) NOT NULL,
    description TEXT,
    trigger_time TIMESTAMP NOT NULL,
    status ENUM('ACTIVE', 'COMPLETED', 'CANCELLED') DEFAULT 'ACTIVE',
    recurrence_pattern VARCHAR(255),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL,
    
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    INDEX idx_user_trigger_time (user_id, trigger_time),
    INDEX idx_trigger_time (trigger_time),
    INDEX idx_status (status),
    INDEX idx_deleted_at (deleted_at),
    INDEX idx_created_at (created_at)
);

Reminder Events Table

CREATE TABLE reminder_events (
    id VARCHAR(36) PRIMARY KEY,
    reminder_id VARCHAR(36) NOT NULL,
    event_time TIMESTAMP NOT NULL,
    event_type ENUM('TRIGGER', 'SCHEDULED') NOT NULL,
    status ENUM('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED') DEFAULT 'PENDING',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    FOREIGN KEY (reminder_id) REFERENCES reminders(id) ON DELETE CASCADE,
    INDEX idx_reminder_event_time (reminder_id, event_time),
    INDEX idx_event_time (event_time),
    INDEX idx_status (status)
);

Reminder Notifications Table

CREATE TABLE reminder_notifications (
    id VARCHAR(36) PRIMARY KEY,
    reminder_event_id VARCHAR(36) NOT NULL,
    channel ENUM('EMAIL', 'SMS', 'PUSH') NOT NULL,
    status ENUM('PENDING', 'SENT', 'FAILED') DEFAULT 'PENDING',
    sent_at TIMESTAMP NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    FOREIGN KEY (reminder_event_id) REFERENCES reminder_events(id) ON DELETE CASCADE,
    INDEX idx_reminder_event_channel (reminder_event_id, channel),
    INDEX idx_status (status),
    INDEX idx_created_at (created_at)
);

Reminder Logs Table

CREATE TABLE reminder_logs (
    id VARCHAR(36) PRIMARY KEY,
    reminder_id VARCHAR(36) NOT NULL,
    log_level ENUM('INFO', 'WARN', 'ERROR') NOT NULL,
    message TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    FOREIGN KEY (reminder_id) REFERENCES reminders(id) ON DELETE CASCADE,
    INDEX idx_reminder_created_at (reminder_id, created_at),
    INDEX idx_created_at (created_at)
);

Primary Key Strategy

UUID v4 is used for all primary keys to:

  • Provide global uniqueness across distributed systems
  • Prevent enumeration attacks on IDs
  • Support horizontal sharding without ID conflicts
  • Avoid sequential ID prediction vulnerabilities

Index Design

Reminders Table Indices:

  1. idx_user_trigger_time (user_id, trigger_time) - For efficient user-specific reminder queries and time-range filtering
  2. idx_trigger_time (trigger_time) - For efficient query of upcoming reminders across users
  3. idx_status (status) - For filtering active vs completed reminders
  4. idx_deleted_at (deleted_at) - For soft-deleted record filtering in queries
  5. idx_created_at (created_at) - For audit and time-based queries

Reminder Events Table Indices:

  1. idx_reminder_event_time (reminder_id, event_time) - For efficient event retrieval by reminder with time ordering
  2. idx_event_time (event_time) - For efficient query of events by time window
  3. idx_status (status) - For filtering pending vs processed events

Reminder Notifications Table Indices:

  1. idx_reminder_event_channel (reminder_event_id, channel) - For efficient lookup of notifications by event and channel
  2. idx_status (status) - For filtering pending vs sent notifications

Data Partitioning / Sharding Strategy

Shard by user_id hash modulo 16 for horizontal scaling:

  • Each shard contains users with similar user_id hash values
  • Reminder queries can be routed to specific shards based on user_id
  • Cross-shard queries handled via coordinator service or sharding key lookup tables

Soft Delete vs. Hard Delete

Soft delete is implemented using deleted_at timestamp field:

  • Prevents data loss during accidental deletions
  • Enables audit trail and recovery capability
  • Allows for logical restoration of reminders (with business logic validation)
  • Maintains referential integrity in related tables

Audit Trail Design

All data modification operations log to reminder_logs table:

  • Fields: reminder_id, log_level (INFO/WARN/ERROR), message, created_at
  • Messages capture: creation, update, deletion, status changes, error conditions
  • Provides complete audit trail for compliance and debugging

Data Archival Strategy

  • Reminders with status = 'COMPLETED' and updated_at < 30 days ago are archived to cold storage
  • Archive retention: 1 year for compliance requirements
  • Archival process runs nightly with batch processing to minimize impact on live systems
  • Archived data is accessible via separate read-only service for compliance queries

## 4. Class Design & Service Layer

```mermaid
classDiagram
    class ReminderService {
        +createReminder(ReminderRequest) Reminder
        +updateReminder(String, ReminderUpdateRequest) Reminder
        +deleteReminder(String) void
        +getReminder(String) Reminder
        +listReminders(UserId, Filter) List~Reminder~
        +triggerReminder(String) void
    }
    
    class ReminderRepository {
        +save(Reminder) Reminder
        +findById(String) Optional~Reminder~
        +findByUserIdAndStatus(UserId, Status) List~Reminder~
        +updateStatus(String, Status) void
        +delete(String) void
    }
    
    class Reminder {
        -id: String
        -userId: String
        -title: String
        -description: String
        -scheduledTime: Instant
        -status: Status
        -createdAt: Instant
        -updatedAt: Instant
        +isOverdue() boolean
        +isActive() boolean
    }
    
    class ReminderEventPublisher {
        +publish(ReminderEvent) void
    }
    
    class ReminderProcessor {
        +processPendingReminders() void
    }
    
    class ReminderValidator {
        +validateCreateRequest(ReminderRequest) ValidationResult
        +validateUpdateRequest(ReminderUpdateRequest) ValidationResult
    }
    
    class ReminderScheduler {
        +scheduleReminder(String, Instant) void
        +cancelScheduledReminder(String) void
    }
    
    class ReminderEvent {
        -id: String
        -reminderId: String
        -eventType: EventType
        -timestamp: Instant
    }
    
    class ReminderStatus {
        ACTIVE
        PENDING
        COMPLETED
        CANCELLED
        OVERDUE
    }
    
    ReminderService --> ReminderRepository : uses
    ReminderService --> ReminderValidator : validates
    ReminderService --> ReminderEventPublisher : publishes
    ReminderService --> ReminderProcessor : schedules
    ReminderService --> ReminderScheduler : schedules
    
    Reminder --> ReminderEventPublisher : emits events
    Reminder --> ReminderProcessor : triggers processing
    
    class ReminderRequest {
        title: String
        description: String
        scheduledTime: Instant
        userId: String
    }
    
    class ReminderUpdateRequest {
        title: String
        description: String
        scheduledTime: Instant
    }
    
    class ReminderFilter {
        status: Status
        userId: String
        from: Instant
        to: Instant
    }
    
    class ValidationResult {
        valid: boolean
        errors: List~String~
    }
stateDiagram-v2
    [*] --> PENDING
    PENDING --> ACTIVE : ScheduledTime reached
    ACTIVE --> COMPLETED : Triggered
    ACTIVE --> OVERDUE : Time passed without trigger
    ACTIVE --> CANCELLED : User cancels
    PENDING --> CANCELLED : User cancels
    OVERDUE --> CANCELLED : User cancels

Service Layer Structure

The Reminder service is designed with a clean separation of concerns:

  1. ReminderService - Main entry point for all reminder operations
  2. ReminderRepository - Data access layer abstraction
  3. ReminderValidator - Input validation and business rule enforcement
  4. ReminderEventPublisher - Event publishing for async operations
  5. ReminderProcessor - Background processing of reminders
  6. ReminderScheduler - Scheduling and timing management

Business Logic Patterns

Strategy Pattern for Reminder Processing

public interface ReminderProcessorStrategy {
    void process(Reminder reminder);
}

@Component
public class EmailReminderProcessor implements ReminderProcessorStrategy {
    private final EmailService emailService;
    
    @Override
    public void process(Reminder reminder) {
        emailService.sendReminder(reminder.getUserId(), 
            reminder.getTitle(), reminder.getDescription());
    }
}

@Component
public class PushNotificationReminderProcessor implements ReminderProcessorStrategy {
    private final NotificationService notificationService;
    
    @Override
    public void process(Reminder reminder) {
        notificationService.sendPushNotification(reminder.getUserId(), 
            reminder.getTitle(), reminder.getDescription());
    }
}

@Component
public class ReminderProcessor {
    private final Map<String, ReminderProcessorStrategy> strategies;
    
    public ReminderProcessor(Map<String, ReminderProcessorStrategy> strategies) {
        this.strategies = strategies;
    }
    
    public void process(Reminder reminder) {
        String type = reminder.getType(); // Could be "email", "push"
        ReminderProcessorStrategy strategy = strategies.get(type);
        if (strategy != null) {
            strategy.process(reminder);
        }
    }
}

Critical-Path Method Implementation

@Transactional
public Reminder createReminder(ReminderRequest request) {
    // Validate input
    ValidationResult validation = validator.validateCreateRequest(request);
    if (!validation.isValid()) {
        throw new ValidationException("Invalid reminder request", validation.getErrors());
    }
    
    // Create domain object
    Reminder reminder = Reminder.builder()
        .id(UUID.randomUUID().toString())
        .userId(request.getUserId())
        .title(request.getTitle())
        .description(request.getDescription())
        .scheduledTime(request.getScheduledTime())
        .status(Status.PENDING)
        .createdAt(Instant.now())
        .updatedAt(Instant.now())
        .build();
    
    // Save to database
    Reminder savedReminder = repository.save(reminder);
    
    // Schedule for processing if in future
    if (reminder.getScheduledTime().isAfter(Instant.now())) {
        scheduler.scheduleReminder(reminder.getId(), reminder.getScheduledTime());
    } else {
        // Immediate processing if already due
        processReminder(reminder.getId());
    }
    
    // Publish event for async processing
    ReminderEvent event = ReminderEvent.builder()
        .id(UUID.randomUUID().toString())
        .reminderId(reminder.getId())
        .eventType(EventType.CREATED)
        .timestamp(Instant.now())
        .build();
    
    eventPublisher.publish(event);
    
    return savedReminder;
}

private void processReminder(String reminderId) {
    Reminder reminder = repository.findById(reminderId)
        .orElseThrow(() -> new ResourceNotFoundException("Reminder not found"));
    
    // Update status to ACTIVE
    repository.updateStatus(reminderId, Status.ACTIVE);
    
    // Process based on type (email, push, etc.)
    processor.process(reminder);
    
    // Update status to COMPLETED
    repository.updateStatus(reminderId, Status.COMPLETED);
}

Transaction Boundary Design

  • Create/Update/Delete operations: Single database transaction
  • Cross-service operations: Saga pattern for distributed transactions (e.g., when reminder creation triggers external notifications)
  • Batch operations: Transaction per batch unit, not entire operation set

Service Interface Contracts

public interface ReminderService {
    Reminder createReminder(ReminderRequest request);
    Reminder updateReminder(String id, ReminderUpdateRequest request);
    void deleteReminder(String id);
    Reminder getReminder(String id);
    List<Reminder> listReminders(UserId userId, ReminderFilter filter);
    void triggerReminder(String id);
}

Saga Pattern for Distributed Transactions

For reminder creation that involves external systems (e.g., notification service):

public class ReminderCreationSaga {
    private final String sagaId = UUID.randomUUID().toString();
    
    public void execute(ReminderRequest request) {
        try {
            // Step 1: Create reminder in DB
            Reminder reminder = createReminderInDB(request);
            
            // Step 2: Send notification (if external system)
            sendNotification(reminder);
            
            // Step 3: Update status in DB
            updateReminderStatus(reminder.getId(), Status.ACTIVE);
            
            // Step 4: Schedule processing
            scheduleProcessing(reminder.getId());
            
        } catch (Exception e) {
            // Compensation logic - rollback steps
            handleCompensation(e);
        }
    }
}

Domain Object Design

@Builder
@Value
public class Reminder {
    String id;
    String userId;
    String title;
    String description;
    Instant scheduledTime;
    Status status;
    Instant createdAt;
    Instant updatedAt;
    
    public boolean isOverdue() {
        return status == Status.ACTIVE && 
               scheduledTime.isBefore(Instant.now());
    }
    
    public boolean isActive() {
        return status == Status.ACTIVE;
    }
    
    public enum Status {
        PENDING, ACTIVE, COMPLETED, CANCELLED, OVERDUE
    }
    
    public enum EventType {
        CREATED, UPDATED, DELETED, TRIGGERED, CANCELLED
    }
}

Thread Safety Considerations

  1. State transitions: Use atomic updates and optimistic locking
  2. Concurrent creation: UUID primary keys prevent conflicts
  3. Batch operations: Isolated transaction boundaries per batch item
  4. Event publishing: Thread-safe event bus with proper ordering guarantees

Idempotency Implementation

public Reminder createReminder(ReminderRequest request, String idempotencyKey) {
    // Check if this operation was already processed
    String cacheKey = "reminder_idempotency:" + idempotencyKey;
    
    String existingId = cache.get(cacheKey);
    if (existingId != null) {
        return repository.findById(existingId).orElseThrow();
    }
    
    // Proceed with creation...
    Reminder reminder = repository.save(reminder);
    
    // Cache the result
    cache.put(cacheKey, reminder.getId(), Duration.ofHours(1));
    
    return reminder;
}

5. Concurrency & Thread Safety

Concurrency Model

The Reminder service will use a thread pool-based concurrency model with Java's ExecutorService and CompletableFuture for asynchronous operations. Each reminder processing task will be executed in a separate thread from a bounded thread pool to prevent resource exhaustion. For high-concurrency scenarios, the service will leverage reactive programming patterns using Project Reactor or RxJava to handle thousands of concurrent reminder triggers efficiently.

Race Condition Prevention Strategy

Race conditions in the reminder service will be mitigated through:

  1. Database-level atomic operations for state transitions and updates
  2. Optimistic locking with version fields in reminder entities to prevent concurrent modifications
  3. Database transactions for multi-step operations that must remain consistent
  4. Idempotency checks to prevent duplicate processing of the same reminder event

Optimistic vs. Pessimistic Locking Selection

Optimistic locking is selected for the reminder service because:

  • Reminder creation and updates are infrequent relative to read operations
  • The system expects low contention for reminder entities
  • It provides better scalability and avoids blocking threads
  • Reminder state changes are typically independent of each other

Implementation example:

@Entity
@Table(name = "reminders")
public class Reminder {
    @Id
    private UUID id;
    
    @Version
    private Long version; // Optimistic locking field
    
    private String status;
    private LocalDateTime triggerTime;
    
    // getters and setters
}

Deadlock Prevention Strategy

Deadlock prevention will be implemented through:

  1. Consistent lock ordering - all operations acquire locks in the same order (e.g., by reminder ID)
  2. Timeout-based lock acquisition - using tryLock() with timeout to prevent indefinite blocking
  3. Avoid nested locking - minimizing the scope of locks and avoiding complex lock hierarchies
  4. Thread pool size limiting - controlling concurrent operations to reduce contention

Idempotency Implementation for Concurrent Duplicate Requests

Idempotency will be handled through:

  1. Idempotency keys in request headers for external API calls
  2. Database uniqueness constraints on reminder creation parameters (user_id + trigger_time + type)
  3. In-memory deduplication cache with TTL for recent reminder triggers
  4. Event-based idempotency tracking using a deduplication table
@Service
public class ReminderService {
    private final Map<String, Instant> deduplicationCache = new ConcurrentHashMap<>();
    
    public void processReminder(String idempotencyKey, ReminderTrigger trigger) {
        String cacheKey = "reminder:" + idempotencyKey;
        Instant now = Instant.now();
        
        // Check if this exact trigger was processed recently
        if (deduplicationCache.putIfAbsent(cacheKey, now) != null) {
            // Already processed - return early
            return;
        }
        
        // Process the reminder (with proper transaction handling)
        processReminderInternal(trigger);
        
        // Remove from cache after TTL to allow reprocessing
        ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
        scheduler.schedule(() -> deduplicationCache.remove(cacheKey), 5, TimeUnit.MINUTES);
    }
}

6. Sequence Diagrams for Key Flows

sequenceDiagram
    participant U as User
    participant API as ReminderServiceAPI
    participant Auth as AuthMiddleware
    participant Service as ReminderService
    participant DB as Database
    participant Scheduler as ReminderScheduler
    participant Notifier as NotificationService

    U->>API: CreateReminderRequest
    API->>Auth: Validate JWT
    Auth-->>API: Authenticated
    API->>Service: createReminder(request)
    Service->>DB: save(reminder)
    DB-->>Service: reminderId
    Service->>Scheduler: schedule(reminder)
    Scheduler-->>Service: scheduled
    Service-->>API: ReminderResponse
    API-->>U: Created 201
sequenceDiagram
    participant U as User
    participant API as ReminderServiceAPI
    participant Auth as AuthMiddleware
    participant Service as ReminderService
    participant DB as Database
    participant Notifier as NotificationService

    U->>API: GetRemindersRequest
    API->>Auth: Validate JWT
    Auth-->>API: Authenticated
    API->>Service: getReminders(userId, filter)
    Service->>DB: findReminders(userId, filter)
    DB-->>Service: List<Reminder>
    Service-->>API: ReminderListResponse
    API-->>U: OK 200
sequenceDiagram
    participant Scheduler as ReminderScheduler
    participant Service as ReminderService
    participant DB as Database
    participant Notifier as NotificationService
    participant EventPublisher as EventPublisher

    Scheduler->>Service: triggerReminder(reminderId)
    Service->>DB: findById(reminderId)
    DB-->>Service: Reminder
    Service->>Notifier: sendNotification(reminder)
    Notifier-->>Service: NotificationSent
    Service->>DB: update(status=notified)
    DB-->>Service: Updated
    Service->>EventPublisher: publish(reminderNotified)
    EventPublisher-->>Service: EventPublished

7. Caching Design

Cache Strategy Selection

For the Reminder service, we'll use a cache-aside pattern with LRU eviction policy. This approach provides the best balance between performance and consistency for a reminder system where:

  • Read-heavy workloads (users frequently checking reminders)
  • Data freshness is important but not mission-critical
  • Cache misses are acceptable for performance reasons

Cache Key Design

Cache keys will follow a structured namespace pattern:

reminder:{userId}:{reminderId}
reminder:byTime:{timeBucket}:{userId}
reminder:byStatus:{status}:{userId}

The namespace strategy provides:

  • Clear separation of concerns
  • Efficient querying by time ranges and status
  • Easy cache invalidation by user or status
  • Support for cache warming strategies

TTL Strategy

Data TypeTTLReason
User reminders30 minutesFrequent access, reasonable freshness
Reminder by time bucket1 hourTime-based queries benefit from longer TTL
Reminder status cache15 minutesStatus changes should be visible quickly
User preferences24 hoursStable user settings don't change frequently

Cache Eviction Policy

We'll implement LRU (Least Recently Used) eviction policy with:

  • Soft TTL: 90% of configured TTL for automatic expiration
  • Hard TTL: Full configured TTL for forced cleanup
  • Cache warming: Pre-populate frequently accessed time buckets during startup

Cache Stampede Prevention

To prevent cache stampedes:

  1. Probabilistic early expiry: Set cache TTL to 75% of configured value for primary keys
  2. Distributed locks: Use Redis distributed locks when cache misses occur
  3. Cache warming: Pre-populate hot keys during system startup and maintenance windows

Distributed Cache Architecture

We'll use Redis Cluster with:

  • 3 master nodes, 3 replica nodes for high availability
  • 200MB memory limit per node with LRU eviction policy
  • Redis Sentinel for automatic failover monitoring
  • Cross-zone replication for geographic distribution

Cache Invalidation Strategy

We'll implement event-driven invalidation:

  1. Write-through: Cache is updated on create/update/delete operations
  2. Event-driven invalidation: Cache entries invalidated via Redis Pub/Sub when data changes
  3. Time-based invalidation: TTL-based cleanup for stale entries
@Component
public class ReminderCacheService {
    private final RedisTemplate<String, Reminder> redisTemplate;
    private final ObjectMapper objectMapper;
    
    public void invalidateReminderCache(String userId, String reminderId) {
        String key = "reminder:" + userId + ":" + reminderId;
        redisTemplate.delete(key);
        
        // Invalidate related caches
        String timeBucketKey = "reminder:byTime:" + getTimeBucket() + ":" + userId;
        redisTemplate.delete(timeBucketKey);
        
        String statusKey = "reminder:byStatus:" + getStatus() + ":" + userId;
        redisTemplate.delete(statusKey);
    }
    
    public Reminder getCachedReminder(String userId, String reminderId) {
        String key = "reminder:" + userId + ":" + reminderId;
        return redisTemplate.opsForValue().get(key);
    }
    
    public void cacheReminder(String userId, String reminderId, Reminder reminder) {
        String key = "reminder:" + userId + ":" + reminderId;
        redisTemplate.opsForValue().set(key, reminder, 30, TimeUnit.MINUTES);
    }
}

8. Repository / Data Access Layer

Repository Pattern Implementation

The repository pattern is implemented to provide a clean abstraction over data access operations, separating domain logic from persistence concerns. Each entity has its own repository interface and implementation.

public interface ReminderRepository {
    Reminder save(Reminder reminder);
    Optional<Reminder> findById(UUID id);
    List<Reminder> findByUserIdAndStatus(UUID userId, ReminderStatus status);
    Page<Reminder> findByUserId(UUID userId, Pageable pageable);
    void deleteById(UUID id);
    void updateStatus(UUID id, ReminderStatus status);
}

@Repository
public class JpaReminderRepository implements ReminderRepository {
    private final EntityManager entityManager;
    
    public JpaReminderRepository(EntityManager entityManager) {
        this.entityManager = entityManager;
    }
    
    @Override
    public Reminder save(Reminder reminder) {
        if (reminder.getId() == null) {
            entityManager.persist(reminder);
        } else {
            entityManager.merge(reminder);
        }
        return reminder;
    }
    
    @Override
    public Optional<Reminder> findById(UUID id) {
        return Optional.ofNullable(entityManager.find(Reminder.class, id));
    }
    
    @Override
    public List<Reminder> findByUserIdAndStatus(UUID userId, ReminderStatus status) {
        return entityManager.createQuery(
            "SELECT r FROM Reminder r WHERE r.userId = :userId AND r.status = :status",
            Reminder.class)
            .setParameter("userId", userId)
            .setParameter("status", status)
            .getResultList();
    }
    
    @Override
    public Page<Reminder> findByUserId(UUID userId, Pageable pageable) {
        TypedQuery<Reminder> query = entityManager.createQuery(
            "SELECT r FROM Reminder r WHERE r.userId = :userId ORDER BY r.createdAt DESC",
            Reminder.class)
            .setParameter("userId", userId);
        
        long total = entityManager.createQuery(
            "SELECT COUNT(r) FROM Reminder r WHERE r.userId = :userId",
            Long.class)
            .setParameter("userId", userId)
            .getSingleResult();
            
        query.setFirstResult(pageable.getOffset());
        query.setMaxResults(pageable.getPageSize());
        
        return new PageImpl<>(query.getResultList(), pageable, total);
    }
    
    @Override
    public void deleteById(UUID id) {
        entityManager.createQuery("DELETE FROM Reminder r WHERE r.id = :id")
            .setParameter("id", id)
            .executeUpdate();
    }
    
    @Override
    public void updateStatus(UUID id, ReminderStatus status) {
        entityManager.createQuery(
            "UPDATE Reminder r SET r.status = :status WHERE r.id = :id")
            .setParameter("id", id)
            .setParameter("status", status)
            .executeUpdate();
    }
}

ORM vs Raw Query Decisions

For this service, we use JPA/Hibernate as the ORM layer because:

  1. It provides type safety and reduces boilerplate code
  2. Supports complex queries with JPQL and Criteria API
  3. Handles object-relational mapping efficiently
  4. Offers lazy loading and caching capabilities

Raw queries are used only for performance-critical operations like bulk updates or analytics queries where we need fine-grained control.

Read vs Write Separation

We implement read-write separation using CQRS pattern for better scalability:

public interface ReminderReadRepository {
    List<ReminderProjection> findActiveRemindersForUser(UUID userId);
    ReminderProjection findById(UUID id);
}

@Repository
public class JpaReminderReadRepository implements ReminderReadRepository {
    private final EntityManager entityManager;
    
    public JpaReminderReadRepository(EntityManager entityManager) {
        this.entityManager = entityManager;
    }
    
    @Override
    public List<ReminderProjection> findActiveRemindersForUser(UUID userId) {
        return entityManager.createQuery(
            "SELECT new com.example.reminder.projection.ReminderProjection(" +
            "r.id, r.title, r.scheduledTime, r.status) " +
            "FROM Reminder r WHERE r.userId = :userId AND r.status = 'ACTIVE'",
            ReminderProjection.class)
            .setParameter("userId", userId)
            .getResultList();
    }
    
    @Override
    public ReminderProjection findById(UUID id) {
        return entityManager.createQuery(
            "SELECT new com.example.reminder.projection.ReminderProjection(" +
            "r.id, r.title, r.scheduledTime, r.status) " +
            "FROM Reminder r WHERE r.id = :id",
            ReminderProjection.class)
            .setParameter("id", id)
            .getSingleResult();
    }
}

Connection Pool Configuration

Connection pool is configured with HikariCP for optimal performance:

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000
      leak-detection-threshold: 60000

N+1 Query Prevention Strategy

To prevent N+1 queries, we use eager loading with JOIN FETCH and projection-based queries:

// Instead of separate queries for each reminder's user, we fetch with JOIN
@Query("SELECT r FROM Reminder r JOIN FETCH r.user WHERE r.userId = :userId")
List<Reminder> findWithUser(@Param("userId") UUID userId);

// For projections to avoid loading full entities
@Query("SELECT new com.example.reminder.dto.ReminderSummary(r.id, r.title, r.scheduledTime) " +
       "FROM Reminder r WHERE r.userId = :userId AND r.status = 'ACTIVE'")
List<ReminderSummary> findActiveRemindersWithSummary(@Param("userId") UUID userId);

Batch Operation Design

Batch operations are implemented for bulk processing:

@Repository
public class BatchReminderRepository {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    public void batchUpdateStatus(List<UUID> reminderIds, ReminderStatus status) {
        if (reminderIds.isEmpty()) return;
        
        String jpql = "UPDATE Reminder r SET r.status = :status WHERE r.id IN :ids";
        Query query = entityManager.createQuery(jpql);
        query.setParameter("status", status);
        query.setParameter("ids", reminderIds);
        
        // Process in batches of 1000 to avoid memory issues
        int batchSize = 1000;
        for (int i = 0; i < reminderIds.size(); i += batchSize) {
            int end = Math.min(i + batchSize, reminderIds.size());
            List<UUID> batch = reminderIds.subList(i, end);
            
            query.setParameter("ids", batch);
            query.executeUpdate();
        }
    }
    
    public void batchDelete(List<UUID> reminderIds) {
        if (reminderIds.isEmpty()) return;
        
        String jpql = "DELETE FROM Reminder r WHERE r.id IN :ids";
        Query query = entityManager.createQuery(jpql);
        query.setParameter("ids", reminderIds);
        
        // Process in batches to avoid transaction size limits
        int batchSize = 1000;
        for (int i = 0; i < reminderIds.size(); i += batchSize) {
            int end = Math.min(i + batchSize, reminderIds.size());
            List<UUID> batch = reminderIds.subList(i, end);
            
            query.setParameter("ids", batch);
            query.executeUpdate();
        }
    }
}

9. Asynchronous Processing & Messaging

Event-driven boundaries in this feature

The Reminder service will use a hybrid synchronous/asynchronous approach to handle various operations:

  1. Synchronous flows:

    • Reminder creation and immediate notification (e.g., SMS/email)
    • Reminder status updates (e.g., completed, cancelled)
    • Immediate validation and conflict detection
  2. Asynchronous flows:

    • Scheduled reminder delivery (e.g., push notifications, emails)
    • Reminder retry logic for failed deliveries
    • Audit trail and analytics updates
    • Cross-service coordination (e.g., integration with calendar services)

The decision to use asynchronous processing for delivery and retries stems from the need to handle potentially long-running operations without blocking the main request path, ensuring system responsiveness and resilience.

Message Schema Design

{
  "version": "1.0",
  "event_type": "reminder.scheduled",
  "timestamp": "2023-10-27T14:30:00Z",
  "payload": {
    "reminder_id": "uuid-12345",
    "user_id": "user-98765",
    "notification_type": "email",
    "scheduled_time": "2023-10-27T15:00:00Z",
    "message_content": "Your meeting is scheduled for 3 PM today.",
    "recipient_email": "user@example.com"
  },
  "source": "reminder-service",
  "correlation_id": "corr-abc123"
}

Dead Letter Queue Strategy and Poison Message Handling

The system will implement a DLQ strategy with:

  1. Max retry attempts: 3 retries with exponential backoff
  2. Dead letter queue: Messages that fail after max retries are moved to a DLQ topic
  3. Poison message handling: Messages in DLQ will trigger alerts and manual inspection
public class ReminderDeliveryConsumer {
    private static final int MAX_RETRY_ATTEMPTS = 3;
    
    public void processReminderDelivery(ReminderEvent event) {
        int attempt = 0;
        while (attempt < MAX_RETRY_ATTEMPTS) {
            try {
                // Attempt delivery
                deliverNotification(event);
                return;
            } catch (Exception e) {
                attempt++;
                if (attempt >= MAX_RETRY_ATTEMPTS) {
                    // Move to DLQ
                    moveMessageToDLQ(event);
                    throw new RuntimeException("Failed after max retries", e);
                }
                // Exponential backoff
                Thread.sleep(1000 * Math.pow(2, attempt));
            }
        }
    }
}

At-least-once vs. Exactly-once Delivery

Decision: At-least-once delivery model with idempotency handling.

Justification:

  • Reminder service needs to ensure notifications are delivered at least once
  • Duplicate deliveries can be safely handled with idempotency keys
  • The cost of exactly-once (complex coordination) outweighs the benefit for this use case

Retry Policy for Consumers

public class ReminderRetryPolicy {
    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 exception) {
        // Retry on transient failures
        return attempt < MAX_RETRIES && 
               (exception instanceof NetworkException || 
                exception instanceof ServiceUnavailableException);
    }
    
    public long getDelay(int attempt) {
        return (long) (INITIAL_DELAY_MS * Math.pow(BACKOFF_MULTIPLIER, attempt));
    }
}

Outbox Pattern for Reliable Event Publishing

The outbox pattern will be implemented to ensure reliable event publishing:

public class ReminderEventPublisher {
    private final EventRepository eventRepository;
    private final MessageProducer messageProducer;
    
    public void publishReminderEvent(Reminder reminder, String eventType) {
        // 1. Create event in outbox table
        ReminderEvent event = new ReminderEvent(
            UUID.randomUUID(),
            reminder.getId(),
            eventType,
            "reminder-service",
            new Date()
        );
        
        // 2. Save to outbox (within same transaction)
        eventRepository.save(event);
        
        // 3. Publish to message broker (in separate transaction)
        messageProducer.publish(event);
    }
}

Sequence Diagram for Async Flows

sequenceDiagram
    participant Client
    participant ReminderService
    participant EventPublisher
    participant MessageBroker
    participant NotificationService
    
    Client->>ReminderService: Create Reminder (POST /reminders)
    ReminderService->>EventPublisher: Store reminder event in outbox
    EventPublisher->>MessageBroker: Publish reminder.scheduled event
    MessageBroker->>NotificationService: Deliver to consumer queue
    NotificationService->>NotificationService: Process delivery attempt
    NotificationService->>NotificationService: Retry on failure (max 3 attempts)
    NotificationService->>NotificationService: Move to DLQ if exhausted

10. Error Handling Design

Error Classification Hierarchy

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

Error
├── ValidationException (400)
│   ├── InvalidInputException
│   └── ConstraintViolationException
├── DomainException (400/409)
│   ├── ReminderAlreadyExistsException
│   ├── ReminderNotFoundException
│   └── InvalidReminderStateException
├── InfrastructureException (500)
│   ├── DatabaseException
│   ├── ExternalServiceException
│   └── CacheException
└── AuthorizationException (401/403)
    ├── UnauthorizedAccessException
    └── PermissionDeniedException

Error Propagation Strategy

  • Validation errors are caught at the API boundary and converted to structured 400 responses
  • Domain errors are propagated up to the service layer and converted to appropriate HTTP status codes (400/409)
  • Infrastructure errors are either retried or escalated to higher layers for circuit breaker handling
  • Authorization errors are immediately converted to 401/403 responses

Retry Strategy with Exponential Backoff and Jitter

For infrastructure-level failures (e.g., database timeouts, external service unavailability):

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

    public <T> T executeWithRetry(Supplier<T> operation) {
        Exception lastException = null;
        
        for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
            try {
                return operation.get();
            } catch (InfrastructureException e) {
                lastException = e;
                if (attempt >= MAX_RETRIES) {
                    throw new RetryFailedException("Operation failed after " + MAX_RETRIES + " attempts", e);
                }
                
                long delay = (long) (BASE_DELAY_MS * Math.pow(2, attempt) + 
                                   (Math.random() - 0.5) * BASE_DELAY_MS * JITTER_RANGE);
                try {
                    Thread.sleep(delay);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    throw new RetryFailedException("Retry interrupted", ie);
                }
            }
        }
        
        throw new RetryFailedException("Operation failed after "  + MAX_RETRIES + " attempts", lastException);
    }
}

Circuit Breaker Design

For external service dependencies (e.g., notification service):

public class CircuitBreaker {
    private final int failureThreshold = 5;
    private final long timeoutMs = 60000; // 1 minute
    private final int successThreshold = 3;
    
    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();
            onSuccess();
            return result;
        } catch (Exception e) {
            onFailure(e);
            throw e;
        }
    }
    
    private <T> T executeOpen(Supplier<T> operation) {
        if (System.currentTimeMillis() - lastFailureTime > timeoutMs) {
            state = CircuitState.HALF_OPEN;
            return executeHalfOpen(operation);
        }
        throw new CircuitBreakerOpenException("Circuit is open");
    }
    
    private <T> T executeHalfOpen(Supplier<T> operation) {
        try {
            T result = operation.get();
            state = CircuitState.CLOSED;
            failureCount = 0;
            return result;
        } catch (Exception e) {
            state = CircuitState.OPEN;
            lastFailureTime = System.currentTimeMillis();
            throw new CircuitBreakerOpenException("Circuit re-opened due to failure", e);
        }
    }
    
    private void onSuccess() {
        if (state == CircuitState.HALF_OPEN) {
            failureCount = 0;
        }
    }
    
    private void onFailure(Exception e) {
        if (state == CircuitState.CLOSED) {
            failureCount++;
            lastFailureTime = System.currentTimeMillis();
            if (failureCount >= failureThreshold) {
                state = CircuitState.OPEN;
            }
        } else if (state == CircuitState.HALF_OPEN) {
            state = CircuitState.OPEN;
        }
    }
}

Fallback Behavior Design

For critical dependencies like notification service:

public class NotificationFallback {
    private final NotificationService primaryService;
    private final NotificationService backupService;
    
    public void sendNotificationWithFallback(NotificationRequest request) {
        try {
            primaryService.send(request);
        } catch (InfrastructureException e) {
            // Log the failure but don't let it break the main flow
            logger.warn("Primary notification service failed, using fallback", e);
            try {
                backupService.send(request);
            } catch (Exception fallbackException) {
                logger.error("Both primary and backup notification services failed", 
                           fallbackException);
                // Consider alerting or storing for manual follow-up
            }
        }
    }
}

Error Logging Format

Structured logging with trace context:

{
  "timestamp": "2023-10-27T14:30:45.123Z",
  "trace_id": "12345678-1234-1234-1234-123456789012",
  "span_id": "abcdef12-3456-7890-abcd-ef1234567890",
  "user_id": "user_12345",
  "entity_id": "reminder_abcde",
  "error_code": "REMINDER_NOT_FOUND",
  "error_message": "Reminder with ID abcde not found",
  "error_type": "DomainException",
  "stack_trace": "...",
  "http_status_code": 404,
  "request_path": "/api/reminders/abcde",
  "request_method": "GET"
}

Alerting Thresholds

Critical alerts are triggered when:

  • Database connection failures exceed 5 in 5 minutes
  • External service timeouts exceed 10% of requests for 1 minute
  • Circuit breaker opens more than 3 times in 5 minutes
  • Retry failures exceed 10% of operations for 1 minute

Alerts are sent to:

  • On-call engineer via Slack
  • Operations dashboard with severity levels (critical/warning)
  • Email to engineering team for critical issues

11. Security Design

Input Validation and Sanitization Strategy

All API inputs are validated using a combination of:

  • Bean Validation (JSR-303) for structural validation
  • Custom validators for business logic constraints (e.g., valid time formats, recurrence rules)
  • HTML sanitization using OWASP Java HTML Sanitizer for any user-generated content fields
public class ReminderRequest {
    @NotBlank
    @Size(max = 255)
    private String title;
    
    @NotNull
    @Future
    private LocalDateTime triggerTime;
    
    @ValidRecurrenceRule
    private String recurrenceRule;
}

SQL Injection Prevention

  • Prepared statements for all database queries using JDBC Template or JPA
  • Parameterized queries - never concatenating user input into SQL strings
  • ORM usage with proper entity mapping to prevent raw SQL injection

Authentication Middleware Design

  • JWT-based authentication with HS256 signing algorithm
  • OAuth2 integration for third-party identity providers (Google, Microsoft)
  • Authentication filter that validates JWT tokens and extracts user context
  • Token refresh mechanism with short-lived access tokens (15 min) and long-lived refresh tokens (7 days)

Authorization Enforcement

  • Role-Based Access Control (RBAC) with user roles: USER, ADMIN
  • Resource-level authorization using Spring Security annotations:
@PreAuthorize("@reminderAuthorizationService.canAccessReminder(principal, #reminderId)")
public void updateReminder(String reminderId, ReminderUpdateRequest request) { }
  • Permission checking at service layer for reminder ownership and modification rights

Sensitive Data Encryption

  • At rest: AES-256 encryption for sensitive fields (reminder content, user preferences)
  • In transit: TLS 1.3 with HSTS enforcement for all API endpoints
  • Key management: AWS KMS integration for key rotation and secure storage

PII Handling and Masking Strategy

  • PII fields: User email, reminder content (masked in logs)
  • Masking rules: Email addresses shown as user***@domain.com, content truncated to first 50 chars in logs
  • Data retention: PII deleted after 3 years of inactivity

Secrets Management

  • AWS Secrets Manager integration for storing:
    • Database credentials (rotated every 90 days)
    • Third-party API keys (Google Calendar, Slack)
    • JWT signing keys
  • Dynamic secret rotation with automated refresh mechanisms

Audit Logging Design

  • Logged events: Reminder creation, modification, deletion, trigger execution
  • Log format:
{
  "timestamp": "2023-12-01T10:30:00Z",
  "userId": "user-uuid-123",
  "action": "CREATE_REMINDER",
  "resourceId": "reminder-uuid-456",
  "details": {
    "title": "Meeting",
    "triggerTime": "2023-12-01T14:00:00Z"
  }
}
  • Retention policy: 90 days for audit logs, archived to S3 for compliance

Security Headers and CORS Configuration

  • Security headers:
    • X-Content-Type-Options: nosniff
    • X-Frame-Options: DENY
    • X-XSS-Protection: 1; mode=block
    • Content-Security-Policy: default-src 'self'
  • CORS policy: Strict whitelist of allowed origins with credentials support

Rate Limiting and DDoS Protection

  • API rate limiting using Redis-based token bucket algorithm (1000 req/hour per user)
  • Request size limits (max 10KB per request)
  • IP-based blocking for suspicious traffic patterns
  • Bot detection using behavioral analysis and CAPTCHA integration

Vulnerability Scanning and Dependency Management

  • Static code analysis using SonarQube for security issues
  • Dependency scanning with OWASP DependencyCheck for vulnerable libraries
  • Automated security patching for runtime dependencies

12. Testing Strategy

Unit Testing Scope and Coverage Targets (%)

  • Service Layer: 85% code coverage with focus on business logic, error handling, and boundary conditions
  • Repository Layer: 90% coverage with emphasis on query correctness and edge case handling
  • Domain Objects: 100% coverage for value objects, enums, and immutable entities
  • Exception Handling: 100% coverage for all custom exception paths
  • Integration Points: 95% coverage for external dependency mocks and stubs

Integration Testing Strategy

  • Database Integration: Use Testcontainers with PostgreSQL 13+ for end-to-end database tests
  • External API Integration: WireMock or embedded mock servers for third-party service dependencies (e.g., email/SMS providers)
  • Event System: Embedded Kafka or RabbitMQ containers for async flow validation
  • Caching Layer: In-memory Redis mock for cache interaction testing

Contract Testing

  • API Contracts: Use Pact for consumer-driven contract testing between reminder service and client applications
  • Event Contracts: Define Avro schemas with schema registry integration for event contract validation
  • External Service Contracts: Mock external service contracts using WireMock or similar tools

Performance Testing Tool and Key Scenarios

  • Tool: k6 (JavaScript-based) for load testing, JMeter for stress testing
  • Key Scenarios:
    • Concurrent reminder creation (1000+ simultaneous requests)
    • High-volume reminder retrieval (batch queries with pagination)
    • Scheduled reminder processing under load (1000+ reminders per minute)
    • Cache warm-up and hit ratio validation
  • Metrics Tracked:
    • p95/p99 response times (target: <200ms for CRUD operations)
    • Throughput (target: 5000 RPS for read-heavy operations)
    • Error rates (target: <0.1% failure rate)

Test Data Management Strategy

  • Fixtures: JSON/YAML fixtures for common test data (users, reminders, categories)
  • Factories: Factory pattern with Faker.js for generating realistic test data
  • Seeding: Database seed scripts for initial test environment setup
  • Isolation: Per-test database transactions with rollback strategy to ensure test isolation
  • Data Generation: Use of data generation libraries for large-scale synthetic data (e.g., 100K+ reminder records for performance testing)

13. Observability Design

Structured Logging Schema

All logs must follow a consistent schema to enable effective querying and analysis:

{
  "timestamp": "2023-10-15T14:30:45.123Z",
  "level": "INFO|WARN|ERROR",
  "service": "reminder-service",
  "trace_id": "1234567890abcdef1234567890abcdef",
  "span_id": "abcdef1234567890abcdef1234567890",
  "user_id": "user-123",
  "entity_id": "reminder-456",
  "operation": "create_reminder",
  "message": "Reminder created successfully",
  "error_code": "REMINDER_001",
  "duration_ms": 45,
  "error_details": {
    "stack_trace": "...",
    "cause": "Invalid reminder time"
  }
}

PII masking rules:

  • User IDs are logged as hashed values (e.g., user-123 → user-abc123)
  • Email addresses are masked (e.g., john@example.com → j***@example.com)
  • Full names are truncated to first initial (e.g., John Doe → J*** ***)

Distributed Tracing Instrumentation

Using OpenTelemetry for end-to-end tracing:

Custom Spans to Add:

  1. reminder.create - from API entry point to DB write
  2. reminder.process - background task execution
  3. reminder.send_notification - external notification service call
  4. reminder.cleanup - periodic cleanup job

Attributes to Capture:

  • reminder.id: reminder identifier
  • reminder.user_id: user who owns the reminder
  • reminder.type: email, sms, push
  • reminder.scheduled_time: when reminder should fire
  • reminder.status: pending, sent, failed

Metrics Design

RED Method Metrics:

Metric NameTypeDescriptionLabels
reminder_creation_countCounterTotal reminders createduser_id, reminder_type
reminder_creation_durationHistogramTime to create reminderuser_id, reminder_type
reminder_send_errorsCounterFailed notification attemptsuser_id, notification_type
reminder_send_durationHistogramTime to send notificationuser_id, notification_type
reminder_processing_errorsCounterFailed reminder processingerror_code
reminder_queue_sizeGaugeCurrent queue sizequeue_name

Additional Service-Level Metrics:

  • active_reminders (Gauge): Count of active reminders
  • notification_success_rate (Gauge): % successful notifications
  • queue_processing_rate (Counter): Rate of queue processing

Health Check Endpoint Design

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

Response Format:

{
  "status": "healthy|degraded|unhealthy",
  "checks": {
    "database": {"status": "healthy", "details": "Connected to primary DB"},
    "cache": {"status": "healthy", "details": "Redis connected"},
    "message_queue": {"status": "degraded", "details": "5% of messages delayed"},
    "dependencies": {"status": "healthy", "details": "All external services reachable"}
  },
  "timestamp": "2023-10-15T14:30:45.123Z"
}

Health Check Logic:

  • /live: Basic service liveness (port listening)
  • /ready: Service readiness (DB, cache, queue connectivity)
  • /health: Full health including external dependencies
  • /startup: Service startup validation (config loaded, DB migration completed)

Alerting Thresholds

Critical Alerts:

  • reminder_creation_errors > 100 errors/minute → Alert
  • reminder_send_errors > 50 errors/minute → Alert
  • queue_processing_rate < 10 items/sec for 5 min → Alert
  • database_connection_errors > 5 errors/minute → Alert

Warning Alerts:

  • reminder_creation_duration p95 > 1000ms → Alert
  • reminder_send_duration p95 > 2000ms → Alert
  • queue_size > 10000 items → Alert

On-call Escalation Path:

  1. Primary engineer (5 min)
  2. Secondary engineer (30 min)
  3. Team lead (1 hour)
  4. SRE team (2 hours)

Monitoring Dashboard

Key dashboard panels:

  1. Service Health Overview: Status of all health checks
  2. Reminder Creation Rate: Throughput over time
  3. Notification Delivery Rate: Success/failure ratio
  4. Queue Metrics: Size, processing rate, latency
  5. Error Rate by Type: Distribution of error types
  6. Latency Distribution: p50/p95/p99 response times

14. Open Questions, Risks & Trade-offs

Decisions requiring product / engineering alignment

  • Notification channel prioritization: Should SMS, email, and push notifications be treated as equal priority channels or should we implement a channel selection strategy based on user preference, device availability, or delivery success rates?
  • Reminder recurrence patterns: How granular should recurrence support be? Should we support simple daily/weekly/monthly patterns or complex cron-like expressions that require more sophisticated parsing and validation?
  • User preference management: How should user preferences for reminder timing (e.g., "remind me 15 minutes before") be stored and managed? Should these be global defaults or per-reminder overrides?
  • Time zone handling: Should all reminders be stored in UTC and converted to user's local time zone at delivery, or should we store the original user timezone and handle conversions dynamically?

Known trade-offs and justifications

  • Eventual consistency vs. strong consistency: We're leaning towards eventual consistency for reminder delivery to improve system scalability and availability, but this introduces a risk of reminders being delivered slightly late or not at all if the system is under heavy load. This trade-off is acceptable for most reminder use cases where slight delays are tolerable.
  • Caching vs. real-time data: We're using cache-aside pattern with TTLs to reduce database load, but this introduces potential staleness. The trade-off is acceptable since reminder systems are generally not mission-critical and can tolerate a few seconds of delay in data freshness.
  • Monolithic design vs. microservices: We're keeping the reminder service as a monolithic component rather than splitting into separate services for notification, scheduling, and user preferences. This simplifies deployment and reduces inter-service communication overhead but increases system complexity in the long term.
  • Idempotency vs. performance: We're implementing idempotency keys for all mutation endpoints to handle duplicate requests, but this adds a small performance overhead due to additional database lookups and state tracking.

Risks and mitigation strategies

  • Delivery failure risk: Reminders might not be delivered due to system outages, network issues, or user device unavailability. Mitigation includes implementing retry logic with exponential backoff and dead-letter queue handling for failed deliveries.
  • Clock drift risk: System clock drift between services or time zone changes could cause reminders to fire at wrong times. Mitigation includes using NTP for clock synchronization and storing timestamps in UTC with proper timezone handling.
  • Resource exhaustion risk: High-volume reminder systems could exhaust database connections, memory, or CPU resources. Mitigation includes implementing proper connection pooling, query optimization, and circuit breaker patterns.
  • Data loss risk: Reminder data could be lost due to database failures or improper cleanup. Mitigation includes implementing proper backup strategies, soft deletes with audit trails, and regular data validation checks.

Technical debt acknowledged

  • Limited extensibility: The current design assumes a fixed set of reminder types and notification channels, which makes future feature additions more complex without refactoring. This is acceptable for MVP but should be addressed in future iterations.
  • Hardcoded business logic: Reminder scheduling and notification logic is currently tightly coupled within the service layer, which makes it harder to extend or modify without affecting core functionality.
  • Limited multi-tenancy support: The current design assumes single-tenant operation and doesn't handle cross-tenant reminder conflicts or permissions properly. This is acceptable for initial implementation but needs enhancement for enterprise use cases.
  • Basic error handling: The current error handling is minimal and doesn't include comprehensive error categorization or detailed logging for debugging complex edge cases. This should be enhanced to support better observability and troubleshooting in production environments.

Was this article helpful?

Topics in this article

More from Backend LLD

  • Backend LLD

    Job Retry Scheduler Design and Implementation

    Comprehensive low-level design for a robust job retry scheduling system with exponential backoff, circuit breaker patterns, and distributed coordination.

  • 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.