When designing a module's public API surface, what's the practical process for deciding what goes in the API versus what stays in the implementation, and what goes wrong when a team just exposes everything 'to be safe'?
answer
- Hyrum's Law
- consumer-first API design
- translate internal types at the boundary
- public is a near-permanent commitment
- expose-everything is cheap now, expensive later
basics
~20 sOnly put in the API the smallest set of types and methods other code actually needs. Everything else stays hidden. Exposing extra stuff 'just in case' makes it much harder to change later, because someone eventually relies on it.
solid answer
~40 sStart from actual consumer use cases, not from what already exists internally: design the API as the minimal contract a caller needs, expressed as interfaces, DTOs, and exceptions, then work backward to decide what implementation detail can stay hidden behind it. A useful test for each candidate public type or method is whether removing or changing it later would break a real caller and whether the team wants to promise it forever - if not, it shouldn't be public. Teams that expose everything 'to be safe' end up with a de facto API far larger than intended, because every accidentally-public helper eventually becomes something someone depends on, which is exactly what Hyrum's Law predicts, and the team loses the ability to refactor internals without a breaking-change process, defeating the entire point of the split.
go deeper
Should intuit that exposing less lets you change more later, even without naming Hyrum's Law specifically.
Should describe the consumer-first design process and the boundary-translation idea, such as mapping an entity to a DTO, in concrete terms.
Should name Hyrum's Law explicitly or describe an exactly equivalent mechanism, and walk through the leak-then-can't-refactor failure mode with a plausible worked example.
Should discuss calibrating surface-size discipline to consumer count and module maturity, and describe a concrete org-level retrofit or deprecation strategy for an already over-exposed module.
## The practical process The practical process is **consumer-first design**. 1. You enumerate the operations external callers actually need - submit an order, look up an order's status - and define minimal interfaces and DTOs for exactly those operations, only implementing the supporting machinery afterward. 2. Every supporting class, such as repositories, mappers, internal validators, or retry helpers, stays inside the implementation module or an unexported package. 3. Any internal type that would otherwise need to cross the boundary, because it's a natural parameter or return type, either becomes a deliberate part of the API as a purpose-built DTO, or gets translated at the boundary - mapping an internal persistence entity to a public DTO right before returning it - so the internal representation never actually leaks. That translation step is deliberate extra ceremony, and skipping it is the single most common way implementation details end up leaking into a public API by accident. ## The underlying justification The underlying justification is **Hyrum's Law**: with a sufficient number of users of an API, it does not matter what you promise in the contract, because all observable behaviors of the system will eventually be depended on by somebody. - If a class is technically public and reachable, someone will eventually depend on it even if it's undocumented and never intended as part of the contract, especially inside a large monorepo with dozens of internal consumers. - Once that happens, the 'internal' code inherits all the compatibility obligations of real API code without anyone having chosen that outcome, and the team that owns it only discovers the tax the moment they try to change it and something downstream unexpectedly breaks. ## The trade-off The trade-off is real design time spent upfront: - translation and mapping layers, - more types to name and maintain, - and more review scrutiny on the question of whether a given type or method should be public at all. For a young module with a single caller, this can feel like premature bureaucracy. Exposing everything, by contrast, is **cheap today and expensive tomorrow**, because every additional exposed method or type is a near-permanent commitment - removing or changing it later becomes a breaking change requiring a deprecation cycle, a major version bump, or a coordinated multi-team migration. The right calibration usually tracks the number of independent consumers and how stable the module is expected to be: a module with one caller on the same team can reasonably start looser and tighten later, while a module published to many teams, or externally, needs the discipline from day one, because retrofitting it after real adoption is dramatically more expensive than getting it right up front. ## How it fails in production In production, this failure mode shows up as a kind of **paralysis**: a team discovers it cannot change an internal helper class because several other teams somehow ended up importing it directly, so a routine refactor stalls indefinitely or gets abandoned. - It can also show up as a module owner shipping what they believed was a minor internal change that quietly breaks unrelated consumers who never should have been coupled to that detail in the first place, eroding trust in the module's versioning promises. - And it shows up structurally when an API module grows monotonically over years, because it is always easier to add a public method than to remove one, until the 'public contract' is functionally identical to the entire codebase and the split delivers no benefit while still costing its full structural overhead. ## A concrete worked scenario A concrete worked scenario: a `notifications-api` module is used by a dozen internal microservices to send emails and text messages. Early on, someone exposes the internal `EmailTemplateEngine` class directly, because it's convenient for one consumer that wants custom formatting. Two years later, the notifications team wants to replace that engine with a faster templating library, only to discover that five separate services now construct `EmailTemplateEngine` instances directly, so the intended internal swap now requires a coordinated cross-team migration plan instead of a same-day refactor - exactly the coordination cost the api/impl split existed to eliminate, undone by one convenience exception years earlier. In hindsight, the correct fix would have been adding a narrow, purpose-built method to the real API, such as `sendWithCustomFormat(...)`, instead of exposing the internal engine wholesale. The general lesson generalizes well beyond this one scenario: every convenience exception made to avoid a small amount of design work today tends to compound, because each exception makes the next one look normal, and by the time the cost becomes visible it is spread across teams who never chose to take it on.
- How do you retrofit a minimal API onto a module that has already over-exposed internals for years?Typically through a deprecation strategy: introduce the new narrow API alongside the old exposed types, mark the old ones deprecated with migration guidance, track and communicate the remaining internal consumers, and only remove the old exposure after a defined migration window or a major version bump. Large organizations sometimes use codemods or static analysis tooling to find and automatically migrate remaining call sites onto the new surface.
- Is a smaller public API surface always better?Not universally - an API that's too minimal forces every consumer variation into workarounds like reflection, forking the library, or repeatedly begging the owning team for new methods, which can be worse in practice than exposing a bit more up front. The real goal is matching the surface to real, anticipated use cases, not minimizing surface area purely for its own sake.
A museum's public galleries versus its storage archive: if you let a visitor wander into the archive 'just this once' because it's convenient, eventually the museum can't reorganize storage without an official announcement, because visitors now expect certain crates to stay exactly where they found them.
saying these in an interview costs you the question
- Treats 'expose everything now, deprecate later' as effectively free
- Cannot name Hyrum's Law or describe an equivalent real consequence of over-exposure
- Believes API surface size doesn't matter once the interfaces exist
- Has no answer for how to translate internal types at the boundary
- Assumes an undocumented public class carries no real compatibility obligation