Designing the surface other code imports. A module's public API is a promise: every method you expose is a contract you keep for every caller, through every refactor, until a major version lets you break it. This chapter covers exporting the least, shaping what you do export so the call site reads as a family, and refusing to let raw or undeclared values cross the boundary in either direction.
# frozen_string_literal: true
# typed: strict
module Commerce
module Pricing
extend T::Sig
# Public surface: three methods. Everything else is private.
sig { params(order: Order).returns(Money) }
def self.subtotal(order:)
raise ArgumentError, "order has no lines" if order.lines.empty?
order.lines.reduce(Money.new(cents: 0)) do |acc, line|
acc.add(line_total(line: line))
end
end
sig { params(order: Order, coupon_code: String).returns(Money) }
def self.discounted_total(order:, coupon_code:)
base = subtotal(order: order)
rate = discount_rate(coupon_code: coupon_code)
result = base.multiply(1 - rate)
raise ArgumentError, "discounted total must be non-negative" unless result.cents >= 0
result.freeze
end
sig { params(order: Order).returns(T::Hash[String, T.untyped]) }
def self.to_h(order:)
{
"subtotal_cents" => subtotal(order: order).cents,
"line_count" => order.lines.size,
}.freeze
end
class << self
extend T::Sig
private
sig { params(line: LineItem).returns(Money) }
def line_total(line:)
line.price.multiply(line.quantity)
end
sig { params(coupon_code: String).returns(Float) }
def discount_rate(coupon_code:)
DISCOUNT_RATES.fetch(coupon_code) do
raise KeyError, "unknown coupon code: #{coupon_code.inspect}"
end
end
DISCOUNT_RATES = T.let(
{ "SAVE10" => 0.10, "SAVE20" => 0.20 }.freeze,
T::Hash[String, Float],
)
end
end
endThe public surface is exactly three methods: subtotal, discounted_total, and to_h (10.1). Every public method carries a sig (10.2). Arguments are keyword-only, so call sites read as Pricing.subtotal(order: order) without positional guesswork (10.3). The module accepts the narrowest duck-typed input — Order is a value object — and returns frozen concrete values; to_h returns a frozen hash so callers cannot reach back into module state (10.4). Raw String coupon codes are resolved inside the module through a fetch that raises on miss, never passed deeper as untyped data (10.5). subtotal/to_h form a symmetric serialization pair: one produces the canonical value, the other emits a hash representation (10.7). Guard assertions appear before the happy path on both public methods (10.2, 10.8).
10.1 — Minimize the public surface: everything that is not part of the contract is private or protected.
Reasoning, step by step:
- Every public method is a promise. Once a caller depends on a method, removing or renaming it is a breaking change — it demands a major-version bump and a migration note for every consumer (10.8). A smaller surface is fewer promises to keep.
- The correct default is private. Define a method without visibility; then ask whether any caller outside this module needs it. If the answer is no, add
private. Promote to public only when a genuine external need exists. - This is especially sharp in Ruby because everything is public by default. Explicit
private/protecteddeclarations are the only fence between "implementation detail" and "API contract." Without them, every helper method is implicitly published. - A module with a large public surface invites callers to depend on internals. Each dependency you did not intend is a refactor you cannot safely do.
Worked example:
module Inventory
extend T::Sig
sig { params(sku: Sku).returns(Integer) }
def self.available_count(sku:) # public — part of the contract
reserved = reserved_count(sku: sku)
on_hand(sku: sku) - reserved
end
class << self
extend T::Sig
private
sig { params(sku: Sku).returns(Integer) }
def on_hand(sku:) # private — implementation detail
LEDGER.fetch(sku, 0)
end
sig { params(sku: Sku).returns(Integer) }
def reserved_count(sku:) # private — implementation detail
RESERVATIONS.fetch(sku, 0)
end
end
endEnforcement: code review; every non-private method in a module is treated as a published contract and reviewed as one.
Reasoning, step by step:
- A public method without a
sigis an undocumented promise. Callers must read the body to know what it accepts and what it returns; there is no machine-readable statement of the contract. - Under
# typed: strict, Sorbet wraps every method that carries asigand checks arguments and the return value at runtime on every call. The sig is not documentation — it is an assertion that fires on every invocation, far denser than any hand-written precondition. - Inference is for locals. Inside a method body, Sorbet can infer local types from assignments; that is fine, because the definition and the use are in the same file and any wrong inference is caught immediately. Across a module boundary, inferred types are invisible to callers and cannot catch a wrong argument until the call fails at runtime — the wrong runtime. The sig makes the boundary explicit.
# typed: strictrejects any method without asig; this rule is therefore mechanically enforced for every method in the repository. Public methods have the added obligation of accuracy: the sig must match the semantics, not just compile.
Enforcement: srb tc under # typed: strict rejects any method without a sig. Code review confirms the sig accurately reflects the method's semantics and failure modes.
10.3 — Use keyword arguments on every public method; positional arguments are order-dependent and backward-incompatible to extend.
Reasoning, step by step:
- A positional argument is a position: callers must know the order, and adding a new required positional anywhere but the end is a breaking change. A keyword argument is a name: callers pass it by name, order is irrelevant, and adding a new keyword with a default is always backward-compatible.
- Call sites with keyword arguments are self-documenting.
Pricing.discounted_total(order: order, coupon_code: code)names each argument where it is passed;Pricing.discounted_total(order, code)is a positional guessing game the reader resolves by opening the signature. - Keyword arguments also prevent transposition bugs.
process(customer, order)andprocess(order, customer)compile identically when both arguments share a type;process(customer:, order:)makes the swap aNoMethodErrorbefore a test runs. - The only sanctioned positional argument is the implicit
selfreceiver. All other arguments are keywords. For existing APIs, adding keyword alternatives before removing positionals satisfies the no-weakening principle (see 10.8).
Worked example:
# bad — positional; adding a third arg breaks every call site
sig { params(order: Order, discount: Money).returns(Money) }
def self.apply_discount(order, discount) = ...
# good — keyword; backward-compatible to extend with new optional keywords
sig { params(order: Order, discount: Money).returns(Money) }
def self.apply_discount(order:, discount:) = ...Enforcement: RuboCop Style/ArgumentsForwarding and code review; public method signatures using positional arguments (other than the receiver) are rejected.
Reasoning, step by step:
- A method's input contract should be the narrowest shape it actually uses. A method that reads an order's lines and status should accept anything that responds to
#linesand#status— not a concreteOrderclass, if a test double or a lightweight struct also satisfies the usage. The narrower the input contract, the easier the method is to call from tests and from future callers. - Return the opposite: the fully-known, concrete thing the method actually produces, frozen before it leaves. Returning a wide type (an interface, an abstract module, a
T::Hash[Symbol, T.untyped]) pushes a narrowing burden onto every call site. Return the concrete type; let the sig name it. - Freeze every collection, hash, and struct before returning it. Callers must not be able to mutate your return value and corrupt the module's internal state.
frozen?beingtrueon a returned collection is a runtime guarantee that the module's state is not shared. - This rule pairs with 3.9 (parse, don't validate): accept parsed typed values in, return frozen typed values out. The module is never the place where raw input is first interrogated.
Worked example:
# bad — accepts more than needed; couples callers to a concrete class
sig { params(order: Order).returns(T::Array[Money]) }
def self.line_prices(order:)
order.lines.map(&:price) # returns a mutable array
end
# good — accepts duck type; returns frozen concrete collection
sig { params(lines: T::Array[LineItem]).returns(T::Array[Money]) }
def self.line_prices(lines:)
lines.map(&:price).freeze
endEnforcement: code review; returned collections must call .freeze before they leave a public method; input types are widened to duck-typed modules where possible (see 06-classes-and-data-modeling.md).
10.5 — Parse at the boundary: validate raw input once into a value object; never pass a raw Hash or scalar deeper into the system.
Reasoning, step by step:
- Raw input — a
Hashfrom JSON, aStringfrom a form param, anIntegerfrom a query string — is unproven. Every method that receives it must either trust it blindly or duplicate validation. Duplication means divergent rules, silent bugs, and a codebase that checks the same invariant in ten places and gets it wrong in two. - Parse once, at the entry point, into a typed value object (
Money.parse, aT::Struct, aData.definerecord). Every downstream method takes the typed form and can trust it without re-checking. The type is the proof; it travels with the value (see 3.9). - A
Hashcrossing a module boundary is a failure to parse. The callee cannot see the hash's shape without reading the caller; the sig lies (T::Hash[Symbol, T.untyped]is not a contract, it is an acknowledgement of defeat). Replace it with aT::StructorData.definethat names and types each field. - Parse-constructors are the only place where
T.untypedappears in a signature. They are namedparseby convention, take raw input, validate fully, and return a typed immutable value. Every other method in the system takes the typed form.
Worked example:
# bad — raw Hash passes through; callee must fetch and trust
sig { params(raw: T::Hash[Symbol, T.untyped]).returns(Money) }
def self.total_from_hash(raw:)
price = T.cast(raw.fetch(:price), Integer)
quantity = T.cast(raw.fetch(:quantity), Integer)
Money.new(cents: price * quantity)
end
# good — parse at the boundary; downstream takes typed LineItem
sig { params(raw: T.untyped).returns(LineItem) }
def self.parse_line_item(raw:)
LineItem.new(
sku: Sku.parse(raw.fetch("sku")),
price: Money.parse(raw.fetch("price_cents")),
quantity: Integer(raw.fetch("quantity")),
)
end
sig { params(line: LineItem).returns(Money) }
def self.line_total(line:)
line.price.multiply(line.quantity) # no validation; the type carries the proof
endEnforcement: code review rejects T::Hash[Symbol, T.untyped] in any non-boundary method signature; T.untyped in a sig is permitted only in named parse constructors (see 03-type-safety-and-nil-discipline.md).
10.6 — Do not return nil from a public method to mean "absent" where an empty collection or a raised error is clearer; when nil is legitimate, type it T.nilable and document it.
Reasoning, step by step:
nilas a sentinel is a lie: the caller asked for aMoneyand receivedNilClass. Every caller must then check; every forgotten check is aNoMethodErrorin production. A method that cannot produce a value has three honest choices: raise a typed error, return an empty collection, or return a discriminated type. None of these choices isnil.- An empty collection is nearly always clearer than
nilfor "no results."[].eachis safe;nil.eachis not. A caller iterating results never needs to nil-check the collection itself. - When raising is the right choice — the absence is exceptional, not normal — raise a typed
StandardErrorsubclass with a message. The caller that wants to handle the absence catches the specific class; the caller that treats it as a bug lets it propagate (see 08-error-handling.md). T.nilablein a return type is a published claim thatnilcarries domain meaning. Audit every occurrence: if you cannot explain whatnilmeans to a domain expert — not "the query returned nothing" but "the order has no applied coupon" — thennilshould not be in the return type. Where it is legitimate, document whatnilmeans in a YARD comment above thesig.
Worked example:
# bad — nil as sentinel; every caller nil-checks before using
sig { params(order_id: OrderId).returns(T.nilable(Order)) }
def self.find(order_id:)
STORE[order_id]
end
# good — raise on miss; return type is non-nilable
sig { params(order_id: OrderId).returns(Order) }
def self.fetch(order_id:)
STORE.fetch(order_id) { raise KeyError, "order not found: #{order_id}" }
end
# also good — nil is a domain value (coupon is genuinely optional)
# @return [T.nilable(Money)] the applied discount, or nil if no coupon was applied
sig { params(order: Order).returns(T.nilable(Money)) }
def self.applied_discount(order:)
return nil unless order.coupon_code
Money.parse(DISCOUNT_TABLE.fetch(order.coupon_code))
endEnforcement: code review questions every T.nilable in a return type; srb tc propagates T.nilable to all call sites, making every unchecked use a type error (see 03-type-safety-and-nil-discipline.md).
Reasoning, step by step:
- Operations that do parallel things should read as variations on one form. When
encode/decode,parse/to_h, andopen/closeshare naming patterns, argument shapes, and return conventions, a caller learns the family once and predicts the rest. Asymmetry —parseon one side andserialize_to_hashon the other — forces the caller to relearn each method. - Symmetry is concrete: paired verbs (
parse/to_h,encode/decode,acquire/release), the same keyword argument names across a family, the same return type for parallel operations, and consistent error contracts (ifparseraisesArgumentError,encoderaisesArgumentErrortoo, notRuntimeError). - Asymmetry that exists for a reason — a one-way operation, a method that has no inverse — is documented as such. The absence of a pair is a conscious design decision, not an oversight.
- Name symmetry extends to resource pairs: if a module exposes
open, it exposesclose; if it exposesbegin_transaction, it exposescommitandrollback. Callers should never be left holding a resource with no documented way to release it.
Worked example:
module Serializer
extend T::Sig
sig { params(raw: T.untyped).returns(LineItem) }
def self.parse(raw:) # inbound: raw → typed
LineItem.new(
sku: Sku.parse(raw.fetch("sku")),
price: Money.parse(raw.fetch("price_cents")),
quantity: Integer(raw.fetch("quantity")),
)
end
sig { params(line: LineItem).returns(T::Hash[String, T.untyped]) }
def self.to_h(line:) # outbound: typed → raw — symmetric verb pair
{
"sku" => line.sku.to_s,
"price_cents" => line.price.cents,
"quantity" => line.quantity,
}.freeze
end
endEnforcement: code review; the API surface is reviewed as a family — verb pairing, argument shape, and error contract consistency are checked together.
10.8 — Deprecate deliberately: mark with a runtime warning and a @deprecated YARD tag; remove only on the next major version; version by semver.
Reasoning, step by step:
- A public method cannot simply vanish — removing it breaks every caller with a
NoMethodErrorthey discover at runtime. Removal is a two-release process: mark the old method@deprecated, keep it as a thin delegation for one full major cycle, then delete it on the next major bump. - The
@deprecatedYARD tag is load-bearing: IDEs and documentation tools surface it at every call site. Pair it with awarnin the method body so callers see the warning in logs even if they do not read documentation. The warning message names the replacement and the removal version. - The version math is semver, non-negotiable. Removing or renaming a public method, narrowing a parameter type, or changing return semantics is a breaking change and demands a MAJOR bump. Adding a new optional keyword argument or a new method is a MINOR. A bug fix that preserves the contract is a PATCH.
- A
@deprecatedtag without a named replacement and removal version is a dead end: it tells the caller to leave but not where to go or when the door closes.
Worked example:
# @deprecated Use {Pricing.discounted_total} instead. Removed in v3.0.
sig { params(order: Order, code: String).returns(Money) }
def self.apply_coupon(order:, code:)
warn "[DEPRECATED] Pricing.apply_coupon is deprecated and will be removed in v3.0. " \
"Use Pricing.discounted_total(order:, coupon_code:) instead. " \
"Called from #{caller_locations(1, 1)&.first}"
discounted_total(order: order, coupon_code: code)
endEnforcement: code review; every removal from the public surface must be preceded by a @deprecated cycle and gated behind a MAJOR version bump.
Reasoning, step by step:
- Every optional keyword argument has a default. That default lives in exactly one place — the method signature — and is documented with a
@paramYARD note naming the default value. The caller passes only the keyword arguments that differ from the defaults; the zero-override call is always valid. - A caller should not need to supply every keyword to produce the ordinary result. APIs that require long keyword lists to get standard behavior shift the cost of default-management onto every call site, duplicating the default across the codebase and creating drift when the default changes.
- Defaults must be stable. Changing a default is a behavior change for every caller who relied on the old default without passing it explicitly; in a library, this is a breaking change (MAJOR). Choose defaults carefully the first time. Document them in the sig's YARD note so callers understand what they inherit.
- Keyword arguments with defaults must come after required keywords in the signature. Required keywords state what the caller must always supply; optional keywords with defaults state what the caller may override. Mixing the two without separating required from optional makes the signature harder to scan.
Worked example:
# @param page_size [Integer] records per page. Default: 50.
# @param max_pages [Integer] pages to fetch before stopping. Default: 10.
sig { params(customer: Customer, page_size: Integer, max_pages: Integer).returns(T::Array[Order]) }
def self.recent_orders(customer:, page_size: 50, max_pages: 10)
raise ArgumentError, "page_size must be positive, got #{page_size}" unless page_size > 0
raise ArgumentError, "max_pages must be positive, got #{max_pages}" unless max_pages > 0
fetch_pages(customer: customer, page_size: page_size, max_pages: max_pages).freeze
end
# common call — no overrides needed; defaults do the right thing
orders = Pricing.recent_orders(customer: customer)
# override call — only what differs from the defaults
bulk = Pricing.recent_orders(customer: customer, page_size: 200)Enforcement: code review confirms that @param YARD notes name the default and that the default in the implementation matches the documentation (see 14-documentation.md).
10.10 — Keep argument shapes stable: required keywords first, keywords with defaults after; never reorder a published signature.
Reasoning, step by step:
- Keyword argument order is part of the signature's readability contract, even though Ruby does not enforce it at the call site. Consistent ordering — required first, optional with defaults after — lets a reader scan any signature in the codebase and immediately locate what is mandatory.
- Reordering keywords in a published signature is a confusing change even though it is not technically breaking for keyword arguments: existing call sites continue to compile, but anyone reading the old documentation or the old
sigis now looking at a different layout than the new code. Reordering is a code-review rejection. - Required positionals do not exist on public methods in this guide (10.3). The only ordering question is among keyword arguments: required keywords first (no default), then optional keywords (with defaults). This is a stable, predictable contract.
- Adding a new required keyword to a published signature is a breaking change even with keyword arguments, because existing callers that do not pass it will raise
ArgumentError. Add new required keywords only with a MAJOR bump, or make them optional with a default that preserves existing behavior.
Worked example:
# original published signature: required keyword first, optional with default after
sig { params(order: Order, currency: String).returns(Money) }
def self.convert(order:, currency: "USD") = ...
# bad — adding a required keyword breaks every caller that does not pass it
sig { params(order: Order, currency: String, rounding: Symbol).returns(Money) }
def self.convert(order:, rounding:, currency: "USD") = ... # rounding is required; existing callers break
# good — new keyword is optional with a default; existing callers unaffected
sig { params(order: Order, currency: String, rounding: Symbol).returns(Money) }
def self.convert(order:, currency: "USD", rounding: :half_up) = ...Enforcement: code review; published signatures are treated as contracts — reordering or adding a required keyword without a MAJOR bump is a rejection.
10.11 — Define value-object protocol methods; Data.define supplies most — do not hand-roll what it gives.
Reasoning, step by step:
- A value object that does not define
==is dangerous: two objects with identical fields compare unequal by identity, producing wrong results inselect,include?,uniq, and every other equality-dependent operation.hashmust be consistent with==or the object breaks as aHashkey.to_henables round-trip serialization and interop.to_smakes debugging readable rather than showing#<LineItem:0x…>. Data.defineprovides==,hash,to_h,to_s,members, and frozen instances for free. Using it means the protocol is correct, consistent, and tested by the Ruby core team. Hand-rolling these methods is error-prone — an off-by-one inhash, a missed field in==— and is a violation of rule 11 in the README (zero technical debt).- At
# typed: strictwith Sorbet,T::Structwithconstfields is the preferred alternative: it provides Sorbet-typed fields, runtime type checking, and immutability. It also provides==andto_h. ChooseT::Structwhen Sorbet must track the field types;Data.definewhen a lightweight, stdlib-only value is sufficient. - Do not define protocol methods (
==,hash,to_h,to_s) thatData.defineorT::Structalready supply unless the custom behavior differs meaningfully from the generated one. If the generatedto_sis insufficient for production logging, override onlyto_sand document why.
Worked example:
# bad — hand-rolled value object; == and hash are error-prone and untested by the platform
class LineItem
extend T::Sig
sig { params(sku: Sku, price: Money, quantity: Integer).void }
def initialize(sku:, price:, quantity:)
@sku = sku
@price = price
@quantity = quantity
freeze
end
sig { params(other: T.untyped).returns(T::Boolean) }
def ==(other)
other.is_a?(LineItem) && other.sku == @sku && other.price == @price # forgot quantity
end
end
# good — T::Struct supplies ==, hash, to_h, and typed fields for free
class LineItem < T::Struct
extend T::Sig
const :sku, Sku
const :price, Money
const :quantity, Integer
endEnforcement: code review rejects hand-rolled ==/hash on value objects that could use Data.define or T::Struct; any override of a generated protocol method must carry a why-comment explaining the deviation (see 06-classes-and-data-modeling.md).
sigon every method,# typed: strict, runtime checking, andT.nilablediscipline: 03-type-safety-and-nil-discipline.md.rubocop-airbnbbaseline,srb tcas the typecheck gate, and column/indent rules: 01-formatting-and-tooling.md.- Effect-verb naming, predicate
?, and nois_/get_prefixes for public names: 02-naming-conventions.md. - Keyword arguments in methods, guard clauses, 25-line cap, and 2+ assertions per method: 05-methods.md.
Data.define,T::Struct,T::Enum,class << self, and making illegal states unrepresentable: 06-classes-and-data-modeling.md.- Typed
StandardErrorsubclasses, raise at the boundary, andcausechaining for error contracts: 08-error-handling.md. - Zeitwerk autoloading, one class per file, and circular-dependency prevention: 12-module-organization.md.
- YARD on public API,
@param/@return/@deprecatedtags, and why-comments: 14-documentation.md.