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

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.
A reliable API isn’t just “correct” in a theoretical sense. It’s an interface that helps a product move faster:
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.
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.
By the end of this article, you’ll have practical ways to:
If you want a quick summary later, jump to /blog/a-practical-checklist-for-designing-reliable-apis.
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.
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.
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).
The key is the promise: pop() returns the latest push(). Whether the stack uses an array, a linked list, or something else is private.
The same separation applies everywhere:
POST /payments is the interface; fraud checks, retries, and database writes are implementation.client.upload(file) is the interface; chunking, compression, and parallel requests 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.
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.
Think of an invariant as “the shape of reality” for your type:
Cart can’t contain negative quantities.UserEmail is always a valid email address (not “validated later”).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.
Good APIs enforce invariants at the boundaries:
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”).
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:
When documenting an API type, write down:
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.
At minimum, document:
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.
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.”
Vague: “Creates a user.”
Better: “Creates a user with a unique email.
email must be a valid address; caller must have users:create permission.userId; the user is persisted and immediately retrievable.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.
nextCursor for the next page; cursors expire after 15 minutes.”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.
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.
When clients only depend on stable behavior, you can:
…and the API remains compatible because the contract didn’t move. That’s the real payoff: stability for users, freedom for maintainers.
A few ways internals accidentally escape:
status=3 instead of a clear name or dedicated operation.Prefer responses that describe meaning, not mechanics:
"userId": "usr_…") rather than database row numbers.If a detail might change, don’t publish it. If users need it, promote it to a deliberate, documented part of the interface promise.
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.
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.
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.
To support “plug-ins” (multiple implementations), write the interface like a contract:
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.
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.
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.
“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:
publish() vs publishDraft()), orIf a parameter requires callers to read the source to know what happens, it’s not part of a good abstraction.
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.
Split an API when you notice either:
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.
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 is a communication tool:
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.
Many breaking changes don’t show up in a compiler:
Think in terms of preconditions and postconditions: what callers must provide, and what they can count on getting back.
Deprecation works when it’s explicit and time-bound:
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.
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.
Start by separating two categories:
This distinction keeps your interface honest: callers learn what they can fix in code versus what they must handle at runtime.
Your contract should imply the mechanism:
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.
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.
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.
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.
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.
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.
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.
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.)
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.
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:
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.
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:
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.
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).
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.
Use short, consistent blocks:
transfer(from, to, amount)amount > 0 and accounts existInsufficientFunds, AccountNotFound, TimeoutIf 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.
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.
A reliable API is one that callers can depend on across time:
Reliability is less about “never failing” and more about failing predictably and honoring the contract.
Write behavior as a contract:
Include edge cases (empty results, duplicates, ordering) so callers can implement and test against the promise.
An invariant is a rule that must always hold inside an abstraction (e.g., “quantity is never negative”). Enforce invariants at boundaries:
This reduces downstream bugs because the rest of the system can stop handling impossible states.
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:
usr_...) instead of database row IDs.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.
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:
Watch for:
If an implementation truly needs extra constraints, publish a separate interface or an explicit capability flag so callers opt in knowingly.
Keep interfaces small and cohesive:
reserve, release, , ).Design errors as part of the contract:
Consistency matters more than the exact mechanism (exceptions vs result types) as long as callers can predict and handle outcomes.
status=3listvalidateIf different roles or change rates exist, split modules/resources (for more on evolution, see /blog/evolving-apis-without-breaking-users).