8 min

Barbara Liskov’s Data Abstraction: Building Reliable APIs

Learn Barbara Liskov’s data abstraction principles to design stable interfaces, reduce breakages, and build maintainable systems with clear, reliable APIs.

Barbara Liskov’s Data Abstraction: Building Reliable APIs

Why Barbara Liskov Still Matters for API Design

Barbara Liskov is a computer scientist whose work quietly shaped how modern software teams build things that don’t fall apart. Her research on data abstraction, information hiding, and later the Liskov Substitution Principle (LSP) influenced everything from programming languages to the everyday way we think about APIs: define clear behavior, protect internals, and make it safe for others to depend on your interface.

“Reliable interfaces” in product terms

A reliable API isn’t just “correct” in a theoretical sense. It’s an interface that helps a product move faster:

  • New features ship without breaking existing customers.
  • Integrations keep working across versions.
  • On-call incidents go down because failures are predictable.
  • Teams can change internals without a coordination marathon.

That reliability is an experience: for the developer calling your API, for the team maintaining it, and for the users who depend on it indirectly.

How data abstraction reduces bugs (and meetings)

Data abstraction is the idea that callers should interact with a concept (an account, a queue, a subscription) through a small set of operations—not through the messy details of how it’s stored or computed.

When you hide representation details, you remove entire categories of mistakes: no one can “accidentally” rely on a database field that wasn’t meant to be public, or mutate shared state in a way the system can’t handle. Just as importantly, abstraction lowers coordination overhead: teams don’t need permission to refactor internals as long as the public behavior stays consistent.

What you’ll be able to apply after this

By the end of this article, you’ll have practical ways to:

  • Write API behaviors as clear promises (including edge cases).
  • Keep interfaces small and stable while systems evolve.
  • Design predictable failure modes that callers can handle.

If you want a quick summary later, jump to /blog/a-practical-checklist-for-designing-reliable-apis.

Data Abstraction, Explained Without Jargon

Data abstraction is a simple idea: you interact with something by what it does, not by how it’s built.

Think of a vending machine. You don’t need to know how the motors turn or how coins are counted. You only need the controls (“select item”, “pay”, “receive item”) and the rules (“if you pay enough, you get the item; if it’s sold out, you get a refund”). That’s abstraction.

“What it does” vs. “How it works”

In software, the interface is the “what it does”: the names of operations, what inputs they accept, what outputs they produce, and what errors to expect. The implementation is the “how it works”: database tables, caching strategy, internal classes, and performance tricks.

Keeping these separate is how you get APIs that stay stable even as the system evolves. You can rewrite internals, swap libraries, or optimize storage—while the interface stays the same for users.

Abstract Data Types (ADTs) in one minute

An abstract data type is a “container + allowed operations + rules,” described without committing to a specific internal structure.

Example: a Stack (last in, first out).

  • push(item): add an item
  • pop(): remove and return the most recently added item
  • peek(): look at the top item without removing it

The key is the promise: pop() returns the latest push(). Whether the stack uses an array, a linked list, or something else is private.

How this maps to real APIs

The same separation applies everywhere:

  • REST endpoints: POST /payments is the interface; fraud checks, retries, and database writes are implementation.
  • SDK methods: client.upload(file) is the interface; chunking, compression, and parallel requests are implementation.
  • UI components: a “DatePicker” exposes props/events; DOM structure and accessibility plumbing are implementation.

When you design with abstraction, you focus on the contract users rely on—and you buy yourself freedom to change everything behind the curtain without breaking them.

Invariants: The Hidden Rules That Keep Systems Correct

An invariant is a rule that must always be true inside an abstraction. If you’re designing an API, invariants are the guardrails that keep your data from drifting into impossible states—like a bank account with two currencies at once, or a “completed” order with no items.

What invariants look like (without math)

Think of an invariant as “the shape of reality” for your type:

  • A Cart can’t contain negative quantities.
  • A UserEmail is always a valid email address (not “validated later”).
  • A Reservation has start < end, and both times are in the same timezone.

If those statements stop being true, your system becomes unpredictable, because every feature now has to guess what “broken” data means.

How invariants guide validation and error handling

Good APIs enforce invariants at the boundaries:

  • On creation: reject invalid inputs early (return a clear error).
  • On updates: allow only changes that keep the invariant true.
  • On parsing/IO: treat external data as untrusted; validate before storing.

This naturally improves error handling: instead of vague failures later (“something went wrong”), the API can explain which rule was violated (“end must be after start”).

Don’t let invariants leak through the interface

Callers shouldn’t have to memorize internal rules like “this method only works after calling normalize().” If an invariant depends on a special ritual, it’s not an invariant—it’s a footgun.

Design the interface so that:

  • invalid states are unrepresentable (or hard to represent)
  • methods preserve the invariant automatically

A practical documentation checklist

When documenting an API type, write down:

  1. The invariant statements (plain English, testable)
  2. Where they’re enforced (constructor, setters, endpoints)
  3. What happens on violation (error type/message, status code)
  4. Which methods preserve them (and any exceptions)
  5. Examples of valid vs invalid inputs (short, concrete)

Contracts: Make Behavior Clear for Callers and Maintainers

A good API isn’t just a set of functions—it’s a promise. Contracts make that promise explicit, so callers can rely on behavior and maintainers can change internals without surprising anyone.

What to spell out in a contract

At minimum, document:

  • Preconditions: what must be true before calling (valid ranges, required permissions, thread-safety expectations).
  • Postconditions: what will be true after a successful call (return value meaning, state changes).
  • Side effects: what else changes (writes to disk, sends network requests, updates caches, modifies passed-in objects).

This clarity makes behavior predictable: callers know what inputs are safe and what outcomes to handle, and tests can check the promise rather than guessing intent.

Contracts reduce “tribal knowledge”

Without contracts, teams rely on memory and informal norms: “Don’t pass null there,” “That call sometimes retries,” “It returns empty on error.” Those rules get lost during onboarding, refactors, or incidents.

A written contract turns those hidden rules into shared knowledge. It also creates a stable target for code reviews: discussions become “Does this change still satisfy the contract?” rather than “It worked for me.”

Good vs. vague wording (examples)

Vague: “Creates a user.”

Better: “Creates a user with a unique email.

  • Preconditions: email must be a valid address; caller must have users:create permission.
  • Postconditions: returns the new userId; the user is persisted and immediately retrievable.
  • Failure modes: returns 409 if email already exists; returns 400 for invalid fields; no partial user is created.”

Vague: “Gets items quickly.”

Better: “Returns up to limit items sorted by createdAt descending.

  • Side effects: none.
  • Consistency: may be up to 60 seconds stale.
  • Pagination: use nextCursor for the next page; cursors expire after 15 minutes.”

Information Hiding: Keep Internals Private, Keep APIs Stable

Information hiding is the practical side of data abstraction: callers should rely on what the API does, not how it does it. If users can’t see your internals, you can change them without turning every release into a breaking change.

Expose operations, not representation

A good interface publishes a small set of operations (create, fetch, update, list, validate) and keeps the representation—tables, caches, queues, file layouts, service boundaries—private.

For example, “add item to cart” is an operation. “CartRowId” from your database is an implementation detail. When you expose the detail, you invite users to build their own logic around it, which freezes your ability to change.

Why hiding internals makes refactors safe

When clients only depend on stable behavior, you can:

  • switch databases or storage formats
  • split a monolith into services
  • add caching or change indexing
  • reorganize internal models

…and the API remains compatible because the contract didn’t move. That’s the real payoff: stability for users, freedom for maintainers.

Common leakage patterns to watch for

A few ways internals accidentally escape:

  • Returning internal IDs that are only meaningful in your storage layer (auto-increment integers, shard keys).
  • Exposing mutable structures (e.g., returning a raw object that clients can patch and send back), which couples clients to your exact fields.
  • Letting clients construct internal state, like accepting status=3 instead of a clear name or dedicated operation.

Designing response shapes that stay stable

Prefer responses that describe meaning, not mechanics:

  • Use public identifiers that are stable and opaque (e.g., "userId": "usr_…") rather than database row numbers.
  • Return copies or read-only views of collections instead of structures whose ordering or internal fields are “accidentally” relied on.
  • Add fields in a backward-compatible way; avoid changing the meaning of existing fields.

If a detail might change, don’t publish it. If users need it, promote it to a deliberate, documented part of the interface promise.

Liskov Substitution Principle as an Interface Promise

Snapshot before risky changes
Refactor with confidence using snapshots and rollback when behavior changes unexpectedly.

The Liskov Substitution Principle (LSP) in one sentence: if a piece of code works with an interface, it should keep working when you swap in any valid implementation of that interface—without needing special cases.

LSP is less about inheritance and more about trust. When you publish an interface, you’re making a promise about behavior. LSP says that every implementation must keep that promise, even if it uses a very different internal approach.

LSP as “don’t surprise the caller”

Callers rely on what your API says—not on what it happens to do today. If an interface says “you can call save() with any valid record,” then every implementation must accept those valid records. If an interface says “get() returns a value or a clear ‘not found’ outcome,” then implementations can’t randomly throw new errors or return partial data.

Safe extension means you can add new implementations (or swap providers) without forcing users to rewrite code. That’s the practical payoff of LSP: it keeps interfaces swappable.

Common LSP violations in APIs

Two common ways APIs break the promise are:

  • Narrower inputs (stricter preconditions): a new implementation rejects inputs that the interface definition allowed. Example: the base interface accepts any UTF‑8 string as an ID, but one implementation only accepts numeric IDs or rejects empty-but-valid fields.

  • Weaker outputs (looser postconditions): a new implementation returns less than promised. Example: the interface says results are sorted, unique, or complete—yet one implementation returns unsorted data, duplicates, or silently drops items.

A third, subtle violation is changing failure behavior: if one implementation returns “not found” while another throws an exception for the same situation, callers can’t safely substitute one for the other.

Designing plug-in behavior without surprises

To support “plug-ins” (multiple implementations), write the interface like a contract:

  • Specify what inputs are valid and keep that set consistent across implementations.
  • Specify what outputs mean (including ordering, defaults, and edge cases).
  • Standardize failure modes: which errors can happen, and what they represent.

If an implementation truly needs stricter rules, don’t hide that behind the same interface. Either (1) define a separate interface, or (2) make the constraint explicit as a capability (for example, supportsNumericIds() or a documented configuration requirement). That way, clients opt in knowingly—rather than being surprised by a “substitute” that isn’t actually substitutable.

Good Interfaces Are Small, Cohesive, and Easy to Read

A well-designed interface feels “obvious” to use because it exposes only what the caller needs—and no more. Liskov’s view of data abstraction pushes you toward interfaces that are narrow, stable, and readable, so users can rely on them without learning internal details.

Prefer cohesive over “do-everything”

Big APIs tend to mix unrelated responsibilities: configuration, state changes, reporting, and troubleshooting all in one place. That makes it harder to understand what’s safe to call and when.

A cohesive interface groups operations that belong to the same abstraction. If your API represents a queue, focus on queue behaviors (enqueue/dequeue/peek/size), not general-purpose utilities. Fewer concepts means fewer accidental misuse paths.

Avoid overly flexible parameters that create ambiguity

“Flexible” often means “unclear.” Parameters like options: any, mode: string, or multiple booleans (e.g., force, skipCache, silent) create combinations that aren’t well-defined.

Prefer:

  • specific methods for distinct behaviors (e.g., publish() vs publishDraft()), or
  • a small, well-typed options object with documented defaults and invalid combinations.

If a parameter requires callers to read the source to know what happens, it’s not part of a good abstraction.

Naming is part of the interface

Names communicate the contract. Choose verbs that describe observable behavior: reserve, release, validate, list, get. Avoid clever metaphors and overloaded terms. If two methods sound similar, callers will assume they behave similarly—so make that true.

When to split into multiple modules/resources

Split an API when you notice either:

  • different user roles (e.g., “admin” vs “consumer”) needing different capabilities, or
  • different change rates (one part evolves frequently, another must stay stable).

Separate modules let you evolve internals while keeping the core promise steady. If you’re planning growth, consider a slim “core” package plus add-ons; see also /blog/evolving-apis-without-breaking-users.

Evolving APIs Without Breaking Users

Publish under your domain
When you are ready to share, host your app on a custom domain.

APIs rarely stay still. New features arrive, edge cases get discovered, and “small improvements” can quietly break real applications. The goal isn’t to freeze an interface—it’s to evolve it without violating the promises users already depend on.

Semantic versioning (practical, with limits)

Semantic versioning is a communication tool:

  • MAJOR: you made a breaking change.
  • MINOR: you added functionality in a backward-compatible way.
  • PATCH: you fixed bugs without changing intended behavior.

Its limit: you still need judgment. If a “bug fix” changes behavior that callers relied on, it’s breaking in practice—even if the old behavior was accidental.

Breaking changes are about contracts, not just types

Many breaking changes don’t show up in a compiler:

  • Tightening input rules (rejecting values you previously accepted).
  • Changing meaning (same fields, different interpretation).
  • Changing timing (a call that was fast becomes slow or blocks).
  • Changing error behavior (new error codes, different retries, different partial results).

Think in terms of preconditions and postconditions: what callers must provide, and what they can count on getting back.

Deprecation paths that users can actually follow

Deprecation works when it’s explicit and time-bound:

  • Mark the old behavior as deprecated in docs and responses (warnings, headers, logs).
  • Offer a dual-support window (old and new side-by-side).
  • Publish a clear timeline (e.g., “new default in 60 days, removal in 180 days”).

How abstraction makes evolution easier

Liskov-style data abstraction helps because it narrows what users can depend on. If callers only rely on the interface contract—not internal structure—you can change storage formats, algorithms, and optimizations freely.

In practice, this is also where strong tooling helps. For example, if you’re iterating quickly on an internal API while building a React web app or a Go + PostgreSQL backend, a vibe-coding workflow like Koder.ai can accelerate the implementation without changing the core discipline: you still want crisp contracts, stable identifiers, and backward-compatible evolution. Speed is a multiplier—so it’s worth multiplying the right interface habits.

Error Handling and Failure Modes: Design for Predictability

A reliable API isn’t one that never fails—it’s one that fails in ways callers can understand, handle, and test. Error handling is part of the abstraction: it defines what “correct use” means, and what happens when the world (networks, disks, permissions, time) disagrees.

Programmer errors vs. runtime failures

Start by separating two categories:

  • Programmer errors: the caller violated the contract (e.g., passing an invalid ID format, calling methods out of order, forgetting required fields). These should be caught early and loudly—often with validation errors that point directly to the misuse.
  • Runtime failures: the caller followed the contract, but something external failed (timeouts, unavailable dependencies, quota limits, concurrency conflicts). These should be representable and recoverable.

This distinction keeps your interface honest: callers learn what they can fix in code versus what they must handle at runtime.

Use the contract to pick the right failure shape

Your contract should imply the mechanism:

  • Errors (validation responses) for contract violations.
  • Exceptions for truly exceptional, non-local failures in libraries—or when you can’t reasonably force every call site to branch.
  • Result types (e.g., Ok | Error) when failures are expected and you want callers to handle them explicitly.

Whatever you choose, be consistent across the API so users don’t guess.

Make failure modes explicit and testable

List possible failures per operation in terms of meaning, not implementation details: “conflict because version is stale,” “not found,” “permission denied,” “rate limited.” Provide stable error codes and structured fields so tests can assert behavior without string matching.

Retries, idempotency, and partial success

Document whether an operation is safe to retry, under what conditions, and how to achieve idempotency (idempotency keys, natural request IDs). If partial success is possible (batch operations), define how successes and failures are reported, and what state callers should assume after a timeout.

Testing Abstractions: Prove the Interface Matches the Promise

An abstraction is a promise: “If you call these operations with valid inputs, you’ll get these outcomes, and these rules will always hold.” Testing is how you keep that promise honest as the code changes.

Turn contracts into unit and integration tests

Start by translating the contract into checks you can run automatically.

Unit tests should verify each operation’s postconditions and edge cases: return values, state changes, and error behavior. If your interface says “removing a non-existent item returns false and changes nothing,” write exactly that.

Integration tests should validate the contract across real boundaries: database, network, serialization, and auth. Many “contract violations” appear only when types are encoded/decoded or when retries/timeouts happen.

Property-based testing for invariants

Invariants are the rules that must remain true across any sequence of valid operations (e.g., “balance never goes negative,” “IDs are unique,” “items returned by list() can be fetched by get(id)”).

Property-based testing checks these rules by generating lots of random-but-valid inputs and operation sequences, searching for counterexamples. Conceptually, you’re saying: “No matter what order users call these methods in, the invariant holds.” This is especially good at finding weird corner cases humans don’t think to write down.

Consumer-driven contract testing for public APIs

For public or shared APIs, let consumers publish examples of the requests they make and the responses they rely on. Providers then run these contracts in CI to confirm changes won’t break real usage—even when the provider team didn’t anticipate that usage.

Monitor production for contract drift

Tests can’t cover everything, so monitor signals that suggest the contract is changing: response shape changes, increases in 4xx/5xx rates, new error codes, latency spikes, and “unknown field” or deserialization failures. Track these by endpoint and version so you can detect drift early and roll back safely.

If you support snapshots or rollbacks in your delivery pipeline, they pair naturally with this mindset: detect drift early, then revert without forcing clients to adapt mid-incident. (Koder.ai, for example, includes snapshots and rollback as part of its workflow, which aligns well with a “contracts first, changes second” approach.)

Common Anti-Patterns and How to Avoid Them

Start with Go and Postgres
Create a Go plus PostgreSQL backend and focus on invariants at the boundaries.

Even teams that value abstraction slip into patterns that feel “practical” in the moment but gradually turn an API into a bundle of special cases. Here are a few recurring traps—and what to do instead.

Permanent feature flags as API knobs

Feature flags are great for rollout, but trouble starts when flags become public, long-lived parameters: ?useNewPricing=true, mode=legacy, v2=true. Over time, callers combine them in unexpected ways, and you end up supporting multiple behaviors forever.

A safer approach:

  • Keep rollout flags internal when possible.
  • If behavior must differ, express it as a new capability with a clear name and lifecycle (and a plan to remove the old one).
  • Document what combinations are valid; reject the rest explicitly.

Leaking database concepts into the interface

APIs that expose table IDs, join keys, or “SQL-shaped” filters (e.g., where=...) force clients to learn your storage model. That makes refactors painful: a schema change becomes a breaking API change.

Instead, model the interface around domain concepts and stable identifiers. Let clients ask for what they mean (“orders for a customer in a date range”), not how you store it.

The “just add a field” reflex

Adding a field looks harmless, but repeated “one more field” changes can blur responsibilities and weaken invariants. Clients start depending on accidental details, and the type becomes a grab-bag.

Avoid the long-term cost by:

  • Introducing a new, focused type for a new concept.
  • Grouping related fields into a nested object with a clear meaning.
  • Treating every addition as a contract change: what does it imply, and what must always be true?

When abstraction becomes too strict

Over-abstracting can block real needs—like pagination that can’t express “start after this cursor,” or a search endpoint that can’t specify “exact match.” Clients then work around you (multiple calls, local filtering), causing worse performance and more errors.

The fix is controlled flexibility: provide a small set of well-defined extension points (e.g., supported filter operators), rather than an open-ended escape hatch.

Simplify without removing capability

Simplification doesn’t have to mean taking power away. Deprecate confusing options, but keep the underlying capability via a clearer shape: replace multiple overlapping parameters with one structured request object, or split one “do everything” endpoint into two cohesive ones. Then guide migration with versioned docs and a clear deprecation timeline (see /blog/evolving-apis-without-breaking-users).

A Practical Checklist for Designing Reliable APIs

You can apply Liskov’s data abstraction ideas with a simple, repeatable checklist. The goal is not perfection—it’s making the API’s promises explicit, testable, and safe to evolve.

A short checklist

  • Invariants: What must always be true about the data or resource? (e.g., “balance never goes negative,” “IDs are unique,” “items are returned in stable order”).
  • Contracts: For each operation, write preconditions, postconditions, and side effects (including what is not changed).
  • Hidden representation: List which details are intentionally private (storage format, caching, internal IDs) and ensure callers can’t depend on them.
  • Evolution plan: Decide how you’ll add capabilities: versioning strategy, deprecation policy, and how long old behavior is supported.

A quick API review workflow (repeatable)

  1. Read the interface only (no implementation). Can a new teammate predict behavior?
  2. Walk through 5 “story tests”: a normal case, an empty case, a boundary case, an invalid-input case, and a failure case.
  3. Check substitution safety: if there are multiple implementations, would swapping one for another surprise callers?
  4. Scan for hidden coupling: are clients forced to know internal states, timing, or storage details?
  5. Write down the breaking changes you’re about to introduce, then redesign until the list is empty (or consciously accepted).

Documentation templates (copy/paste)

Use short, consistent blocks:

  • Operation: transfer(from, to, amount)
  • Requires: amount > 0 and accounts exist
  • Ensures: balances updated atomically; total sum preserved
  • Errors: InsufficientFunds, AccountNotFound, Timeout
  • Notes: idempotency, ordering, performance expectations

Optional next reading

If you want to go deeper, look up: Abstract Data Types (ADTs), Design by Contract, and the Liskov Substitution Principle (LSP).

If your team keeps internal notes, link them from a page like /docs/api-guidelines so the review workflow stays easy to reuse—and if you build new services rapidly (whether by hand or with a chat-driven builder like Koder.ai), treat those guidelines as a non-negotiable part of “shipping fast.” Reliable interfaces are how speed compounds instead of backfiring.

FAQ

Why does Barbara Liskov’s work still matter for API design today?

She popularized data abstraction and information hiding, which map directly to modern API design: publish a small, stable contract and keep the implementation flexible. The payoff is practical: fewer breaking changes, safer refactors, and more predictable integrations.

What does “a reliable interface” mean in product and engineering terms?

A reliable API is one that callers can depend on across time:

  • New versions don’t break existing consumers.
  • Failure modes are consistent and documented.
  • Internals can change without changing the public behavior.

Reliability is less about “never failing” and more about failing predictably and honoring the contract.

How do I turn an API endpoint or method into a clear behavioral promise?

Write behavior as a contract:

  • Preconditions: what must be true before the call (valid ranges, permissions).
  • Postconditions: what will be true after success (returned values, state changes).
  • Side effects: what else changes (writes, network calls, cache updates).

Include edge cases (empty results, duplicates, ordering) so callers can implement and test against the promise.

What are invariants, and where should an API enforce them?

An invariant is a rule that must always hold inside an abstraction (e.g., “quantity is never negative”). Enforce invariants at boundaries:

  • Validate on create/update.
  • Reject invalid inputs early with specific errors.
  • Prevent “special rituals” like “call normalize() first” from being required.

This reduces downstream bugs because the rest of the system can stop handling impossible states.

What is information hiding, and how do I apply it to response shapes and IDs?

Information hiding means exposing operations and meaning, not internal representation. Avoid coupling consumers to things you might want to change later (tables, caches, shard keys, internal statuses).

Practical tactics:

  • Use stable, opaque public IDs (e.g., usr_...) instead of database row IDs.
  • Don’t require clients to construct internal state (avoid status=3).
  • Add fields backward-compatibly without changing the meaning of existing ones.
Why is leaking database concepts into an API such a common long-term problem?

Because they freeze your implementation. If clients depend on database-shaped filters, join keys, or internal IDs, then a schema refactor becomes an API breaking change.

Prefer domain questions over storage questions, like “orders for a customer in a date range,” and keep the storage model private behind the contract.

What is the Liskov Substitution Principle (LSP) in practical API terms?

LSP means: if code works with an interface, it should keep working with any valid implementation of that interface without special cases. In API terms, it’s a “don’t surprise the caller” rule.

To support substitutable implementations, standardize:

  • Valid inputs (no implementation adds stricter preconditions).
  • Output guarantees (ordering, completeness, uniqueness).
  • Failure behavior (same meanings for errors and “not found”).
What are common LSP violations when multiple implementations or providers exist?

Watch for:

  • Narrower inputs: a new implementation rejects inputs the interface allowed.
  • Weaker outputs: it drops items, changes ordering, or returns partial data without saying so.
  • Different failure semantics: one returns “not found,” another throws or returns a different error shape.

If an implementation truly needs extra constraints, publish a separate interface or an explicit capability flag so callers opt in knowingly.

How do I design an API that stays small, cohesive, and easy to understand?

Keep interfaces small and cohesive:

  • Prefer focused operations that match one abstraction.
  • Avoid “options: any” and piles of booleans that create ambiguous combinations.
  • Use names that describe observable behavior (reserve, release, list, validate).

If different roles or change rates exist, split modules/resources (for more on evolution, see /blog/evolving-apis-without-breaking-users).

How should I design error handling so failures are predictable and testable?

Design errors as part of the contract:

  • Separate programmer errors (contract violations) from runtime failures (timeouts, conflicts, quotas).
  • Document stable error codes/fields so tests don’t rely on message strings.
  • Specify retry safety and idempotency (keys, request IDs), and define partial success for batch operations.

Consistency matters more than the exact mechanism (exceptions vs result types) as long as callers can predict and handle outcomes.

Related posts