Skip to main content

Cache Module

The cache module provides a pluggable caching layer that sits in front of Supabase database queries. When a service fetches data from Supabase, the result is cached for a configurable period. Subsequent requests for the same data are served from the cache instead of hitting Supabase again. On writes (inserts, updates, upserts), the cache is invalidated or updated to keep it consistent with the database.

How It Works

Client Request


Service

├── GET: cache.get(key)
│ ├── Cache HIT → return cached data (no Supabase call)
│ └── Cache MISS → fetch from Supabase → cache.set(key, data, ttl) → return data

├── INSERT / UPSERT: write to Supabase → cache.set(key, newData, ttl)

├── UPDATE (list): write to Supabase → cache.delete(key) or cache.deleteByPrefix(prefix)

└── DELETE: delete from Supabase → cache.delete(key) or cache.deleteByPrefix(prefix)

This pattern ensures:

  • Reads are fast - repeated requests for the same data don't hit Supabase.
  • Writes stay consistent - the cache is invalidated or updated immediately after a successful write to Supabase, so subsequent reads reflect the latest data.
  • TTL provides a safety net - even if a cache update is missed, stale data expires automatically.

Architecture

CacheService (Injectable, Global)
├── implements CacheStore interface
├── delegates to one of:
│ ├── MemoryStore (default, in-process Map)
│ └── RedisStore (requires Redis via ioredis)

The CacheService reads USE_REDIS at construction time. If true, it connects to Redis using REDIS_URL. Otherwise, it falls back to an in-memory Map.

The CacheModule is registered globally via @Global() in AppModule, so all services can inject CacheService without importing the module.

File Structure

backend/src/cache/
├── cache.module.ts # NestJS module (@Global, exports CacheService)
├── cache.service.ts # Main service, selects store at startup
└── stores/
├── index.ts # Barrel exports
├── cache.interface.ts # CacheInterface definition
├── MemoryStore.ts # In-memory implementation
└── RedisStore.ts # Redis implementation (ioredis)

CacheInterface

All stores implement this interface:

MethodSignatureDescription
getget(key: string): Promise<any>Retrieve a cached value by key. Returns null on miss or expiry.
setset(key: string, value: any, ttl: number): Promise<void>Store a value with a TTL in seconds.
deletedelete(key: string): Promise<void>Remove a specific key.
deleteByPrefixdeleteByPrefix(prefix: string): Promise<void>Remove all keys starting with the given prefix.
clearclear(): Promise<void>Remove all cached entries.

Store Implementations

MemoryStore

  • Uses a Map<string, Entry> where Entry contains { value, expires }.
  • expires is Date.now() + (ttl * 1000) at write time (TTL is in seconds, stored as ms).
  • get() checks expiry and auto-evicts stale entries on read.
  • Suitable for development and single-instance deployments.
  • Data is lost on process restart.

RedisStore

  • Uses ioredis to connect to a Redis instance.
  • Values are JSON-serialized on set() and JSON-parsed on get().
  • TTL is set via SET key value EX ttl (seconds).
  • deleteByPrefix() uses SCAN with MATCH to find and delete matching keys without blocking.
  • clear() calls FLUSHDB - use with caution in shared Redis instances.
  • Suitable for production and multi-instance deployments where cache must be shared.

Environment Variables

VariableRequiredDefaultDescription
USE_REDISNofalseSet to true to use Redis instead of in-memory cache
REDIS_URLOnly if USE_REDIS=true-Redis connection URL (e.g., redis://localhost:6379)

Usage Pattern

Since CacheModule is global, any service can inject CacheService directly without importing the module:

import { CacheService } from '@/cache/cache.service';

@Injectable()
export class SomeService {
constructor(private readonly cache: CacheService) {}
}

Cached Read (read-through)

Check the cache before querying Supabase. On a miss, fetch from Supabase and populate the cache.

async getProfile(userId: string) {
const cached = await this.cache.get(`profile:${userId}`);
if (cached) return cached;

const { data } = await supabase
.from('user_profile')
.select('*')
.eq('id', userId)
.single();

if (data) {
await this.cache.set(`profile:${userId}`, data, 300);
}
return data;
}

Cached Write (write-through)

After a successful write to Supabase, update the cache so reads immediately reflect the new data.

async updateProfile(userId: string, dto: UpdateProfileDto) {
const { data } = await supabase
.from('user_profile')
.update({ first_name: dto.firstName })
.eq('id', userId)
.select('*')
.single();

if (data) {
await this.cache.set(`profile:${userId}`, data, 300);
}
return data;
}

Cache Invalidation on Delete

Remove the cache entry when the underlying data is deleted.

async deleteAccount(userId: string) {
await supabase.from('user_profile').delete().eq('id', userId);
await this.cache.delete(`profile:${userId}`);
}

Prefix Invalidation

When cache keys include variable suffixes (e.g., query parameters), use deleteByPrefix to clear all variants at once.

async enrollStudent(classId: string, dto: EnrollStudentDto) {
// ... enroll logic ...
// Clears enrolled:classId:*, covering all userId/subjectId combinations
await this.cache.deleteByPrefix(`enrolled:${classId}`);
}

Cross-Service Invalidation

Some write operations in one service invalidate caches owned by another. For example, saving a grade invalidates computation caches in CalculationService.

// In GradeService
private async invalidateCalcCaches() {
await this.cache.deleteByPrefix('calc:');
}

async bulkCreate(userId: string, dto: BulkGradeDto, token: string) {
// ... save grades ...
await this.invalidateCalcCaches();
}

Cached Services

AuthService

MethodStrategyCache KeyTTL
getProfileRead-throughprofile:{userId}300s
verifyOtpWarm cacheprofile:{userId}300s
onboardWrite-throughprofile:{userId}300s
updateProfileWrite-throughprofile:{userId}300s
deleteAccountInvalidateprofile:{userId}-

ClassService

MethodStrategyCache KeyTTL
getMyClassesRead-throughmy-classes:{userId} or my-classes:{userId}:{yearId}300s
getTeachersRead-throughclass-teachers:{classId}300s
getMySubjectsForClassRead-throughmy-subjects:{userId}:{classId}300s
getSchoolTeachersRead-throughschool-teachers:{schoolId}300s
createClassInvalidatemy-classes:{userId}, my-classes:{userId}:{yearId}-
updateClassInvalidateclass-teachers:{classId}-
deleteClassInvalidateclass-teachers:{classId}-
addTeacherInvalidateclass-teachers:{classId}, my-classes:{teacherId}, my-subjects:{teacherId}:{classId}-
removeTeacherInvalidateclass-teachers:{classId}, my-classes:{teacherId}, my-subjects:{teacherId}:{classId}-

EnrollmentService

MethodStrategyCache KeyTTL
getEnrolledStudentsRead-throughenrolled:{classId}:{userId|all}:{subjectId|all}300s
getStudentSubjectsRead-throughstudent-subjects:{classId}:{studentId}300s
enrollPrefix invalidateenrolled:{classId}*-
bulkEnrollPrefix invalidateenrolled:{classId}*-
unenrollPrefix invalidateenrolled:{classId}*, student-subjects:{classId}:{studentId}-
assignSubjectsPrefix invalidateenrolled:{classId}*, student-subjects:{classId}:{studentId}-
bulkAssignSubjectsPrefix invalidateenrolled:{classId}*, student-subjects:{classId}:{studentId} per student-
removeSubjectPrefix invalidateenrolled:{classId}*, student-subjects:{classId}:{studentId}-

CalculationService

MethodStrategyCache KeyTTL
calculateClassTermResultsRead-throughcalc:class-term:{groupId}:{termId}600s
calculateClassYearResultsRead-throughcalc:class-year:{groupId}:{yearId}600s

Calculation caches are invalidated by GradeService and AssessmentService via deleteByPrefix('calc:').

SchoolService

MethodStrategyCache KeyTTL
findAllRead-throughschools:all600s
createInvalidateschools:all-

SubjectService

MethodStrategyCache KeyTTL
findAllRead-throughsubjects:{schoolId}300s
createInvalidatesubjects:{schoolId}-
updatePrefix invalidatesubjects:*-
deletePrefix invalidatesubjects:*-

StudentService

MethodStrategyCache KeyTTL
findAll (no search)Read-throughstudents:{schoolId}300s
createInvalidatestudents:{schoolId}-
updatePrefix invalidatestudents:*-

Search queries bypass the cache entirely.

AcademicYearService

MethodStrategyCache KeyTTL
findAllRead-throughacademic-years:{schoolId}300s
findActiveRead-throughacademic-year-active:{schoolId}300s
createInvalidateacademic-years:{schoolId}, academic-year-active:{schoolId}-
updatePrefix invalidateacademic-year*-
setActivePrefix invalidateacademic-year*-
deactivatePrefix invalidateacademic-year*-

TermService

MethodStrategyCache KeyTTL
findByYearRead-throughterms:{yearId}300s
createInvalidateterms:{yearId}-
updatePrefix invalidateterms:*-
deletePrefix invalidateterms:*-

GradeService (write-only, no cached reads)

MethodCross-Invalidation
createcalc:*
bulkCreatecalc:*
updatecalc:*
excludecalc:*

AssessmentService (write-only, no cached reads)

MethodCross-Invalidation
createcalc:*
updatecalc:*
excludecalc:*
deletecalc:*

Cache Key Conventions

PatternExampleDescription
entity:{id}profile:abc-123Single record by ID
entity:{scope}subjects:school-456List scoped to a parent
entity:{id}:{qualifier}my-classes:user-1:year-2List scoped to multiple dimensions
entity:{id}:{q1}:{q2}enrolled:class-1:user-2:subj-3List with multiple query params
calc:{type}:{group}:{period}calc:class-term:grp-1:term-2Computed result keyed by inputs

Notes

  • The CacheModule is @Global() and exports CacheService, so any service can inject it without importing the module.
  • The store selection happens once at construction time and cannot be changed at runtime.
  • The MemoryStore auto-evicts expired entries on read.
  • deleteByPrefix is used when cache keys include variable suffixes that can't be predicted at invalidation time.
  • For Redis, deleteByPrefix uses cursor-based SCAN to avoid blocking the server.
  • Calculation caches use a longer TTL (600s) since they are the most expensive operations to recompute.
  • Schools also use 600s TTL since the list rarely changes.