A Clojure memoization library with scoped caching and smart invalidation.
clojure.core/memoize and clojure.core.memoize provide basic caching, but real applications need:
- Scoped caching - fresh cache per request/job/test, discarded when done (scopes can nest)
- Tag-based invalidation - clear all cached data for an entity with one call
- N+1 query prevention - populate single-item caches from bulk loads
- Variable per-entry expiry - set TTL based on the cached value itself
- 2-3x better performance - backed by Caffeine
Traditional caching strategies struggle with API/web requests:
- TTL-based caching: What timeout do you pick? Too short and you get no benefit. Too long and users see stale data. There's rarely a "right" answer because data can change at any moment.
- Size-based caching: Prevents memory issues but doesn't help with staleness. A cached value could be seconds or hours old.
What you actually want for request handling:
- Start fresh for each request (no stale data from previous requests)
- Cache within the request (avoid repeated DB calls in the same request)
- Discard when the request ends (no memory leaks)
Memento's with-caches makes this trivial. While request handling is the most common use case, scopes work for any bounded context - background jobs, batch processing, test fixtures - and can be nested.
When a user updates their profile, you need to invalidate all cached data about that user - across multiple functions. Memento's tag-based invalidation lets you do this with a single call:
(m/memo-clear-tag! :user user-id) ; Clears user 123's data from ALL tagged cachesYou have single-item cached functions like get-user-email. Elsewhere you load a list of 100 users. Without coordination, calling get-user-email for each user means 100 database queries - for data you already have.
Memento's event system lets bulk loaders populate single-item caches:
;; After loading users in bulk, fire events to populate individual caches
(doseq [user users]
(m/fire-event! :user [:user-seen user]))See Events documentation for the full pattern.
;; deps.edn
org.clojars.roklenarcic/memento {:mvn/version "2.0.68"}
;; Leiningen
[org.clojars.roklenarcic/memento "2.0.68"]Requires Java 11+.
(require '[memento.core :as m]
'[memento.config :as mc])
;; Basic memoization - wrap your function with a cache
(def get-user
(m/memo (fn [user-id]
(println "Fetching user" user-id)
{:id user-id :name "Alice"})
{mc/type mc/caffeine})) ; Use Caffeine cache
(get-user 1) ; prints "Fetching user 1", returns {:id 1 :name "Alice"}
(get-user 1) ; returns cached result, no printdefmemo works just like defn - the map is standard function metadata:
(require '[memento.core :as m]
'[memento.config :as mc])
(m/defmemo get-user
"Fetches user from database."
{mc/type mc/caffeine}
[user-id]
(db/fetch-user user-id))(m/defmemo get-user
"Fetches user, cached for 5 minutes, max 1000 entries."
{mc/type mc/caffeine
mc/size< 1000
mc/ttl [5 :m]}
[user-id]
(db/fetch-user user-id))Note: Include mc/type mc/caffeine for functions that should always cache. For request-scoped caching, you can omit the type (see below).
Data goes stale. Set a TTL (time-to-live) to automatically expire entries:
(m/defmemo get-exchange-rate
"Cache exchange rates for 1 minute."
{mc/type mc/caffeine
mc/ttl [1 :m]} ; Also: [30 :s], [2 :h], [1 :d]
[currency]
(api/fetch-rate currency))Or use fade for access-based expiration (expires if not accessed):
(m/defmemo get-user-preferences
"Cache preferences, expire after 10 minutes of no access."
{mc/type mc/caffeine
mc/fade [10 :m]}
[user-id]
(db/fetch-preferences user-id))Prevent unbounded memory growth with size limits:
(m/defmemo get-product
"Cache up to 10,000 products (LRU eviction)."
{mc/type mc/caffeine
mc/size< 10000}
[product-id]
(db/fetch-product product-id))Use with-caches to temporarily replace caches for tagged functions within a scope:
;; Option 1: No caching outside scope (tags only, no mc/type)
(m/defmemo get-user
{mc/tags [:request]}
[user-id]
(db/fetch-user user-id))
;; Option 2: Long-term cache outside scope, fresh cache inside
(m/defmemo get-user-orders
{mc/type mc/caffeine
mc/ttl [1 :h]
mc/tags [:request]}
[user-id]
(db/fetch-orders user-id))
;; In your request handler middleware
(defn wrap-request-cache [handler]
(fn [request]
(m/with-caches :request
(constantly (m/create {mc/type mc/caffeine})) ; Fresh cache for this scope
(handler request))))
;; Within a request:
;; - Both functions use the fresh scoped cache
;; - Multiple calls with same args hit the cache
;; - Cache is discarded when scope endsSee the Scoped Caching Guide for more patterns including nested scopes and consulting long-term caches.
By default, the cache key is the full argument list. Use mc/key-fn to transform it:
;; Ignore the db-conn argument for caching purposes
(m/defmemo get-user
{mc/type mc/caffeine
mc/key-fn rest} ; Cache key is [user-id], not [db-conn user-id]
[db-conn user-id]
(db/fetch-user db-conn user-id))
;; Extract a nested value from a request map
(m/defmemo get-current-user
{mc/type mc/caffeine
mc/key-fn* (fn [request] (-> request :session :user-id))}
[request]
(db/fetch-user (-> request :session :user-id)))mc/key-fn receives args as a sequence; mc/key-fn* receives them as separate parameters (like the function itself).
Use mc/ret-fn to transform values before caching, or prevent caching certain values:
;; Don't cache error responses
(m/defmemo fetch-api-data
{mc/type mc/caffeine
mc/ret-fn (fn [args response]
(if (>= (:status response 0) 400)
(m/do-not-cache response) ; Don't cache errors
response))}
[endpoint]
(http/get endpoint))Without tag-based invalidation, you face an N×M maintenance problem:
- You have N cached functions that read user data
- You have M functions that modify user data
- Every modifier must know about every cached function to invalidate it
- Adding a new cached function means updating all M modifiers
- Adding a new modifier means knowing all N cached functions
Tag-based invalidation decouples them completely:
;; CACHED FUNCTIONS: just tag with :user, don't care who invalidates
(m/defmemo get-user
{mc/type mc/caffeine, mc/tags [:user]}
[user-id]
(-> (db/fetch-user user-id)
(m/with-tag-id :user user-id)))
(m/defmemo get-user-orders
{mc/type mc/caffeine, mc/tags [:user]}
[user-id]
(-> (db/fetch-orders user-id)
(m/with-tag-id :user user-id)))
(m/defmemo get-user-preferences
{mc/type mc/caffeine, mc/tags [:user]}
[user-id]
(-> (db/fetch-preferences user-id)
(m/with-tag-id :user user-id)))
;; MODIFYING FUNCTIONS: just invalidate :user tag, don't care who's cached
(defn update-user! [user-id data]
(db/update-user! user-id data)
(m/memo-clear-tag! :user user-id))
(defn delete-user! [user-id]
(db/delete-user! user-id)
(m/memo-clear-tag! :user user-id))
(defn merge-users! [from-id to-id]
(db/merge-users! from-id to-id)
(m/memo-clear-tags! [:user from-id] [:user to-id]))Now you can add cached functions or modifying functions independently - they only need to agree on the tag name (:user).
A cached value can also be tagged with multiple IDs - useful for aggregated data like dashboards. See the Invalidation Guide for details.
;; Clear all entries for a function
(m/memo-clear! get-user)
;; Clear specific entry
(m/memo-clear! get-user 123)Durations can be numbers (seconds) or [amount :unit] pairs:
30 ; 30 seconds
[30 :s] ; 30 seconds
[5 :m] ; 5 minutes
[2 :h] ; 2 hours
[1 :d] ; 1 day| Setting | Description | Example |
|---|---|---|
mc/type |
Cache implementation (required) | mc/caffeine |
mc/size< |
Max entries (LRU eviction) | 1000 |
mc/ttl |
Time-to-live | [5 :m] |
mc/fade |
Expire after last access | [10 :m] |
mc/tags |
Tags for scoping/invalidation | [:user :request] |
mc/key-fn |
Transform args to cache key | (fn [args] ...) |
mc/ret-fn |
Transform return value | (fn [args val] ...) |
See Configuration Guide for all options.
- Configuration Guide - All configuration options,
key-fn,ret-fn - Invalidation Guide - Cache clearing, tag-based invalidation
- Scoped Caching Guide -
with-caches, nested scopes, request patterns - Advanced Features - Tiered caching, events, variable expiry
- Performance - Benchmarks and comparisons
- Internals - Architecture for contributors
For testing or debugging, disable all caching globally:
java -Dmemento.enabled=false ...See MIGRATION.md for version upgrade guides.
Copyright 2020-2024 Rok Lenarcic
Licensed under the MIT License.