Intermediate13 min readOO Analysis & Modelinglive prototype

Designing the API First

Agree on the public contract — the method signatures a caller sees — before you write a line of implementation; design from the outside in, not the inside out.

The idea

What it is

Designing the API first means deciding what the caller sees — the public method signatures, the interface, the contract — before you write any implementation. You write down book(request): Result<Ticket> and agree on it, and only then do you fill in the body. It flips the usual habit of coding the logic first and letting whatever methods fall out become the "API" by accident. Here API means the public face of any class or module — its methods, parameters, and return types — not just a web or REST endpoint.

Think of wiring a building. You don't invent a brand-new socket shape in every room and then hope appliances fit. Everyone agrees on the shape of the socket first — the contract — and then electricians wire behind the wall and appliance makers build plugs, independently, confident the two will meet. The socket is the API: a small, stable, public shape that hides an ocean of wiring nobody calling it needs to think about. Design that shape on purpose, up front, and everything that plugs into it gets simpler.

The one sentence to remember

Design the contract — the public signatures a caller sees — before the implementation: decide what plugs into the socket before you wire the wall.

Mechanics

How it works

Design from the caller's perspective

The single most useful trick is to write the client code you wish you could call first, before any implementation exists. Pretend the class is already done and write the few lines that use it: const ticket = lot.park(car);. Does that read cleanly? Is it obvious what comes back, and what happens when the lot is full? You're designing the API by consuming it, which is the only perspective that matters — an API is judged by the people who call it, not by the person who wrote it. This is the heart of API-first (sometimes called consumer-driven or outside-in) design.

Write the usage code first

Before implementing anything, write three or four lines of example client code that call the method as you wish it existed. If those lines are awkward to write, the signature is wrong — fix it now, while it costs nothing.

What a good signature communicates

A method signature is a tiny contract that should answer four questions at a glance: what does it do (the name), what does it need (the parameters), what does it give back (the return type), and how can it fail (the error style). A signature like boolean doIt(int a, int b, boolean flag) answers none of them — you have to read the body to learn anything. Result<Ticket> park(Vehicle vehicle) answers all four: it parks a vehicle, it needs a vehicle, it returns a ticket, and the Result wrapper says it can fail in a way you must handle. Good signatures are intention-revealing: the name and types tell the story so the body doesn't have to.

The decisions you surface early

Designing the API first forces a handful of decisions into the open before they harden into accidents:

  • Return typevoid (the caller learns nothing), a raw Ticket (fine on the happy path, but what about failure?), or a Result<Ticket> / Optional<Ticket> that makes "might not produce one" explicit in the type.
  • Error-handling stylethrow an exception, return null, return a Result / Optional, or return an error code. Pick one deliberately and be consistent; mixing them is what makes a surface miserable to call.
  • Naming — vague verbs like process(), handle(), doIt() hide intent; park(vehicle), cancelBooking(id), reserveSeat(...) reveal it.
  • Parameter shape — a long positional list (park(plate, color, height, isElectric, level)) is easy to mis-order; a single parameter object (park(ParkRequest)) is self-documenting and grows without breaking callers.
  • Command–query separation — a method should either do something (a command, often void or returning a receipt) or answer something (a query, with no side effects), not quietly both.
  • Idempotency — can the caller safely retry park after a timeout without double-parking? Designing the contract is where you decide and document that.
  • Minimal surface — expose the fewest public members that let callers do their job; every extra public method is a promise you must keep forever.

Keep the surface minimal — hide the internals

The public API is a promise: once a caller depends on a method, you can't change it freely without breaking them. So the contract should expose intent and hide mechanism — this is encapsulation and abstraction in action. A clean park(vehicle) says nothing about how spots are tracked, whether there's a database, or what an int spotIndex means internally. The moment a signature leaks HashMap<String,Integer> spots or a Connection conn parameter, the caller is coupled to your implementation, and you've lost the freedom to change it. A deep module — a small interface over a lot of hidden functionality — is the goal; a shallow one that exposes its guts is a liability.

Don't leak implementation or persistence concerns

Never let database rows, ORM entities, connection objects, internal indices, or framework types appear in your public signatures. save(Connection c, Row r) welds every caller to your storage choice. The contract should speak the domain's language (book(request): Result<Ticket>), not the wiring's.

Contracts let implementations be swapped (DIP)

When the API is an interface rather than a concrete class, you gain the ability to swap what's behind it. interface ParkingLot { park(v): Result<Ticket>; } can be backed by an in-memory lot today and a distributed one tomorrow, and no caller changes. High-level code depends on the abstraction, not the concrete type — that's the Dependency Inversion Principle. Designing the API first naturally produces these thin contracts, because you're describing what the caller needs, not how you'll provide it.

Why agreeing first unblocks everyone

Once the contract is agreed, work parallelizes. One engineer builds the real implementation behind the interface; another builds the caller against a mock of that same interface; a third writes tests that assert the contract's behavior. None of them wait on each other, because the interface is the shared truth. Testing gets easier for the same reason: you mock against the interface, verify the caller handles every documented outcome (including the failure cases the Result type forces it to acknowledge), and you're done. A contract designed first is a coordination point; an API that fell out of the implementation is a surprise everyone discovers late.

Leaky vs. clean: a contrast

Here's the same operation as a leaky contract and a clean one. The leaky version returns a bare boolean, smuggles results out through an out-parameter, takes an error-prone positional list, and exposes an internal index. The clean version is an intention-revealing interface returning an explicit result type over a parameter object:

typescript
// ❌ LEAKY — boolean return, out-param, positional soup, exposed internals
class Lot {
  // did it work? caller can't tell why it failed.
  park(plate: string, color: string, h: number, ev: boolean,
       outSpotIndex: { value: number }): boolean { /* ... */ }
}
const out = { value: -1 };
if (!lot.park("AB-12", "red", 1.6, false, out)) { /* why?? */ }
// caller must know what 'outSpotIndex' means — that's an internal detail

// ✅ CLEAN — intention-revealing interface, result type, parameter object
interface ParkingLot {
  park(vehicle: Vehicle): Result<Ticket>;   // one obvious thing; failure is in the type
}
const r = lot.park(car);
if (r.ok) issue(r.value);           // r.value is a Ticket — a domain object
else      show(r.error.message);    // the caller MUST handle the failure case

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

Pick the design decisions for a single park(vehicle) operation — its return type, error-handling style, parameter style, and method name — and watch the client code a caller would write re-render live on the right, alongside a caller-experience score with concrete pros and cons. Choose return null and it warns about forgotten null checks; choose a Result type and errors become explicit. Hit Snap to clean API to see the ideal contract, or Reset to start awkward again.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Flip the return type and read the client code

Start with the Return type selector on void. The Client code panel shows a call that throws away its result — the caller learns nothing. Switch it to Ticket, then to Result<Ticket>, and watch the snippet re-render each time. Notice how Result<Ticket> forces an if (r.ok) check into the caller's code, and the caller-experience score climbs as failure becomes visible in the type.

try 02

Make the contract leak, then watch the cons appear

Set Error handling to return null and read the live con in the experience panel: "caller can forget the null check → NPE risk." Now switch Parameter style to long param list and Naming to the vague doIt() — the score bar drops into the red and the console narrates each regression. This is exactly the leaky contract: easy to mis-call, impossible to read.

try 03

Snap to the clean API

Press Snap to clean API. Every selector jumps to its best choice — Result<Ticket> return, Result error handling, a parameter object, and the intention-revealing park(vehicle) name — and the Client code panel renders the ideal caller snippet. The score maxes out, the pros light up green, and you can see the leaky-vs-clean contrast at a glance. Hit Reset to return to the awkward starting contract.

In practice

When to use it — and what trips people up

When designing the API first pays off most

Reach for API-first design whenever a contract will be shared or outlive its first caller: a public library or SDK, a service consumed by another team, a module sitting at a subsystem boundary, or anything you'll mock heavily in tests. The cost of a bad signature scales with the number of callers depending on it — a published interface is expensive to change because you can't fix every caller in one commit. Spend the design effort up front exactly where the API will be hard to change later.

Published vs. merely public

There's a difference between public (anyone in your codebase can call it) and published (callers outside your codebase depend on it). A published interface is a contract you can't quietly refactor — every breaking change ripples out to people you can't see. Design those with the most care, and keep their surface as small as you honestly can.

Don't over-engineer the contract either

API-first is not an excuse to invent interfaces for code with exactly one caller that'll never be swapped. Throwaway internal helpers don't need a hand-crafted contract. Design the API first where the surface is shared, stable, or hard to change — not on every private function.

What it gives you

  • Callers come first — the contract is judged by the people who use it, so the public surface stays clean and intention-revealing.
  • Parallel work unblocks — implementation, callers, and tests all proceed against the agreed interface at once.
  • Easy testing — mock the interface, and the contract's documented outcomes (including failures) are simple to assert.
  • Swappable implementations — an interface contract lets you change what's behind it (DIP) without touching callers.
  • Failure modes are explicit — choosing a Result/Optional return makes 'this can fail' impossible for the caller to ignore.

Common mistakes

  • Up-front effort — agreeing the contract before coding feels slower at the very start of a feature.
  • Premature contracts can mislead — designing an API before you understand the problem can lock in the wrong shape.
  • Over-abstraction risk — wrapping single-caller internal code in interfaces adds indirection for no gain.
  • A published API is a promise — once callers depend on it, breaking changes are costly, so the surface must be chosen carefully.
  • Result/error-style consistency takes discipline — mixing exceptions, nulls, and result types across one surface confuses callers.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

// 1) DESIGN THE CONTRACT FIRST — the interface a caller depends on.
//    No implementation yet; just the shape of the socket.
interface Result<T> { ok: boolean; value?: T; error?: { code: string; message: string }; }

interface ParkRequest { vehicle: Vehicle; preferEv?: boolean; }   // parameter object
interface Ticket { id: string; spot: string; issuedAt: number; }

interface ParkingLot {
  park(request: ParkRequest): Result<Ticket>;   // intention-revealing, failure in the type
  release(ticketId: string): Result<void>;
  isFull(): boolean;                             // a pure query — no side effects
}

// 2) WRITE THE CLIENT CODE YOU WISH YOU COULD CALL — against the interface only.
function handlePark(lot: ParkingLot, car: Vehicle) {
  const r = lot.park({ vehicle: car });
  if (r.ok) return r.value!;                  // a Ticket — a domain object
  console.warn("could not park:", r.error!.message); // failure is impossible to ignore
}
// Implementations (in-memory, distributed, mock-for-tests) can be swapped behind ParkingLot.

References & further reading

7 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 05

What does "designing the API first" primarily mean?

question 02 / 05

What is the recommended first concrete step when designing an API?

question 03 / 05

Why is returning a Result<Ticket> or Optional<Ticket> often better than returning null on failure?

question 04 / 05

Which signature leaks implementation details into the public contract?

question 05 / 05

How does agreeing on the API (as an interface) first help a team work in parallel?

0/5 answered