The Adapter+Fake Pattern
Why every external integration ships a real client and a deterministic fake, and how to add your own.
Never call a third-party API directly from a controller or a model. Every external integration in
One Shot, billing, email, bot checking, App Store and Play Store receipt verification, is wrapped
in the same seam: a real client when credentials are present, and a deterministic fake otherwise.
This is the single mechanism that lets a fresh clone of the kit run its entire feature set with
zero external accounts, and it's the pattern to copy when you add a new one. The fastest way is the
add-integration skill in Claude Code.
The reference implementations
app/adapters/billing.rb is the full example (with billing/local.rb and billing/stripe.rb
alongside it). app/adapters/bot_check.rb is the simpler one, worth reading first if you want the
shape without the extra complexity billing has.
Building a new adapter
- Create
app/adapters/<service>.rbas a module with an entry point, typically.for(...)or.client, that returns the real client when the service's credential environment variable is present, and a fake otherwise. Resolve credentialsENVfirst, thenRails.application.credentials.dig(...)as a fallback. - Put the real client and the fake in
app/adapters/<service>/. The real client talks to the actual API, usingNet::HTTPor the vendor's gem. The fake returns sensible canned values with no network call at all, and it has to be complete enough that the whole feature works against it, not just enough to avoid raising. - Honor a force-fake override, following the pattern in
Billing#stripe?, so demos and CI can run entirely on fakes even when real credentials happen to be configured. - Fail sensibly, on purpose. Rescue and report errors through
ErrorReporter.report(e, context: {...}), and decide deliberately whether this integration should fail open or fail closed.BotCheck#verifyfails closed: if the check can't run, it treats the request as suspicious rather than waving it through. - Document the credential in Bring Your Own Keys
and in
.env.example, so the next person setting this up knows what to provide.
If the integration sends the user off-site
A hosted checkout, an OAuth consent screen, a hosted KYC flow: anything that redirects the browser to another domain needs two more changes, and both fail silently if you miss them.
Content Security Policy. The CSP is enforced, and form-action covers the entire navigation a
form starts, including the redirect a POST triggers. Add the provider's host to policy.form_action
in config/initializers/content_security_policy.rb, and pin it in
spec/requests/content_security_policy_spec.rb. Miss this and the button does nothing: no
exception, no flash, nothing in the network tab, just a link that appears dead. See
SEO and Structured Data for the CSP's other
implications on this site.
data-turbo="false". A form whose action redirects off-site needs this attribute, on the form
or the submit button (Turbo checks both). Without it, Turbo submits the form via fetch and tries
to follow the cross-origin redirect as an XHR, which connect-src blocks: the same dead-looking
button, a different cause. spec/requests/cross_origin_forms_spec.rb is where this is pinned.
Testing
Test both paths: the fake path, which should work with no credentials configured at all, and the
enabled-but-invalid path, where credentials are present but the service rejects them.
spec/adapters/bot_check_spec.rb is the reference spec.
Verify
bin/check should be green.
Next
If the new integration involves money, read How Billing Works for how the billing adapter specifically handles the real-versus-fake split.