StrictLazy
Strict, explicit preloading for computed values — values that includes /
preload cannot express (external APIs, window functions, cross-table
aggregates). strict_lazy applies the spirit of Rails' strict_loading to those
values: it forces you to preload them explicitly in the controller, and raises
in development/test if you read one without preloading — instead of silently
falling back to N+1.
No batch-loader / N1Loader / ar_lazy_preload dependency. Just
activesupport.
Why
A naive view helper can't "register before the first access", so it quietly
degrades to N+1. Auto-batching gems fix the N+1 but hide the missing preload.
strict_lazy takes the opposite stance — make the preload mandatory and make
forgetting it loud — so every query stays in the controller and view rendering
issues no hidden queries.
| Tool | Target | On unloaded access |
|---|---|---|
includes / preload |
associations only | lazy load (can N+1) |
strict_loading (Rails) |
associations only | raise |
| batch-loader | general | auto-batch, no detection |
| N1Loader (+ar_lazy_preload) | computed values | auto-batch; plain setup silently N+1s |
strict_lazy |
computed values | raise (immediate detection) |
Installation
gem "strict_lazy"
Quick start
Define a resolver, declare the value with lazy_load, preload in the
controller, and read via .lazy.
class Post < ApplicationRecord
include StrictLazy
# Block resolver: receives (records, loader); call loader.call(record, value)
# for each record you fulfill. Posts with zero comments never appear in the
# GROUP BY, so they fall back to default: 0.
lazy_load :comments_count, default: 0 do |posts, loader|
by_id = posts.index_by(&:id)
Comment.where(post_id: by_id.keys).group(:post_id).count.each do |post_id, n|
loader.call(by_id[post_id], n)
end
end
# from: resolver — a named class method, defined BEFORE the lazy_load. Good for
# complex/reusable resolvers; dedup FKs yourself.
def self.resolve_avatar(posts, loader)
urls = AvatarService.bulk_fetch(posts.map(&:author_id).uniq)
posts.each { |p| loader.call(p, urls[p.]) }
end
lazy_load :avatar, from: :resolve_avatar, sync: true
end
# controller
@posts = Post.recent.to_a
StrictLazy.preload(@posts) # all declared loaders
# StrictLazy.preload(@posts, :avatar) # or just some
<%= post.lazy.comments_count %>
<img src="<%= post.lazy.avatar %>">
Eager vs lazy (sync:)
sync: false(default): resolution is deferred to the first.lazyread, then the whole preloaded group is resolved in one shot and memoized.sync: true: resolved eagerly atStrictLazy.preloadtime.
Strict detection & violation
Reading a value that was never preloaded triggers the violation policy:
| mode | behavior |
|---|---|
:raise |
raise StrictLazy::UnloadedError (no wasted query) |
:log |
Rails.logger.warn, then degrade to a single-record resolve |
:ignore |
silently degrade to a single-record resolve (N+1) |
Environment defaults (via the Railtie):
- development / test →
:raise - production →
:ignore
Override globally with StrictLazy.violation = :log, or in Rails with
config.strict_lazy.violation = :log.
Scoped overrides — with_violation
StrictLazy.with_violation(mode) { ... } overrides the effective policy for the
duration of the block, then restores the previous state — even if the block
raises. Overrides nest (an inner call shadows the outer one) and are scoped to
the current Fiber/Thread, so parallel test processes never interfere.
StrictLazy.with_violation(:ignore) do
record.lazy.x # degrades to a single-record resolve instead of raising
end
The three APIs relate as: StrictLazy.violation= sets the global baseline,
with_violation applies a scoped override, and the StrictLazy.violation
reader returns the effective value (innermost override, else the baseline).
Per-test policy in RSpec
strict_lazy ships no implicit RSpec hook — wire it up explicitly so the policy
is visible where it applies. A common setup: model specs don't need preloads
(:ignore), while system/request specs keep the strict baseline (:raise).
# spec/rails_helper.rb
RSpec.configure do |config|
config.around(:each, type: :model) do |example|
StrictLazy.with_violation(:ignore) { example.run }
end
end
To relax only a few examples, drive the around off a tag instead:
RSpec.configure do |config|
config.around(:each, :ignore_lazy) do |example|
StrictLazy.with_violation(:ignore) { example.run }
end
end
it "computes something", :ignore_lazy do
# ...
end
Defaults
default: is written for any record the resolver does not fulfill. Pass a
callable for per-record defaults so mutable values are never shared:
lazy_load :tags, default: -> { [] } do |records, loader| ... end # arity 0
lazy_load :slug, default: ->(record) { "p-#{record.id}" } do ... end # arity 1
A static value (default: 0) is written as-is.
Design notes
- Preload is mandatory. Detection happens on first access; dev/test raise so you catch it immediately.
- Resolvers are set-level. A resolver runs over the whole group, not one
record. FK dedup/mapping is the resolver's responsibility (there is no
key:). - Scope the records.
preloadshould cover the collection once; the same loader preloaded on overlapping groups resolves more than once. - Definition order (for
from:). Define the referenced class method before thelazy_loaddeclaration. Block resolvers have no such constraint. - Values live on the record (
@_lazy_<reader>) and are GC'd with the request — no thread-local cache, no middleware. - Predicate readers.
lazy_load :published?is supported and read asrecord.lazy.published?; the?is encoded in the ivar (@_lazy_published_pred) so it does not collide with a plainpublishedreader. A reader must be a bare name or a?predicate — the read-only.lazynamespace rejects setter (=), bang (!), and operator reader names at declaration time.
Non-Rails usage
Works without Rails: include StrictLazy, declare with lazy_load, call
StrictLazy.preload(records), read via record.lazy.x. Set the policy yourself
with StrictLazy.violation = :raise (the default).
Where it fits
Use includes / strict_loading for associations, bullet to detect N+1
in associations, and strict_lazy for computed values you want preloaded
explicitly and checked strictly.
Development
After checking out the repo, run bin/setup. Then bundle exec rake runs specs
and RuboCop. bundle exec appraisal install && bundle exec appraisal rake spec
runs the full Rails matrix.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/aki77/strict_lazy.
License
Available as open source under the terms of the MIT License.