Skip to content
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ plugins:

AllCops:
NewCops: enable
SuggestExtensions: false # rubocop-rake is the only suggestion and our Rakefile is trivial boilerplate
TargetRubyVersion: 3.3

Layout/LineLength:
Expand Down
67 changes: 67 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project

Cacheable is a Ruby gem by Splitwise that adds method caching via an AOP (Aspect-Oriented Programming) pattern. Include `Cacheable` in a class, annotate methods with `cacheable :method_name`, and results are automatically cached.

## Commands

```bash
# Install dependencies
bundle install

# Run full default task (rubocop + rspec)
bundle exec rake

# Run tests only
bundle exec rspec

# Run a single test file
bundle exec rspec spec/cacheable/cacheable_spec.rb

# Run a single test by line number
bundle exec rspec spec/cacheable/cacheable_spec.rb:45

# Run linter only
bundle exec rubocop

# Auto-fix lint issues
bundle exec rubocop -a

# Watch and auto-run tests/lint on file changes
bundle exec guard
```

## Architecture

The gem uses **module prepending with dynamic method generation** to intercept and cache method calls.

### Core flow

1. **`Cacheable`** (`lib/cacheable.rb`) — The module users include. On `included`, it extends the host class with `MethodGenerator` and creates a unique anonymous interceptor module that gets prepended to the class.

2. **`MethodGenerator`** (`lib/cacheable/method_generator.rb`) — When `cacheable :method_name` is called, this generates five methods on the interceptor module:
- `method_name` (override) — dispatcher that routes to `with_cache` or `without_cache` based on the `unless:` condition
- `method_with_cache` — fetch from cache or compute and store
- `method_without_cache` — bypass cache, call original
- `method_key_format` — generate the cache key
- `clear_method_cache` — invalidate the cache entry

3. **`CacheAdapter`** (`lib/cacheable/cache_adapter.rb`) — Protocol for cache backends. Default is `:memory`. Required interface: `fetch(key, options, &block)` and `delete(key)`.

4. **`MemoryAdapter`** (`lib/cacheable/cache_adapters/memory_adapter.rb`) — Built-in hash-backed in-memory cache. Production use typically wires in `Rails.cache` or a custom adapter.

### Key design details

- Each class that includes `Cacheable` gets its own unique interceptor module (created via `Module.new`), which is prepended to the class. This is how `super` chains through to the original method.
- The `unless:` option accepts a proc/symbol that, when truthy, skips caching and calls the original method directly.
- `key_format:` accepts a proc receiving `(target, method_name, args, **kwargs)` for custom cache key generation.

## Style

- Max line length: 120 characters
- Max method length: 25 lines
- Rubocop enforced with `NewCops: enable`
- No frozen string literal comments required
11 changes: 3 additions & 8 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
cacheable (2.0.0)
cacheable (2.1.0)

GEM
remote: https://rubygems.org/
Expand All @@ -14,9 +14,7 @@ GEM
reline (>= 0.6.0)
coderay (1.1.3)
diff-lcs (1.6.2)
ffi (1.17.3-arm64-darwin)
ffi (1.17.3-x86_64-darwin)
ffi (1.17.3-x86_64-linux-gnu)
ffi (1.17.3)
formatador (1.2.3)
reline
guard (2.20.1)
Expand Down Expand Up @@ -118,10 +116,7 @@ GEM
unicode-emoji (4.2.0)

PLATFORMS
arm64-darwin-23
x86_64-darwin-20
x86_64-darwin-21
x86_64-linux
ruby

DEPENDENCIES
cacheable!
Expand Down
103 changes: 63 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,9 @@ end
> a = GitHubApiAdapter.new
> a.star_count
Fetching data from GitHub
=> 19
=> 58
> a.star_count
=> 19
=> 58

# Notice that "Fetching data from GitHub" was not output the 2nd time the method was invoked.
# The network call and result parsing would also not be performed again.
Expand All @@ -102,12 +102,12 @@ The cache can intentionally be skipped by appending `_without_cache` to the meth
> a = GitHubApiAdapter.new
> a.star_count
Fetching data from GitHub
=> 19
=> 58
> a.star_count_without_cache
Fetching data from GitHub
=> 19
=> 58
> a.star_count
=> 19
=> 58
```

#### Remove the Value via `clear_#{method}_cache`
Expand All @@ -118,15 +118,15 @@ The cached value can be cleared at any time by calling `clear_#{your_method_name
> a = GitHubApiAdapter.new
> a.star_count
Fetching data from GitHub
=> 19
=> 58
> a.star_count
=> 19
=> 58

> a.clear_star_count_cache
=> true
> a.star_count
Fetching data from GitHub
=> 19
=> 58
```

## Additional Configuration
Expand All @@ -135,7 +135,7 @@ Fetching data from GitHub

#### Default

By default, Cacheable will construct a key in the format `[cache_key || class_name, method_name]` without using method arguments.
By default, Cacheable will construct a key in the format `[cache_key || class_name, method_name]` without using method arguments. If a cached method is called with arguments while using the default key format, Cacheable will emit a warning to stderr since different arguments will return the same cached value. To silence the warning, provide a `:key_format` proc that includes the arguments in the cache key.

If the object responds to `cache_key` its return value will be the first element in the array. `ActiveRecord` provides [`cache_key`](https://api.rubyonrails.org/classes/ActiveRecord/Integration.html#method-i-cache_key) but it can be added to any Ruby object or overwritten. If the object does not respond to it, the name of the class will be used instead. The second element will be the name of the method as a symbol.

Expand All @@ -155,12 +155,13 @@ require 'net/http'
class GitHubApiAdapter
include Cacheable

cacheable :star_count, key_format: ->(target, method_name, method_args) do
[target.class, method_name, method_args.first, Time.now.strftime('%Y-%m-%d')].join('/')
cacheable :star_count, key_format: ->(target, method_name, method_args, **kwargs) do
date = kwargs.fetch(:date, Time.now.strftime('%Y-%m-%d'))
[target.class, method_name, method_args.first, date].join('/')
end

def star_count(repo)
puts "Fetching data from GitHub for #{repo}"
def star_count(repo, date: Time.now.strftime('%Y-%m-%d'))
puts "Fetching data from GitHub for #{repo} (as of #{date})"
url = "https://api.github.com/repos/splitwise/#{repo}"

JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count']
Expand All @@ -170,33 +171,34 @@ end

* `target` is the object the method is being called on (`#<GitHubApiAdapter:0x0…0>`)
* `method_name` is the name of the method being cached (`:star_count`)
* `method_args` is an array of arguments being passed to the method (`[params]`)
* `method_args` is an array of positional arguments being passed to the method (`[params]`)
* `**kwargs` are the keyword arguments being passed to the method

Including the method argument(s) allows you to cache different calls to the same method. Without the arguments in the cache key, a call to `star_count('cacheable')` would populate the cache and `star_count('tokenautocomplete')` would return the number of stars for Cacheable instead of what you want.

In addition, we're including the current date in the cache key so calling this method tomorrow will return an updated value.
**Note:** The `key_format` proc only receives keyword arguments that the caller explicitly passes — method defaults are not included. That's why the proc uses `kwargs.fetch(:date, Time.now.strftime('%Y-%m-%d'))` to compute its own default when `date:` is omitted. This ensures the cache key always varies by date.

```irb
> a = GitHubApiAdapter.new
> a.star_count('cacheable')
Fetching data from GitHub for cacheable
=> 19
Fetching data from GitHub for cacheable (as of 2026-02-26)
=> 58
> a.star_count('cacheable')
=> 19
=> 58
> a.star_count('tokenautocomplete')
Fetching data from GitHub for tokenautocomplete
=> 1164
Fetching data from GitHub for tokenautocomplete (as of 2026-02-26)
=> 1309
> a.star_count('tokenautocomplete')
=> 1164
=> 1309

# In this example the follow cache keys are generated:
# GitHubApiAdapter/star_count/cacheable/2018-09-21
# GitHubApiAdapter/star_count/tokenautocomplete/2018-09-21
# GitHubApiAdapter/star_count/cacheable/2026-02-26
# GitHubApiAdapter/star_count/tokenautocomplete/2026-02-26
```

### Conditional Caching

You can control if a method should be cached by supplying a proc to the `unless:` option which will get the same arguments as `key_format:`. This logic can be defined in a method on the class and the name of the method as a symbol can be passed as well. **Note**: When using a symbol, the first argument, `target`, will not be passed but will be available as `self`.
You can control if a method should be cached by supplying a proc to the `unless:` option which will get the same arguments as `key_format:` (`target, method_name, method_args, **kwargs`). This logic can be defined in a method on the class and the name of the method as a symbol can be passed as well. **Note**: When using a symbol, the first argument, `target`, will not be passed but will be available as `self`.

```ruby
# From examples/conditional_example.rb
Expand All @@ -208,18 +210,19 @@ require 'net/http'
class GitHubApiAdapter
include Cacheable

cacheable :star_count, unless: :growing_fast?, key_format: ->(target, method_name, method_args) do
[target.class, method_name, method_args.first].join('/')
cacheable :star_count, unless: :growing_fast?, key_format: ->(target, method_name, method_args, **kwargs) do
date = kwargs.fetch(:date, Time.now.strftime('%Y-%m-%d'))
[target.class, method_name, method_args.first, date].join('/')
end

def star_count(repo)
puts "Fetching data from GitHub for #{repo}"
def star_count(repo, date: Time.now.strftime('%Y-%m-%d'))
puts "Fetching data from GitHub for #{repo} (as of #{date})"
url = "https://api.github.com/repos/splitwise/#{repo}"

JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count']
end

def growing_fast?(_method_name, method_args)
def growing_fast?(_method_name, method_args, **)
method_args.first == 'cacheable'
end
end
Expand All @@ -230,17 +233,17 @@ Cacheable is new so we don't want to cache the number of stars it has as we expe
```irb
> a = GitHubApiAdapter.new
> a.star_count('tokenautocomplete')
Fetching data from GitHub for tokenautocomplete
=> 1164
Fetching data from GitHub for tokenautocomplete (as of 2026-02-26)
=> 1309
a.star_count('tokenautocomplete')
=> 1164
=> 1309

> a.star_count('cacheable')
Fetching data from GitHub for cacheable
=> 19
Fetching data from GitHub for cacheable (as of 2026-02-26)
=> 58
> a.star_count('cacheable')
Fetching data from GitHub for cacheable
=> 19
Fetching data from GitHub for cacheable (as of 2026-02-26)
=> 58
```

### Cache Options
Expand All @@ -251,6 +254,26 @@ If your cache backend supports options, you can pass them as the `cache_options:
cacheable :with_options, cache_options: {expires_in: 3_600}
```

### Per-Class Cache Adapter

By default, all classes use the global adapter set via `Cacheable.cache_adapter`. If you need a specific class to use a different cache backend, you can set one directly on the class:

```ruby
class FrequentlyAccessedModel
include Cacheable

self.cache_adapter = MyFasterCache.new

cacheable :expensive_lookup

def expensive_lookup
# ...
end
end
```

The class-level adapter takes precedence over the global adapter. Classes without their own adapter fall back to `Cacheable.cache_adapter` as usual.

### Flexible Options

You can use the same options with multiple cache methods or limit them only to specific methods:
Expand Down Expand Up @@ -302,15 +325,15 @@ end
```irb
> GitHubApiAdapter.star_count_for_cacheable
Fetching data from GitHub for cacheable
=> 19
=> 58
> GitHubApiAdapter.star_count_for_cacheable
=> 19
=> 58

> GitHubApiAdapter.star_count_for_tokenautocomplete
Fetching data from GitHub for tokenautocomplete
=> 1164
=> 1309
> GitHubApiAdapter.star_count_for_tokenautocomplete
=> 1164
=> 1309
```

### Other Notes / Frequently Asked Questions
Expand Down
8 changes: 4 additions & 4 deletions examples/class_method_example.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ def self.star_count_for_tokenautocomplete

GitHubApiAdapter.star_count_for_cacheable
# Fetching data from GitHub for cacheable
# => 19
# => 58
GitHubApiAdapter.star_count_for_cacheable
# => 19
# => 58

GitHubApiAdapter.star_count_for_tokenautocomplete
# Fetching data from GitHub for tokenautocomplete
# => 1164
# => 1309
GitHubApiAdapter.star_count_for_tokenautocomplete
# => 1164
# => 1309
25 changes: 13 additions & 12 deletions examples/conditional_example.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,33 @@
class GitHubApiAdapter
include Cacheable

cacheable :star_count, unless: :growing_fast?, key_format: ->(target, method_name, method_args) do
[target.class, method_name, method_args.first].join('/')
cacheable :star_count, unless: :growing_fast?, key_format: ->(target, method_name, method_args, **kwargs) do
date = kwargs.fetch(:date, Time.now.strftime('%Y-%m-%d'))
[target.class, method_name, method_args.first, date].join('/')
end

def star_count(repo)
puts "Fetching data from GitHub for #{repo}"
def star_count(repo, date: Time.now.strftime('%Y-%m-%d'))
puts "Fetching data from GitHub for #{repo} (as of #{date})"
url = "https://api.github.com/repos/splitwise/#{repo}"

JSON.parse(Net::HTTP.get(URI.parse(url)))['stargazers_count']
end

def growing_fast?(_method_name, method_args)
def growing_fast?(_method_name, method_args, **)
method_args.first == 'cacheable'
end
end

a = GitHubApiAdapter.new
a.star_count('tokenautocomplete')
# Fetching data from GitHub for tokenautocomplete
# => 1164
# Fetching data from GitHub for tokenautocomplete (as of 2026-02-26)
# => 1309
a.star_count('tokenautocomplete')
# => 1164
# => 1309

a.star_count('cacheable')
# Fetching data from GitHub for cacheable
# => 19
# Fetching data from GitHub for cacheable (as of 2026-02-26)
# => 58
a.star_count('cacheable')
# Fetching data from GitHub for cacheable
# => 19
# Fetching data from GitHub for cacheable (as of 2026-02-26)
# => 58
Loading