Cache

> Store and retrieve data with Velocity's multi-driver cache system supporting Redis and in-memory storage.

Velocity provides a unified caching interface supporting multiple drivers. The framework reads CACHE_DRIVER and the related env vars at boot and constructs a *cache.Manager for you, exposed as app.Cache (and ctx.Cache() from any handler).

Quick Start

No global cache. All cache operations are methods on *cache.Manager. Reach for app.Cache outside of requests, ctx.Cache() inside a handler. The package-level helpers are limited to cache.NewManager, cache.RememberT, cache.RememberTWithContext, and cache.Drivers() (the pluggable driver registry).

Inside a handler always prefer the *WithContext variant of every method (PutWithContext, GetWithContext, RememberEWithContext, StoreWithContext, …) so the request’s deadline flows through to Redis dials, S3 reaches, and DB connects.

import (
    "time"
    "github.com/velocitykode/velocity/router"
)

func handler(ctx *router.Context) error {
    rctx := ctx.Context()

    // Store a value with TTL. PutWithContext threads ctx through to the
    // driver so a slow Redis write is cancelled when the request is.
    ctx.Cache().PutWithContext(rctx, "user:123", userData, 1*time.Hour)

    // Retrieve a value
    var user User
    if val, found := ctx.Cache().GetWithContext(rctx, "user:123"); found {
        user = val.(User)
    }

    // Store permanently
    ctx.Cache().ForeverWithContext(rctx, "app_version", "1.0.0")

    // Remove a value
    ctx.Cache().ForgetWithContext(rctx, "user:123")
    return nil
}
import (
    "time"
    "github.com/velocitykode/velocity/router"
)

func getUser(ctx *router.Context, userID int) (*User, error) {
    key := fmt.Sprintf("user:%d", userID)

    // Get from cache or compute and store. Use RememberEWithContext so a
    // transient DB error is propagated instead of poisoning the slot, and
    // the request's deadline flows into both the cache lookup and the
    // upstream call.
    result, err := ctx.Cache().RememberEWithContext(ctx.Context(), key, 15*time.Minute, func() (interface{}, error) {
        return fetchUserFromDB(ctx.Context(), userID)
    })
    if err != nil {
        return nil, err
    }

    return result.(*User), nil
}
import (
    "time"
    "github.com/velocitykode/velocity/velocity"
)

func bulkOperations(app *velocity.App) {
    // Store multiple values
    items := map[string]interface{}{
        "key1": "value1",
        "key2": "value2",
        "key3": "value3",
    }
    app.Cache.PutMany(items, 30*time.Minute)

    // Retrieve multiple values
    keys := []string{"key1", "key2", "key3"}
    results := app.Cache.Many(keys)

    for key, value := range results {
        fmt.Printf("%s: %v\n", key, value)
    }
}

Decision matrix

Pick the Remember* variant that matches your error and context needs. Default to RememberEWithContext (or the typed RememberTWithContext[T]) inside any handler that already has a ctx.

SituationHelper
Memoize idempotent fetch; tolerate framework eating callback errorsRemember
Memoize and propagate callback errors (no cache poison on err)RememberE
Inside a handler, propagate request ctx into Redis / S3 / DB lookupsRememberWithContext / RememberEWithContext
Typed return, no interface{} assertion at the call siteRememberT[T]
Typed and ctx-aware (the default for new code)RememberTWithContext[T]
Forever cache until explicit ForgetRememberForever / RememberForeverWithContext
Forever and error-awareRememberForeverE / RememberForeverEWithContext
Remember swallows the error you discard inside the callback. If fetchUserFromDB fails, you cache the zero value for the full TTL and every subsequent caller gets garbage back. Use RememberE whenever the callback can fail.

Callback error path

Remember takes a func() interface{} callback, so the only way to handle a callback failure is to swallow it with _, which writes a zero value into the cache and pins it for the full TTL. That is rarely what you want.

RememberE takes a func() (interface{}, error). When the callback returns a non-nil error, the cache slot is left untouched and the error is propagated to the caller. The next request retries from scratch.

import "time"

// WRONG: transient DB hiccup poisons the cache for 15 minutes.
result, err := app.Cache.Remember(key, 15*time.Minute, func() interface{} {
    user, _ := fetchUserFromDB(userID) // err discarded
    return user
})

// RIGHT: error propagates, slot is not written, next call retries.
result, err := app.Cache.RememberE(key, 15*time.Minute, func() (interface{}, error) {
    return fetchUserFromDB(userID)
})
if err != nil {
    return nil, err
}
return result.(*User), nil

RememberForeverE is the equivalent error-aware variant of RememberForever.

Context propagation

Stores that talk to a remote backend (Redis) implement the ContextStore interface. The manager threads context.Context through to the driver when available so a slow Redis lookup is cancelled with the request.

// ContextStore is satisfied by the Redis driver. Memory and file drivers
// fall back to the plain Store methods automatically.
type ContextStore interface {
    Store
    GetCtx(ctx context.Context, key string) (interface{}, bool)
    PutCtx(ctx context.Context, key string, value interface{}, ttl time.Duration) error
    ForeverCtx(ctx context.Context, key string, value interface{}) error
    ForgetCtx(ctx context.Context, key string) error
    FlushCtx(ctx context.Context) error
    HasCtx(ctx context.Context, key string) bool
    IncrementCtx(ctx context.Context, key string, value int64) (int64, error)
    DecrementCtx(ctx context.Context, key string, value int64) (int64, error)
    ManyCtx(ctx context.Context, keys []string) map[string]interface{}
    PutManyCtx(ctx context.Context, items map[string]interface{}, ttl time.Duration) error
}

Every Manager operation has a *WithContext counterpart: GetWithContext, PutWithContext, ForeverWithContext, ForgetWithContext, RememberWithContext, RememberEWithContext, RememberForeverWithContext, RememberForeverEWithContext, plus StoreWithContext(ctx, name) and DefaultStoreWithContext(ctx) for resolving named stores under the caller’s deadline. Use them from any handler that already has a ctx.

The ctx threads end-to-end: the first call that materialises a store (Redis dial, file mkdir, S3 endpoint check) sees the caller’s ctx through the registry’s Resolve(ctx, name, cfg) path, and every subsequent read/write on a ContextStore honours the same ctx. A handler with a 200 ms deadline cancels both the Redis dial AND the Redis GET if either runs long.

func handler(ctx *router.Context) error {
    val, err := ctx.Cache().RememberEWithContext(ctx.Context(), "regions", 5*time.Minute, func() (interface{}, error) {
        return upstream.FetchRegions(ctx.Context())
    })
    if err != nil {
        return err
    }
    return ctx.JSON(200, val)
}

Store(name) and DefaultStore() still exist; they call StoreWithContext(context.Background(), ...) internally. Reach for them only outside a request scope (boot wiring, scripts, tests).

Configuration

Configure caching through environment variables in your .env file:

# Driver selection
CACHE_DRIVER=memory        # Options: memory, file, redis

# Prefix for cache keys
CACHE_PREFIX=velocity_cache

# Memory driver (default, no additional config needed)

# File driver settings
CACHE_PATH=./storage/cache

# Redis driver settings
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DATABASE=0

# Multiple stores (optional)
CACHE_STORES=session:memory,api:redis

Drivers

The framework ships four built-in factories (memory, file, redis, database) that self-register from cache/init.go at package import time. Any extra factory you install via cache.Drivers().Register(...) joins the same registry and becomes selectable through CACHE_DRIVER like a built-in.

Memory Driver

The memory driver stores cache data in application memory:

  • Fast: In-memory access with no I/O overhead
  • Thread-safe: Concurrent access properly synchronized
  • Auto-cleanup: Expired items automatically removed
  • Development: Perfect for development and testing

Note: Cache is lost when the application restarts.

// Auto-configured from .env
CACHE_DRIVER=memory

File Driver

The file driver stores cache data on the filesystem:

  • Persistent: Cache survives application restarts
  • Simple: No external dependencies
  • Single-server: Best for single-server deployments
CACHE_DRIVER=file
CACHE_PATH=./storage/cache

Redis Driver

The redis driver provides distributed caching:

  • Distributed: Share cache across multiple servers
  • Persistent: Cache survives application restarts
  • Production: Best for production environments
CACHE_DRIVER=redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DATABASE=0

The Redis factory takes the caller’s ctx and uses it for the initial PING, so a misconfigured cluster fails under the request deadline rather than the go-redis default dial timeout. Construct one directly when you need to bypass the manager:

import (
    "context"
    "github.com/velocitykode/velocity/cache/drivers"
)

store, err := drivers.NewRedisStore(ctx, "myapp", "127.0.0.1", 6379, "", 0, false)
if err != nil {
    return err
}

NewRedisStore validates host non-empty / port positive in the factory itself; cache.StoreConfig.Validate no longer enforces driver-specific fields, so third-party drivers stay free to define their own config shape.

Custom Drivers

cache.Drivers() returns the canonical driver registry. Register a third-party factory from your driver package’s init() and the manager will resolve it like any built-in:

package dragonfly

import (
    "context"

    "github.com/velocitykode/velocity/cache"
)

func init() {
    cache.Drivers().Register("dragonfly", func(ctx context.Context, cfg cache.StoreConfig) (cache.Store, error) {
        // Validate the driver-specific fields here. The manager has
        // already merged global + per-store prefix into cfg.Prefix.
        if cfg.Host == "" {
            return nil, fmt.Errorf("dragonfly: host required")
        }
        return newDragonflyStore(ctx, cfg)
    })
}

Then point the config at the new driver name:

CACHE_DRIVER=dragonfly

A few rules the registry enforces (panicking at boot, never at first request):

  • Names are case-insensitive and trimmed; Dragonfly and dragonfly collide.
  • Register panics on a duplicate registration. Use Drivers().Override(name, factory) from tests when you intentionally want to swap a real driver for a fake; it returns the previous factory so a t.Cleanup can restore it.
  • A nil factory or empty name panics immediately.
  • Resolve returns a typed *driverregistry.NotFoundError (with the available names) when CACHE_DRIVER points at an unregistered driver, so the failure surface includes a “did you mean?” hint.

StoreConfig.Validate only checks that Driver is non-empty. It deliberately does NOT consult Drivers().Names(): validation runs at config-load time, while the driver package’s init() may run later (a blank import). Registry lookup is the resolver’s job.

For the cross-subsystem story (queue, storage, mail, notification, log, orm all share the same driverregistry), see Driver Registry.

API Reference

Every method below has a *WithContext sibling that takes ctx context.Context as the first argument. The non-ctx forms exist for boot wiring and one-off scripts; inside a handler always reach for the ctx-aware variant.

Basic Operations

Put

Store a value in the cache with a TTL:

import "time"

// Store string (request-scoped)
app.Cache.PutWithContext(ctx, "username", "john_doe", 1*time.Hour)

// Store struct
user := User{ID: 123, Name: "John Doe"}
app.Cache.PutWithContext(ctx, "user:123", user, 30*time.Minute)

// Boot-time wiring without a request ctx: use plain Put.
app.Cache.Put("config", configData, 24*time.Hour)

Get

Retrieve a value from the cache:

// Get value (ctx threads through to Redis when applicable)
value, found := app.Cache.GetWithContext(ctx, "username")
if found {
    username := value.(string)
    fmt.Println("Username:", username)
}

// Get string value directly. GetString is convenience-only and does not
// take a ctx; use GetWithContext + a type assertion when you need both.
username, found := app.Cache.GetString("username")
if found {
    fmt.Println("Username:", username)
}

Forever

Store a value permanently (no expiration):

// Store without expiration, request-scoped
app.Cache.ForeverWithContext(ctx, "app_name", "Velocity")
app.Cache.ForeverWithContext(ctx, "build_number", "1234")

Forget

Remove a value from the cache:

// Remove single key, request-scoped
app.Cache.ForgetWithContext(ctx, "user:123")

// Check if removed
if !app.Cache.Has("user:123") {
    fmt.Println("User cache cleared")
}

Flush

Clear all values from the cache:

// Clear entire cache
app.Cache.Flush()

Advanced Operations

Remember

Get from cache or compute and store. The callback signature is func() interface{}, so any error inside the callback must be discarded. Prefer RememberE whenever the callback can fail.

import "time"

// Use only when the callback cannot return a meaningful error.
result, err := app.Cache.Remember("config:flags", 15*time.Minute, func() interface{} {
    return staticFlags()
})

RememberE

Error-aware variant of Remember. The callback returns (interface{}, error); on a non-nil error the cache slot is NOT written and the error is propagated. This prevents transient upstream failures from pinning a zero value for the full TTL. Use RememberEWithContext whenever a ctx is in scope.

func getExpensiveData(app *velocity.App, id int) (*Data, error) {
    key := fmt.Sprintf("data:%d", id)

    result, err := app.Cache.RememberE(key, 15*time.Minute, func() (interface{}, error) {
        return queryDatabase(id)
    })
    if err != nil {
        return nil, err
    }
    return result.(*Data), nil
}

RememberEWithContext

RememberE with a context.Context. The ctx threads through to the underlying driver via ContextStore so a slow Redis lookup is cancelled with the request, AND through the registry’s Resolve(ctx, ...) step the first time the store is materialised so the initial Redis dial honours the same deadline. Memory and file drivers ignore the ctx transparently.

func getRegions(ctx context.Context, app *velocity.App) ([]Region, error) {
    val, err := app.Cache.RememberEWithContext(ctx, "regions", 5*time.Minute, func() (interface{}, error) {
        return upstream.FetchRegions(ctx)
    })
    if err != nil {
        return nil, err
    }
    return val.([]Region), nil
}

RememberT

Typed-generic shim over RememberE that returns T directly, skipping the interface{} assertion at the call site. The first argument is anything that satisfies RememberEable (the cache *Manager does).

import "github.com/velocitykode/velocity/cache"

region, err := cache.RememberT[Region](app.Cache, "regions:eu", 5*time.Minute, func() (Region, error) {
    return upstream.FetchRegion("eu")
})
if err != nil {
    return err
}
// region is Region, no cast needed.

RememberTWithContext[T] is the ctx-aware counterpart, taking any RememberEContextable (the cache *Manager again):

region, err := cache.RememberTWithContext[Region](app.Cache, ctx, "regions:eu", 5*time.Minute, func() (Region, error) {
    return upstream.FetchRegion(ctx, "eu")
})

On a type mismatch (cache slot holds a different type than T), the function returns the zero T and an error so callers can detect corruption.

RememberForever

Get from cache or compute and store permanently:

func getAppConfig(ctx context.Context, app *velocity.App) (*Config, error) {
    result, err := app.Cache.RememberForeverEWithContext(ctx, "app_config", func() (interface{}, error) {
        return loadConfig(ctx)
    })
    if err != nil {
        return nil, err
    }
    return result.(*Config), nil
}

RememberForeverE is the error-aware variant; RememberForeverWithContext and RememberForeverEWithContext add ctx propagation. Prefer RememberForeverEWithContext for any callback that hits the network.

Increment / Decrement

Atomic increment or decrement of numeric values:

// Increment counter
newValue, err := app.Cache.Increment("page_views", 1)
if err != nil {
    log.Error("Failed to increment", "error", err)
}

// Increment by custom amount
app.Cache.Increment("total_sales", 150)

// Decrement counter
app.Cache.Decrement("items_remaining", 1)

// Decrement by custom amount
app.Cache.Decrement("stock_level", 10)

Has

Check if a key exists in the cache:

if app.Cache.Has("user:123") {
    fmt.Println("User is cached")
} else {
    fmt.Println("User not in cache")
}

Bulk Operations

PutMany

Store multiple values at once:

import "time"

func cacheUserData(app *velocity.App, users []User) {
    items := make(map[string]interface{})

    for _, user := range users {
        key := fmt.Sprintf("user:%d", user.ID)
        items[key] = user
    }

    // Store all users with 1 hour TTL
    app.Cache.PutMany(items, 1*time.Hour)
}

Many

Retrieve multiple values at once:

func getUserBatch(app *velocity.App, userIDs []int) map[string]interface{} {
    keys := make([]string, len(userIDs))
    for i, id := range userIDs {
        keys[i] = fmt.Sprintf("user:%d", id)
    }

    // Get all users at once
    results := app.Cache.Many(keys)

    return results
}

Multiple Cache Stores

Use different cache stores for different purposes:

Configuration

# Default store
CACHE_DRIVER=memory

# Additional stores
CACHE_STORES=session:memory,api:redis

Using Named Stores

func useMultipleStores(ctx context.Context, app *velocity.App) {
    // Get named store off the manager. StoreWithContext threads ctx into
    // the driver factory the first time the store is materialised, so a
    // slow Redis dial is cancelled when the request is.
    sessionStore, err := app.Cache.StoreWithContext(ctx, "session")
    if err != nil {
        log.Error("Failed to get session store", "error", err)
        return
    }

    // Use specific store
    sessionStore.Put("session:abc123", sessionData, 30*time.Minute)

    // Get from specific store
    val, found := sessionStore.Get("session:abc123")
}

Store(name) is the ctx-less form (it calls StoreWithContext(context.Background(), name) internally). Prefer the ctx-aware variant inside any handler. Subsequent calls to either form return the cached instance, so you only pay the dial cost on the first call.

Manager for Advanced Usage

The framework constructs a *cache.Manager during velocity.New() and exposes it as app.Cache. Reach for it directly when you need named stores or distributed locks:

func setupCaching(ctx context.Context, app *velocity.App) {
    // Get specific stores from the manager, ctx-scoped.
    apiCache, _ := app.Cache.StoreWithContext(ctx, "api")
    sessionCache, _ := app.Cache.StoreWithContext(ctx, "session")

    // Use different stores for different purposes
    apiCache.Put("api:users", users, 5*time.Minute)
    sessionCache.Put("session:123", sessionData, 30*time.Minute)
}

DefaultStoreWithContext(ctx) returns the manager’s default store under the same ctx contract. If you ever need a manager outside the framework lifecycle (tests, scripts), build one yourself with cache.NewManager(&cache.Config{...}). That is the only package-level constructor.

Usage Patterns

User Profile Caching

Reduce database queries by caching user profiles:

func getUserProfile(ctx *router.Context, userID int) (*User, error) {
    key := fmt.Sprintf("user:profile:%d", userID)

    user, err := cache.RememberTWithContext[*User](ctx.Cache(), ctx.Context(), key, 1*time.Hour, func() (*User, error) {
        return db.QueryUser(ctx.Context(), userID)
    })
    if err != nil {
        return nil, err
    }
    return user, nil
}

func updateUserProfile(ctx *router.Context, userID int, updates map[string]interface{}) error {
    // Update database
    if err := db.UpdateUser(ctx.Context(), userID, updates); err != nil {
        return err
    }

    // Invalidate cache under the request ctx so a slow Redis is cancellable.
    key := fmt.Sprintf("user:profile:%d", userID)
    ctx.Cache().ForgetWithContext(ctx.Context(), key)

    return nil
}

API Response Caching

Cache expensive API responses:

func fetchWeatherData(ctx *router.Context, city string) (*Weather, error) {
    key := fmt.Sprintf("weather:%s", city)

    return cache.RememberTWithContext[*Weather](ctx.Cache(), ctx.Context(), key, 15*time.Minute, func() (*Weather, error) {
        return callWeatherAPI(ctx.Context(), city)
    })
}

Rate Limiting

Implement rate limiting with cache counters:

func checkRateLimit(ctx *router.Context, userID int) (bool, error) {
    key := fmt.Sprintf("rate_limit:user:%d", userID)

    // Increment request counter
    count, err := ctx.Cache().Increment(key, 1)
    if err != nil {
        return false, err
    }

    // Set expiration on first request
    if count == 1 {
        ctx.Cache().PutWithContext(ctx.Context(), key, count, 1*time.Minute)
    }

    // Check if over limit (e.g., 60 requests per minute)
    if count > 60 {
        return false, fmt.Errorf("rate limit exceeded")
    }

    return true, nil
}

Session Storage

Use cache for session data:

func storeSession(ctx *router.Context, sessionID string, data map[string]interface{}) error {
    key := fmt.Sprintf("session:%s", sessionID)
    return ctx.Cache().PutWithContext(ctx.Context(), key, data, 30*time.Minute)
}

func getSession(ctx *router.Context, sessionID string) (map[string]interface{}, error) {
    key := fmt.Sprintf("session:%s", sessionID)

    val, found := ctx.Cache().GetWithContext(ctx.Context(), key)
    if !found {
        return nil, fmt.Errorf("session not found")
    }

    return val.(map[string]interface{}), nil
}

func destroySession(ctx *router.Context, sessionID string) error {
    key := fmt.Sprintf("session:%s", sessionID)
    return ctx.Cache().ForgetWithContext(ctx.Context(), key)
}

Query Result Caching

Cache database query results:

func getPopularPosts(ctx *router.Context) ([]Post, error) {
    return cache.RememberTWithContext[[]Post](ctx.Cache(), ctx.Context(), "posts:popular", 10*time.Minute, func() ([]Post, error) {
        return db.QueryWithContext(ctx.Context(), `
            SELECT * FROM posts
            WHERE published = true
            ORDER BY views DESC
            LIMIT 10
        `)
    })
}

Testing

Use the memory driver for testing:

func TestCaching(t *testing.T) {
    // Build a manager directly for tests; no .env required.
    mgr := cache.NewManager(&cache.Config{
        Default: "default",
        Stores: map[string]cache.StoreConfig{
            "default": {Driver: cache.DriverMemory},
        },
    })

    // Clear cache before the assertions
    mgr.Flush()

    // Test cache operations
    mgr.Put("test_key", "test_value", 1*time.Minute)

    val, found := mgr.Get("test_key")
    assert.True(t, found)
    assert.Equal(t, "test_value", val.(string))

    // Test expiration
    mgr.Put("expire_key", "value", 1*time.Millisecond)
    time.Sleep(2 * time.Millisecond)

    _, found = mgr.Get("expire_key")
    assert.False(t, found)
}

Best Practices

  1. Use Appropriate TTLs: Set reasonable expiration times based on data volatility
  2. Cache Invalidation: Always invalidate cache when underlying data changes
  3. Key Naming: Use consistent, hierarchical key naming (e.g., resource:action:id)
  4. Cache Prefixes: Use the CACHE_PREFIX to avoid key collisions
  5. Error Handling: Always handle cache errors gracefully
  6. Memory Management: Monitor cache size and implement eviction policies
  7. Testing: Use the memory driver for unit tests
  8. Production: Use Redis driver for production environments
  9. Documentation: Document which data is cached and for how long

Performance Considerations

  1. Driver Selection:

    • Memory: Fastest, but not persistent or distributed
    • File: Moderate speed, persistent, single-server
    • Redis: Fast, persistent, distributed
  2. TTL Selection: Balance freshness vs. performance

    • Frequently changing data: 1-5 minutes
    • Moderately changing data: 15-60 minutes
    • Rarely changing data: 1-24 hours
  3. Serialization: Complex objects have serialization overhead

  4. Bulk Operations: Use PutMany and Many for batch operations

Examples

Complete Handler Example

import (
    "time"
    "github.com/velocitykode/velocity/cache"
    "github.com/velocitykode/velocity/router"
)

type ProductHandler struct{}

func (c *ProductHandler) Show(ctx *router.Context) error {
    productID := ctx.Param("id")
    key := fmt.Sprintf("product:%s", productID)

    // RememberTWithContext returns *Product directly, propagates the
    // request ctx into both the cache lookup and the upstream fetch, and
    // skips the cache write on error so a transient failure does not
    // poison the slot for the full TTL.
    product, err := cache.RememberTWithContext[*Product](ctx.Cache(), ctx.Context(), key, 1*time.Hour, func() (*Product, error) {
        return fetchProduct(ctx.Context(), productID)
    })
    if err != nil {
        return ctx.Error("Product not found", 404)
    }

    return ctx.JSON(200, product)
}

func (c *ProductHandler) Update(ctx *router.Context) error {
    productID := ctx.Param("id")

    // Update product in database
    if err := updateProduct(ctx.Context(), productID, ctx.Body); err != nil {
        return ctx.Error("Failed to update product", 500)
    }

    // Invalidate cache under the request ctx
    ctx.Cache().ForgetWithContext(ctx.Context(), fmt.Sprintf("product:%s", productID))

    return ctx.JSON(200, map[string]string{"status": "updated"})
}
  • Driver Registry for the shared Drivers().Register pattern across cache, queue, storage, mail, notification, log, and orm.
  • Notifications for outbound delivery channels that frequently sit behind a cache.
  • Queue when work is too long for fire-and-forget caching and needs durable retries.
  • CSRF which leans on the cache manager for token storage in distributed deployments.