skip to content

In a Ruby codebase, what team policy would you set for monkey-patching core classes like Integer and String, and why?

level: principalimportance: should knowfreq 25%

answer

  1. prefer no patch at all
  2. libraries: opt-in core_ext require
  3. named modules, prepended
  4. fail loudly if the name exists
  5. CI with -w and -W:performance

basics

~20 s

Default to not patching: helpers, wrapper objects or refinements. A justified global patch lives in one core_ext file per class as a named, prepended module that raises if the name exists, pinned by tests and checked in CI with -w and -W:performance.

solid answer

~50 s

I would make a core patch the exception that needs a reason. The ladder is: a plain helper or value object first; a refinement when the syntax really matters and the callers are few; a global patch only when the whole process must see the behaviour. Global patches live in a `core_ext/` directory, one file per class, each a **namespaced module** applied with `prepend`, so the owner is visible in `ancestors` and backtraces. Each checks `method_defined?` at load and raises rather than silently overwriting or skipping. Libraries never patch on `require` of the main file; they offer an opt-in `core_ext` require. Tests pin every patched behaviour, and CI boots once with `-w` and `-W:performance` and fails on `method redefined` or core-redefinition warnings. There is no single right policy: a script or small app can relax it, a shared library must be strict.

go deeper

for a junior

Recall that core patches affect the whole process, so teams usually allow them only in a few reviewed files.

for a middle

Explain the tools a policy relies on: named prepended modules, method_defined? checks, and the -w and -W:performance warnings.

for a senior

Describe how you would enforce the policy in CI and review, and how you would retire a patch after an upstream fix.

for a principal

Tie strictness to blast radius, justify the cost of wrappers and fail-loud guards, and set different rules for apps and published gems.

## Why a policy is needed Ruby lets any file reopen `Integer` or `String` and change them for the whole process. That power is cheap to use and expensive to debug: patches collide when two libraries define the same method, alias chains recurse, patches to optimized operators slow down every caller, and upgrades change load order. A team policy turns an individual convenience into a reviewed decision. There is no single correct answer. The right strictness depends on who else runs your code: a one-off script can patch freely, an application team needs reviewable rules, and a published gem must assume it shares the process with code it has never seen. ## A preference ladder 1. **No patch.** A module function (`Durations.minutes(5)`) or a small value object with its own methods gives the same capability without touching core classes. 2. **A refinement.** When the call syntax (`5.minutes`) really matters and the callers are few, a refinement limits the change to files that activate it. Refinements have their own limits and are covered separately. 3. **A global patch.** Only when the entire process needs the behaviour, for example to fix a bug in a core or standard-library method until an upgrade. ## Rules for the patches that remain - **One place.** Every patch lives under `core_ext/`, one file per patched class (`core_ext/integer.rb`), loaded explicitly at boot. Nothing hides in a model file. - **Named modules, prepended.** Write `module MyApp::CoreExt::IntegerMinutes` and apply it with `Integer.prepend`. The module name shows up in `Integer.ancestors` and in backtrace labels, so anyone can see who changed the method, and `super` reaches the original. - **Fail loudly on collision.** Before adding a new method, check `Integer.method_defined?(:minutes)` and raise if it is already there. A silent `unless` guard hides the conflict; a boot-time error names it. - **No alias chains** on methods a dependency may also patch; they recurse when mixed with `prepend`. - **Never patch core operators** such as `Integer#+` or `String#==` without a benchmark; since Ruby 3.4 CRuby reports it under `-W:performance` because it disables interpreter and JIT shortcuts. - **Every patch has a test** that asserts its behaviour, and a comment naming why it exists and when it can go (for example, the upstream fix it is waiting for). ## Rules for libraries | Concern | Application code | Published gem | |---|---|---| | Adding core methods | allowed with review | only in an opt-in `require "mygem/core_ext"` | | Fixing a core bug | allowed, with removal note | avoid; document the workaround instead | | Changing existing core behaviour | exceptional, benchmarked | not acceptable | A gem that patches on its main `require` forces the patch on every application that installs it, including ones with a conflicting patch. ## Enforcement in CI - Boot the application once with `ruby -w` (or `RUBYOPT=-w`) and fail on `method redefined; discarding old`, which reveals two definers of one method. - Boot once with `-W:performance` and fail on `disables interpreter and JIT optimizations`. - After every dependency upgrade, the pinning tests run before anything ships. - Code review rejects new files that reopen core classes outside `core_ext/`. ## Retiring patches A patch policy also needs an exit. Each patch comment names the condition for deleting it (an upstream release, a dependency removal), and a periodic review, for example with every Ruby upgrade, walks `core_ext/` and deletes what is no longer needed. The pinning tests make that safe: delete the patch, and if the tests still pass on the new release, it was obsolete. ## The tradeoffs a lead owns The policy costs something: wrapper objects are more verbose, refinements confuse newcomers, and fail-loud guards can block an upgrade until someone decides. A principal-level answer states which of those costs the team accepts, and ties strictness to the blast radius — how much code outside the team runs in the same process.

  • Why raise on an existing method name instead of skipping the patch with `unless method_defined?`?
    Skipping lets the first definer win silently, so your code runs against a method it did not write and fails later somewhere unrelated. Raising at boot names the collision immediately, and the team then decides which definition to keep.
  • When is a global core patch the right choice despite the policy?
    When the behaviour must apply to code you do not control, such as a bug in a core or standard-library method that affects dependencies too. Then a scoped refinement is not enough; the patch goes in `core_ext/`, carries a test and a note naming the upstream fix that will let it be deleted.

saying these in an interview costs you the question

  • Refinements make global core patches unnecessary in every case.
  • A gem should apply its core patches as soon as it is required.
  • A silent method_defined? guard is the safe default for collisions.
  • Patches can live anywhere as long as they have tests.