Chapter
Turn Requirements Into APIs and Data
Define the system boundary, derive stored information from access patterns, and make failure behavior explicit.
Learning objectives
- Translate core workflows into small API contracts
- Design records around required reads and writes
- Explain primary keys and indexes in terms of access paths
- Include validation, idempotency, and failure responses in the contract
Contracts connect the product to the architecture
Before continuing, explain why a read-heavy workload does not by itself tell you how many servers to buy. Then retrieve the two workflows from your requirement brief: those—not the infrastructure you hope to use—will determine today’s API.
The requirement brief says users create short links and visitors follow them. The capacity estimate describes how often those actions occur. Before choosing infrastructure, define the boundary through which clients request those behaviors.
An API contract describes:
- what operation a client can request;
- what information the client supplies;
- what the system returns on success;
- what can fail and how that failure is represented.
HTTP is one way to expose the contract. The important design work is the meaning, not the particular punctuation of a URL.
Derive one operation from each core workflow
The creation workflow can become:
POST /links
Content-Type: application/json
Idempotency-Key: 7d81...
{
"destination": "https://example.com/a/long/path",
"expiresAt": "2027-01-01T00:00:00Z"
} A successful response might be:
HTTP/1.1 201 Created
{
"code": "aZ91kLm",
"shortUrl": "https://sho.rt/aZ91kLm",
"destination": "https://example.com/a/long/path",
"expiresAt": "2027-01-01T00:00:00Z"
} The redirect workflow can become:
GET /aZ91kLm If the code exists and has not expired:
HTTP/1.1 302 Found
Location: https://example.com/a/long/path
Cache-Control: no-store Why 302 rather than 301? A permanent redirect may be cached aggressively by browsers
and intermediaries, causing future requests to bypass the service. That can be desirable
for an immutable mapping and undesirable if expiration, abuse controls, or redirect metrics
must remain enforceable. The choice follows product behavior, not habit.
A 302 alone does not prohibit caching. For this baseline, Cache-Control: no-store
instructs browsers and intermediaries not to store the response, so later requests can be
checked for expiration. This is separate from the service’s internal mapping cache. See
the Cache-Control reference.
Error responses are part of the contract
Write important failures explicitly:
| Situation | Possible response | Meaning |
|---|---|---|
| Destination is malformed | 400 Bad Request | The client can correct the request |
| Short code is unknown | 404 Not Found | No mapping exists |
| Mapping expired | 404 or 410 Gone | Choose whether clients may distinguish expiration |
| Creation rate exceeded | 429 Too Many Requests | Client should slow down |
| Storage temporarily unavailable | 503 Service Unavailable | Operation may succeed later |
The exact status is less important than consistent semantics. Clients need to know whether to correct, retry, authenticate, or stop.
Why creation may need idempotency
A client may submit a creation request, lose the response, and retry. If each retry creates a different short code, one intended action produces multiple records.
An idempotency key identifies repeated attempts of the same logical request. The server stores the result associated with that key for a limited period and returns the same result when the request is retried.
first request with key X → create code abc1234, remember X → abc1234
retry with key X → return abc1234, do not create another mapping Idempotency does not mean every endpoint naturally has no effect when repeated. It is a contract and storage decision that makes retries safe.
One code or one request?
Two retries generate different codes, and both codes are unique. Has the database’s unique-code constraint prevented the duplicate user action? What information is missing?
Compare your answer
No. Uniqueness says two rows cannot share a code; it does not say two requests represent the same intention. The server must remember the request key and its result. Think of a claim ticket: repeating the ticket should retrieve the same order, not place another one.
For our illustrative contract, require a high-entropy random request key and retain its
result for 24 hours. During that window, the same key and payload replay the saved success;
the same key with a changed destination or expiration returns 409 Conflict. Concurrent
retries must wait for the first transaction or receive a retryable conflict. After the
retention window, reuse can create a new link; this is not unlimited deduplication.
These are choices for our service, not rules built into HTTP.
Stripe’s idempotency documentation provides a real example of saved results, parameter checks, and bounded key retention.
Data modeling begins with access patterns
An access pattern is a question the system must answer or a write it must perform.
Our MVP needs:
Write: store one new code → destination mapping
Read: retrieve one active mapping by code
Delete or expire: stop redirecting after expiresAt It does not need to search destinations by keyword or list links by owner because those features are outside the current scope.
A minimal record could be:
Link
- code: string
- destination: string
- createdAt: timestamp
- expiresAt: timestamp or null The short code is the natural lookup value for redirects, so make it the primary key. A primary key uniquely identifies a record and gives the database a direct access path to it.
CREATE TABLE links (
code VARCHAR(12) PRIMARY KEY,
destination TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NULL
); This schema is illustrative; it does not mean a relational database is always required. It makes the record and uniqueness rule concrete.
The retry promise needs a second record:
CreationRequest
- requestKey: unique random key
- normalizedPayload: destination and expiration used for comparison
- responseStatus and responseBody: the successful result to replay
- retainUntil: timestamp Store the request result and link in the same transaction: a group of database changes
that commits together or rolls back together. Otherwise a crash could save the link but
lose the evidence needed to recognize its retry. A unique constraint on requestKey
coordinates concurrent requests; a separate unique constraint on code handles random
code collisions. Chapter 4 traces this boundary.
Our anonymous example treats request keys as unguessable retry tokens, not authorization. Do not include secrets in them. If accounts are added, scope keys to the authenticated caller. Production storage estimates must also include these temporary request records, which were not part of the earlier long-term mapping estimate.
Practice: Retry After a Lost Response
First test the retry contract you just defined. Follow a request whose response is lost, then predict how another application instance should handle it:
An index is an additional search structure
An index helps the database locate rows by selected fields without scanning every row. The
primary key already provides an index-like access path by code.
Adding an index has a tradeoff:
- reads using that field may become faster;
- writes must also update the index;
- the index consumes storage.
Do not add indexes for hypothetical queries. If expiration cleanup frequently asks for all
rows whose expiresAt is before now, an expiration index may be justified. If cleanup can
use another mechanism or expired records are rare, it may not be.
Generating a short code
The key-space estimate showed that seven base-62 characters provide trillions of possible codes. We still need a method to choose one.
Random code with collision detection
Generate seven random base-62 characters, attempt to insert the record, and retry if the primary-key uniqueness check reports a collision.
Advantages:
- codes do not reveal creation order;
- generation can happen on many application instances;
- the approach is simple while the namespace is sparsely occupied.
Costs:
- collisions are possible and must be handled;
- randomness must be generated correctly;
- repeated retries become less attractive as occupancy grows.
Numeric ID encoded as base 62
Obtain a unique number and encode it with base-62 symbols.
Advantages:
- uniqueness follows from the numeric ID;
- no collision retries are needed.
Costs:
- generating IDs may require coordination or allocated ranges;
- sequential codes can reveal traffic or be easy to enumerate;
- one global counter can become a bottleneck if designed naively.
Neither method is universally superior. The choice depends on security expectations, throughput, operational simplicity, and how uniqueness is coordinated.
Input Validation: Define the Service’s Trust Boundary
Accepting an arbitrary destination can turn the service into an abuse tool. The creation path should at minimum consider:
- allowing only supported schemes such as
httpsand perhapshttp; - rejecting malformed or excessively long destinations;
- rate limiting clients;
- blocking known malicious destinations;
- preventing access to internal network addresses if the service later fetches URLs;
- defining reporting and removal behavior.
The MVP does not need to solve global abuse prevention, but the design should identify the trust boundary: user-controlled input becomes a redirect served to other people.
Practice: contract before components
First complete a missing contract: a client times out after submitting key X, then retries X with a different destination. Should the server replay, create another link, or reject? State why before checking.
Check the guided contract
Reject with the chosen conflict response. Replaying would hide a changed request; creating another link would break the meaning of X as one intention. The client needs a new key for a genuinely new action.
For a notification service, define:
- one API that accepts a notification request;
- the fields required to send it and deduplicate retries;
- success, validation-failure, rate-limit, and temporary-failure responses;
- a minimal stored record;
- the three most important reads or writes;
- which fields require uniqueness or an index.
Do not draw a queue or worker yet. First make the behavior and data boundary precise.
Compare with a sample notification contract
Use POST /notifications with recipient, channel, message, and a request key. Return
202 Accepted with an ID after storing the job durably; it does not mean delivery happened.
Use 400 for invalid inputs, 429 for rate limiting, and 503 for temporary inability
to accept work. Repeated identical keys replay the acceptance within the retention window;
changed payloads conflict.
Store ID, recipient, channel, payload, status, and timestamps, plus the request-key record. Core access patterns are inserting a job, looking up its status by ID, and finding pending work. Make IDs and request keys unique; consider an index on status and creation time for finding pending jobs. Creating one job does not guarantee a provider delivers only once: that is a separate boundary for the final chapter.
Retrieve and transfer
At your next session, sketch both records from memory and explain why one unique constraint cannot replace the other. Later, design “save a book to my reading list.” Should repeating the same user/book pair create another saved item?
Compare the new access pattern
If a reading list is a set of saved books, make the user/book pair unique. That expresses a product rule even when requests use different keys. A URL shortener may intentionally allow two different short links for the same destination. Do not carry uniqueness rules between products without checking their meaning.
Chapter checkpoint
You should now be able to explain why the URL code is the primary lookup key, why a retry can create duplicate state, and why adding a database index is not free. The output of this chapter is a contract and data model that the first architecture can implement.