TypedCache
TypedCache is a lightweight, type-safe façade around your favourite Ruby cache stores. It adds three things on top of the raw back-end implementation:
-
Namespacing – hierarchical
Namespace
helpers prevent key collisions. You can create nested namespaces easily, likeNamespace.at("users", "profiles", "avatars")
. -
Stronger types – RBS signatures as well as monadic types like
Either
,Maybe
, andSnapshot
wrap cache results so you always know whether you have a value, an error, or a cache-miss. - Composable decorators – behaviours like instrumentation can be layered on without touching the underlying store.
TL;DR – Think Faraday or Rack middlewares, but for caching.
Installation
bundle add typed_cache && bundle install
# or
gem install typed_cache
This gem does is also cryptographically signed, if you want to ensure the gem was not tampered with, make sure to use these commands:
bundle add typed_cache && bundle install --trust-policy=HighSecurity
# or
gem install typed_cache -P HighSecurity
If there are issues with unsigned gems, use MediumSecurity
instead.
Quick start
require "typed_cache"
# 1. Build a store
store = TypedCache.builder
.with_backend(:memory, shared: true)
.with_instrumentation(:rails) # e.g. using ActiveSupport
.build
.value # unwrap Either for brevity
# 2. Get a reference to a key
user_ref = store.ref("users:123") # => CacheRef
# 3. Fetch and compute if absent
user_snapshot = user_ref.fetch do
puts "Cache miss! Computing..."
{ id: 123, name: "Jane" }
end.value # => Snapshot
puts "Found: #{user_snapshot.value} (from_cache?=#{user_snapshot.from_cache?})"
Builder API
Step | Purpose |
---|---|
with_backend(:name, **opts) |
Mandatory. Configure the concrete Backend and its options. |
with_decorator(:key) |
Optional. Add a decorator by registry key. |
with_instrumentation(:source) |
Optional. Add instrumentation, e.g. :rails or :dry . |
build |
Returns Either[Error, Store] . |
Back-ends vs Decorators
-
Back-end (
TypedCache::Backend
) – persists data (Memory, Redis, etc.). -
Decorator (
TypedCache::Decorator
) – wraps an existing store to add behaviour (Instrumentation, Logging, Circuit-Breaker …).
Both include the same public Store
interface, so they can be composed
freely. Registries keep them separate:
TypedCache::Backends.available # => [:memory, :active_support]
TypedCache::Decorators.available # => [:instrumented]
Register your own
class RedisBackend
include TypedCache::Backend
# … implement #get, #set, etc.
end
TypedCache::Backends.register(:redis, RedisBackend)
class LogDecorator
include TypedCache::Decorator
def initialize(store) = @store = store
def set(key, value)
puts "[cache] SET #{key}"
@store.set(key, value)
end
# delegate the rest …
end
TypedCache::Decorators.register(:logger, LogDecorator)
Error handling
All operations return one of:
-
Either.right(Snapshot)
– success -
Either.left(CacheMissError)
– key not present -
Either.left(StoreError)
– transport / serialization failure
Use the monad directly or pattern-match:
result.fold(
->(err) { warn err.message },
->(snapshot) { puts snapshot.value },
)
The CacheRef
and Store
APIs
While you can call get
, set
, and fetch
directly on the store
, the more powerful way to work with TypedCache is via the CacheRef
object. It provides a rich, monadic API for a single cache key. The Store
also provides fetch_all
for batch operations.
You get a CacheRef
by calling store.ref(key)
:
user_ref = store.ref("users:123") # => #<TypedCache::CacheRef ...>
Now you can operate on it:
# Fetch a value, computing it if it's missing
snapshot_either = user_ref.fetch do
{ id: 123, name: "Jane Doe" }
end
# The result is always an Either[Error, Snapshot]
snapshot_either.fold(
->(err) { warn "Something went wrong: #{err.message}" },
->(snapshot) { puts "Got value: #{snapshot.value} (from cache: #{snapshot.from_cache?})" }
)
# You can also map over values
name_either = user_ref.map { |user| user[:name] }
puts "User name is: #{name_either.value.value}" # unwrap Either, then Snapshot
Batch Operations with fetch_all
For retrieving multiple keys at once, the Store
provides a fetch_all
method. This is more efficient than fetching keys one by one, especially with remote back-ends like Redis.
It takes a list of keys and a block to compute the values for any missing keys.
user_refs = store.fetch_all("users:123", "users:456") do |missing_key|
# This block is called for each cache miss
user_id = missing_key.split(":").last
puts "Cache miss for #{missing_key}! Computing..."
{ id: user_id, name: "Fetched User #{user_id}" }
end
user_refs.each do |key, snapshot_either|
snapshot_either.fold(
->(err) { warn "Error for #{key}: #{err.message}" },
->(snapshot) { puts "Got value for #{key}: #{snapshot.value}" }
)
end
The CacheRef
API encourages a functional style and makes composing cache operations safe and predictable.
Instrumentation
TypedCache can publish events about cache operations using different instrumenters. To enable it, use the with_instrumentation
method on the builder, specifying an instrumentation backend:
# For ActiveSupport::Notifications (e.g. in Rails)
store = TypedCache.builder
.with_backend(:memory)
.with_instrumentation(:rails)
.build.value
# For Dry::Monitor
store = TypedCache.builder
.with_backend(:memory)
.with_instrumentation(:dry)
.build.value
Events are published to a topic like typed_cache.<operation>
(e.g., typed_cache.get
). The topic namespace can be configured.
Payload keys include: :namespace, :key, :operation, :duration
, and cache_hit
.
You can subscribe to these events like so:
# Example for ActiveSupport
ActiveSupport::Notifications.subscribe("typed_cache.get") do |name, start, finish, id, payload|
# Or you can subscribe via the store object itself
instrumenter = store.instrumenter
instrumenter.subscribe("get") do |event|
payload = event.payload
puts "Cache GET for key #{payload[:key]} took #{payload[:duration]}ms. Hit? #{payload[:cache_hit]}"
end
If you call with_instrumentation
with no arguments, it uses a Null
instrumenter, which has no overhead.
Custom Instrumenters
Just like with back-ends and decorators, you can write and register your own instrumenters. An instrumenter must implement an instrument
and a subscribe
method.
class MyCustomInstrumenter
include TypedCache::Instrumenter
def instrument(operation, key, **payload, &block)
# ... your logic ...
end
def subscribe(event_name, **filters, &block)
# ... your logic ...
end
end
# Register it
TypedCache::Instrumenters.register(:custom, MyCustomInstrumenter)
# Use it
store = TypedCache.builder
.with_instrumentation(:custom)
# ...
Further examples
For more advanced scenarios—including Rails integration, pattern matching, custom back-ends, and testing—see examples.md.
License
This work is licensed under the Apache-2.0 license.
Apache 2.0 License Key Terms
Grants
- Perpetual, worldwide, non-exclusive, royalty-free license to:
- Reproduce the work
- Prepare derivative works
- Distribute the work
- Use and sell the work
Requirements
- Include a copy of the Apache 2.0 License with any distribution
- Provide attribution
- Clearly mark any modifications made to the original work
- Retain all original copyright and license notices
Permissions
- Commercial use allowed
- Modification permitted
- Distribution of original and modified work permitted
- Patent use granted
- Private use allowed
Limitations
- No warranty or liability protection
- Trademark rights not transferred
- Contributors not liable for damages
Compatibility
- Can be used in closed-source and commercial projects
- Requires preserving original license and attribution