Back to the blog

Contracts first: build APIs that are easier to change

Software Engineering 1/2: parse untrusted input, isolate pricing rules, and test invariants with three runnable JavaScript examples and original diagrams.

Contracts first: build APIs that are easier to change

Software Engineering, part 1 of 2. A small API becomes difficult to change when every function must guess what its inputs mean. A quantity arrives as text, a discount uses two incompatible units, and an HTTP response quietly exposes an internal exception. The code may be short while its assumptions spread across the interface, service, and database. Clear contracts give those assumptions one deliberate home.

We will build the core of a fictional book-order quote. The request contains a product code and quantity; a trusted catalog supplies the price, and an application policy supplies any discount. The browser never chooses its own price. These examples run entirely in memory. They teach boundaries and tests rather than providing a complete checkout, payment flow, or production authorization system.

1. Write the rules before the handler

Our input contract is intentionally narrow: one SKU containing uppercase letters, digits, or hyphens, and a quantity from 1 through 50. Surrounding SKU whitespace is harmless and is removed. Numeric text such as "2" is rejected instead of silently converted. Extra fields are rejected. Another product may choose different rules; the engineering task is to make the choice consistent and visible.

Define the output contract too. A successful parse returns a fresh command with an explicit success flag. An expected validation failure returns an issue, rather than throwing an exception. A missing catalog product is a separate domain outcome. A database outage is an operational failure. Combining all three into "something went wrong" removes information that the interface and support tools need.

JSON syntax and application validity are separate checks. MDN documents JSON.parse as constructing a JavaScript value from JSON text; that value may still be null, an array, or an unsuitable object. An HTTP adapter would catch malformed JSON before calling the parser below.

2. Parse once, then use a smaller type of input

Run this cell. It accepts two requests and rejects five. Notice that success contains only the fields the application needs. Copying an arbitrary request object into a database record would give future, unexpected fields a route into the system. Explicit construction also lets normalization happen at one boundary, instead of repeatedly throughout the business logic.

01-parse-command.jsjavascript
function parseCommand(value) {
  const fail = issue => ({ ok: false, issue });
  if (value === null || typeof value !== 'object' || Array.isArray(value))
    return fail('Expected an object');
  const keys = Object.keys(value);
  if (keys.length !== 2 || !keys.includes('sku') || !keys.includes('quantity'))
    return fail('Expected only sku and quantity');
  if (typeof value.sku !== 'string') return fail('SKU must be text');
  const sku = value.sku.trim();
  if (!/^[A-Z0-9-]{1,24}$/.test(sku)) return fail('Invalid SKU');
  if (!Number.isSafeInteger(value.quantity) || value.quantity < 1 || value.quantity > 50)
    return fail('Quantity must be an integer from 1 to 50');
  return { ok: true, value: { sku, quantity: value.quantity } };
}
const samples = [
  { sku: 'BOOK-01', quantity: 2 },
  { sku: ' BOOK-01 ', quantity: 1 },
  { sku: 'BOOK-01', quantity: '2' },
  { sku: 'BOOK-01', quantity: 1, admin: true },
  { sku: 'BOOK-01', quantity: 51 },
  { sku: 'BOOK-01', quantity: 1.5 },
  null
];
const results = samples.map(parseCommand);
const accepted = results.filter(result => result.ok).length;
if (accepted !== 2) throw new Error('Unexpected acceptance count');
console.log({ accepted, rejected: results.length - accepted });
console.log(results[1]);
Browser JavaScript

Runs locally in an isolated browser worker. Use console.log() or return a result.

The issue text is useful for this lesson. A real API can add stable error codes and field paths so translated interfaces do not depend on matching an English sentence. Keep internal diagnostics out of public responses. Validation is also separate from authorization: a well-formed SKU does not prove that the caller may buy it or access its price.

Outside input enters a parser, validated SKU and quantity enter a pure pricing function, and a response adapter maps explicit outcomes to the interface.
The parser handles representation; pricing handles domain rules. Catalog access and HTTP responses stay at the edges.

3. Put deterministic decisions in a pure core

The pricing function below takes ordinary values and returns ordinary values. It reads no clock, network, global user, or database. The same input therefore gives the same result. That makes a rule change easy to inspect and test. The handler can retrieve a trusted catalog price, call the function, then store or return the resulting quote.

We represent this fictional currency in integer cents, and discounts in basis points: 1,000 basis points means 10 percent. The rounding policy is nearest cent, with an exact half cent rounded upward. State that policy because rounding each line versus rounding a complete invoice can produce different totals. Tax rules, multiple currencies, and refunds need additional domain decisions.

S=uq,D=⌊Sb+500010000⌋,T=S−DS = u q,\qquad D = \left\lfloor\frac{S b + 5000}{10000}\right\rfloor,\qquad T = S-D

The bounds also protect arithmetic. Number.isSafeInteger checks exact integer representability. With our limits, even the intermediate discounted product remains below JavaScript’s safe-integer ceiling. This guarantee would need revisiting if those limits or units changed; using integer cents alone does not make unlimited multiplication safe.

02-pure-pricing-and-invariants.jsjavascript
function quote({ unitCents, quantity, discountBps }) {
  if (!Number.isSafeInteger(unitCents) || unitCents < 0 || unitCents > 1_000_000)
    throw new RangeError('unitCents out of range');
  if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > 50)
    throw new RangeError('quantity out of range');
  if (!Number.isSafeInteger(discountBps) || discountBps < 0 || discountBps > 10_000)
    throw new RangeError('discountBps out of range');
  const subtotalCents = unitCents * quantity;
  const discountCents = Math.floor((subtotalCents * discountBps + 5_000) / 10_000);
  return { subtotalCents, discountCents, totalCents: subtotalCents - discountCents };
}
console.log(quote({ unitCents: 2500, quantity: 2, discountBps: 1000 }));
let checked = 0;
for (const unitCents of [0, 1, 1999, 1_000_000]) {
  for (const quantity of [1, 2, 50]) {
    let previousTotal = Infinity;
    for (const discountBps of [0, 1000, 3333, 10_000]) {
      const result = quote({ unitCents, quantity, discountBps });
      if (!Number.isSafeInteger(result.totalCents) || result.totalCents < 0)
        throw new Error('Invalid total');
      if (result.totalCents + result.discountCents !== result.subtotalCents)
        throw new Error('Conservation failed');
      if (result.totalCents > previousTotal) throw new Error('Discount increased total');
      previousTotal = result.totalCents;
      checked++;
    }
  }
}
console.log({ combinationsChecked: checked });
Browser JavaScript

Runs locally in an isolated browser worker. Use console.log() or return a result.

The example returns a 5,000-cent subtotal, 500-cent discount, and 4,500-cent total. The loop checks 48 combinations, including zero-priced items, a one-cent item, a full discount, and the maximum quantity. It checks conservation and that increasing a discount never increases the total. These properties cover related cases that a single happy-path snapshot would miss.

4. Test the seams and the boundaries

A useful test asks which promise could break. For parsing, test wrong types, missing keys, extra keys, and values just outside an allowed interval. For pricing, test rounding at a half cent and totals near permitted limits. For an HTTP adapter, test the mapping of an invalid request, an unavailable product, and an unexpected storage failure. These tests exercise different contracts and should fail for different reasons.

The next independent cell isolates the quantity rule. It checks troublesome JavaScript values, then sweeps quantities 1 through 60. Run it to display a chart of cumulative outcomes. The accepted curve levels off at 50 because the remaining ten inputs exceed the contract. This chart describes enumerated synthetic test cases; it is not customer traffic or a measured error rate.

03-quantity-boundary-visualization.jsjavascript
function acceptsQuantity(value) {
  return Number.isSafeInteger(value) && value >= 1 && value <= 50;
}
const tricky = [0, -1, 1.5, '2', null, NaN, Infinity, 2 ** 53];
if (tricky.some(acceptsQuantity)) throw new Error('Invalid quantity accepted');
let accepted = 0, rejected = 0;
const acceptedPoints = [], rejectedPoints = [];
for (let quantity = 1; quantity <= 60; quantity++) {
  if (acceptsQuantity(quantity)) accepted++; else rejected++;
  acceptedPoints.push([quantity, accepted]);
  rejectedPoints.push([quantity, rejected]);
}
if (accepted !== 50 || rejected !== 10) throw new Error('Boundary contract changed');
console.log({ accepted, rejected, trickyValuesRejected: tricky.length });
if (typeof plot === 'function') plot({
  title: 'Quantity boundary: cumulative outcomes',
  xLabel: 'Quantity tested (1 through 60)', yLabel: 'Cumulative cases',
  series: [
    { name: 'Accepted', kind: 'line', points: acceptedPoints },
    { name: 'Rejected', kind: 'line', points: rejectedPoints }
  ]
});
Browser JavaScript

Runs locally in an isolated browser worker. Use console.log() or return a result.

Cumulative accepted cases rise to 50 then remain flat, while rejected cases rise from zero to 10 for quantities 51 through 60.
Exact output of cell 3: 50 accepted quantities, 10 rejected quantities, plus eight separately checked invalid JavaScript values.

Try changing the maximum to 49 in the predicate without changing the assertion. The cell should fail. Then decide whether the product rule changed or the implementation regressed before updating the expectation. A test that merely copies the implementation’s current answer would miss that disagreement. Keep a few named examples from product requirements alongside broader invariant checks.

5. Keep the service boundary proportional to the problem

A small application does not need a separate deployed service for each function. A module boundary can be enough: parse the transport value, authorize the caller, load trusted inputs, calculate a result, persist the required change, then map a public response. Make dependencies explicit so a test can replace catalog access without replacing the pricing rules. Add distribution only when independent ownership or scaling justifies its operational cost.

Pure functions do not solve concurrency. If a quote reserves scarce stock, checking availability and updating stock must obey an appropriate database transaction or atomic constraint. Authentication, request size limits, observability, and abuse controls also live outside this arithmetic example. The benefit of a small core is knowing where those responsibilities belong, rather than assuming that unit tests cover the whole product.

Practice and continue

Add a minimum order value and explain whether a fully discounted order is allowed. Add a named test for a half-cent discount, then intentionally break the rounding policy and observe the failure. Finally sketch the result variants your interface needs for an unknown SKU and an unavailable catalog. The exercise is complete when a reader can understand each outcome without reading the transport handler.

Next: Software Engineering, part 2 — Why indexes work. We will use the same habit of explicit assumptions to reason about search costs and database tradeoffs.

Sources and further reading

MDN: JSON.parse — parsing JSON text is distinct from validating a domain command.

MDN: Number.isSafeInteger — exact integer bounds and representability.

Node.js: strict assertions — a production test suite can use the standard assertion API; the runnable cells use explicit checks to stay dependency-free.

THE NEXT CHAPTER STARTS WITH A CONVERSATION

A good idea deserves
a great build.

Tell us what you have in mind. We’ll help you find the way forward.

Start a project