ruby-api-design
Use when designing Ruby methods, classes, services, or library boundaries whose public inputs, outputs, errors, visibility, or compatibility contract matter.
Files2
View on GitHubSKILL.mdskills/ruby-api-design/SKILL.md
GitHubRuby API Design
Purpose
Make public Ruby APIs predictable for both callers and future maintainers.
Activate when
- introducing a public method or class
- changing parameters or return values
- designing a service/gem/library boundary
- callers branch on result types
- an API is used from multiple places
- compatibility with existing callers matters
Repository inspection
Inspect:
- all current callers
- tests and fixtures
- visibility
- existing error/result conventions
- supported Ruby version
- documentation/examples if the API is externally consumed
Decision rules
Prefer:
- explicit required arguments
- keyword arguments for semantically named options
- a predictable return type or narrow family such as Thing or nil
- domain-specific exceptions or result objects when multiple failure outcomes matter
- private helpers for implementation detail
Avoid:
- unrelated return types such as object/hash/false/string
- boolean flags that switch the public method into unrelated modes
- exposing internal hashes when a domain object has stable meaning
- changing a public return contract merely to simplify implementation
Module depth
Treat every public API as the interface of a module: a method, class, gem, or engine. Callers must learn everything the interface exposes: arguments, return family, errors, ordering constraints, and required configuration.
- Prefer deep modules: a small interface over a large amount of behavior. A shallow module whose interface is nearly as complex as its implementation adds indirection without leverage.
- Deletion test: imagine deleting the module and inlining it into its callers. If complexity reappears across several callers, the module earns its keep; if it vanishes, it was a pass-through.
- The interface is the test surface. Callers and tests cross the same seam. A test that has to reach past the interface signals the wrong shape.
- One adapter is a hypothetical seam; two is a real one. Do not add an injectable port or strategy until at least two implementations exist, typically production and test.
Load references/deep-modules.md when deciding what a module should hide, where its seam goes, how to test it through its dependencies, or when comparing alternative interfaces.
Compatibility procedure
- identify the current contract
- list callers and observable behavior
- define the intended contract
- update tests at the public boundary
- change implementation
- verify failure and edge behavior
- inspect downstream callers
Failure modes
- accidental return-type widening
- exceptions introduced where callers expect nil/results
- optional parameters that silently alter old behavior
- public methods exposing internal state
- undocumented keyword/positional compatibility changes
- changing visibility during refactoring
- shallow wrappers that forward to one collaborator and fail the deletion test
- an injectable port with a single implementation and no test double
Reference example
A small public API surface with explicit keyword arguments, private collaborators, and a documented deprecation path.
class ExchangeRate
def initialize(fetcher:) # collaborator injected, not global
@fetcher = fetcher
end
def convert(amount, from:, to:, at: :latest)
rate = @fetcher.rate(from, to)
(amount * rate).round(2)
end
# Deprecated bridge kept for one minor release, then removed.
def convert!(amount, from, to)
warn "convert! is deprecated; use convert(amount, from:, to:)"
convert(amount, from: from, to: to)
end
private attr_reader :fetcher
end
rates = Struct.new(:table) do
def rate(from, to) = table[[from, to]]
end.new({ [:usd, :eur] => 0.92 })
api = ExchangeRate.new(fetcher: rates)
raise "wrong conversion" unless api.convert(100, from: :usd, to: :eur) == 92.0
puts api.convert(250, from: :usd, to: :eur)
Agent review checklist
- public contract is explicit
- arguments are meaningful
- return behavior is predictable
- failures have an intentional contract
- visibility is deliberate
- module passes the deletion test; no pass-through layer
- every seam has at least two real adapters, or is not a seam
- callers were checked
- compatibility tests exist where needed
References
Load only when needed; the reference is one level deep. Consult a listed pattern only when the change needs its implementation shape.
| Load when | Reference | Covers | Patterns |
|---|---|---|---|
| deciding what a module hides, where its seam goes, how to test it through its dependencies, or comparing interfaces | references/deep-modules.md | Vocabulary; depth and leverage; dependency categories; replace-don't-layer testing; designing the interface twice | dependency-injection, adapter |
Verification
Test successful results, invalid inputs, failure behavior, nil/empty behavior where applicable, and representative callers. For library APIs, verify the documented entry point independently of implementation classes.
Source foundation
Grounded in the method, argument, return-value, and class API guidance in Learn Rails 6, with responsibility and readability principles from Clean Ruby.
Module depth, the deletion test, the one-adapter rule, dependency categories, and designing an interface twice are adapted, in this repository's words and with Ruby examples, from the codebase-design skill in https://github.com/mattpocock/skills (MIT License, Copyright (c) 2026 Matt Pocock), which builds on John Ousterhout's A Philosophy of Software Design and Michael Feathers' seams. This repository keeps "boundary" as an allowed word.