In-Memory Caching

Authorization checks happen constantly — every controller action, every view helper, every allowed_to? call. When your policy rules hit the database, this adds up fast. Action Policy includes a multi-layered caching system to keep things snappy.

The N+1 Authorization Problem

Imagine rendering a product list with edit buttons:

<% @products.each do |product| %>
<%= product.name %>
<% if allowed_to?(:update?, product) %>
<%= link_to "Edit", edit_product_path(product) %>
<% end %>
<% end %>

If update? queries the database (say, checking team membership or subscription status), you get one query per product. With 50 products, that’s 50 extra queries on every page load.

Three Layers of Caching

Action Policy solves this with three caching layers, applied automatically as you build up from basic usage to production-ready policies.

Layer 1: Per-Instance Memoization

The simplest layer is policy instance reuse. When you call authorized? or allowed_to? multiple times for the same record, Action Policy returns the same policy instance rather than creating a new one.

This is handled by ActionPolicy::Behaviours::Memoized, which is automatically included in Rails controllers. The memoization key uses policy_cache_key if available, then cache_key, then falls back to object_id:

# These reuse the same ProductPolicy instance
allowed_to?(:show?, product)
allowed_to?(:update?, product) # same instance
allowed_to?(:destroy?, product) # same instance

Layer 2: Per-Rule Caching

Within a single policy instance, rule results are memoized. Once update? is evaluated, calling it again on the same instance returns the cached result immediately — no re-execution of the rule body.

This is built into ActionPolicy::Base via CachedApply. It means this view code:

<% if allowed_to?(:update?, product) %>
<%= link_to "Edit", edit_product_path(product) %>
<% end %>
<% if allowed_to?(:update?, product) %>
<%= link_to "Quick Edit", edit_product_path(product, quick: true) %>
<% end %>

…evaluates update? only once per policy instance, regardless of how many times you call it.

Layer 3: Persistent Caching with cache

Layers 1 and 2 only last for the duration of a single request. For expensive rules that rarely change, you can persist results across requests using cache:

class ProductPolicy < ApplicationPolicy
cache :update?
def update?
# This DB query is cached across requests
user.teams.exists?(product_id: record.id)
end
end

The cached result is stored in the policy cache store (an in-memory store by default) and reused until the cache key changes or the entry expires.

Adding Caching to ProductPolicy

Open . The current show? rule checks whether a product is published — a database query that runs on every page listing products:

def show?
record.published? || record.user_id == user&.id
end

Add cache :show? to persist this result across requests:

class ProductPolicy < ApplicationPolicy
cache :show?
relation_scope do |relation|
if user&.admin?
relation
else
relation.where(published: true)
end
end
def index?
true
end
def show?
record.published? || record.user_id == user&.id
end
def create?
user.present?
end
alias_rule :new?, to: :create?
def update?
user.present?
end
alias_rule :edit?, :destroy?, to: :update?
end

How the Cache Key Works

Action Policy builds cache keys from multiple components to ensure correctness:

"action_policy/{namespace}/{user_cache_key}/{record_cache_key}/{policy_class}/{rule}"

For example:

"action_policy//users/42-20240315/products/7-20240310/ProductPolicy/show?"

This means:

  • Different users get different cache entries
  • Updating a record (changing updated_at) automatically invalidates its cache entries
  • Different rules are cached independently

Customizing policy_cache_key

If your model doesn’t use standard cache_key (or you want a more specific key), define policy_cache_key on the model:

class User < ApplicationRecord
def policy_cache_key
"#{id}/#{role}/#{updated_at.to_i}"
end
end

This is useful when the cache validity depends on fields that aren’t captured in the default cache_key (which uses id and updated_at).

Seeing It In Action

Verify caching in the Rails console:

Terminal window
$ bin/rails console
store(dev)> product = Product.first
store(dev)> user = User.find_by(email_address: "user@example.com")
store(dev)> policy = ProductPolicy.new(product, user: user)
=> #<ProductPolicy ...>
# First call — evaluates the rule and caches result
store(dev)> policy.apply(:show?)
=> true
# Second call — returns memoized result immediately (Layer 2)
store(dev)> policy.apply(:show?)
=> true
# Check the result object
store(dev)> policy.result.cached?
=> true

The cached? method on the result tells you whether the rule result came from cache.

Now add cache :show? to your ProductPolicy using the solution button, or type it in yourself!

Powered by WebContainers
Files
Preparing Environment
  • Preparing Ruby runtime
  • Prepare development database
  • Starting Rails server