Worked feature recipe
Build a personal saved-links domain from a concrete product decision through browser proof.
Worked feature recipe: personal saved links
This example turns one bounded product request into a complete vertical slice. It is deliberately personal: an authenticated user saves, lists, and deletes their own links. It does not add sharing, organizations, roles, billing, uploads, background work, or a public API.
Use this as a concrete companion to Build your first feature. The exact product name and UI can change; the owner, boundaries, and proof cannot be silently dropped.
Product contract
| Situation | Required result |
|---|---|
| Signed-in owner saves a valid URL and label | A personal link appears in the owner's list. |
| Same owner saves the same URL twice | The application returns a stable conflict without a duplicate row. |
| Anonymous caller | The route returns 401; the browser sends the user to sign in. |
| Another user reads or deletes a link ID | The application returns the chosen non-disclosing missing result and changes no row. |
| New user opens the page | The page has a clear empty state and an accessible way to add the first link. |
| Save request is pending or fails | The form communicates progress and a useful recovery action without losing its safe input. |
The public fields are bookmark_id, url, label, and created_at. user_id is persistence and
authorization data, not client-controlled input.
1. Design the schema around ownership
Create a new post-release dbmate migration. A suitable table has a generated UUID primary key, a
non-null user_id UUID REFERENCES users(id) ON DELETE CASCADE, a bounded URL and label, a creation
timestamp, a unique (user_id, url) constraint, and an index supporting the owner's ordered list.
The important behavior is expressed by the database:
CREATE TABLE bookmarks (
bookmark_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
url TEXT NOT NULL CHECK (length(url) <= 2048),
label TEXT NOT NULL CHECK (length(trim(label)) BETWEEN 1 AND 120),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, url)
);
CREATE INDEX bookmarks_user_created_at_idx
ON bookmarks (user_id, created_at DESC);The migration's down section drops only bookmarks and its dependent index. Before release, prove
fresh up → down → up on a named disposable database. Follow
Add a database migration for reversible migration and
deployment rules.
2. Define one wire contract
Create packages/contracts/src/bookmarks.ts and export it from the contracts package index. The
create request accepts only a trimmed URL and label; it does not accept user_id, bookmark_id, or
an entitlement claim. The list response is a public, mapped bookmark record rather than a raw
database row.
Validate the request once with Zod, then infer its TypeScript type from that schema. Add contract tests for a valid HTTPS URL, an invalid URL, blank/overlong labels, and unknown input keys. This gives both the route and browser client the same public shape.
3. Keep the server layers narrow
Create apps/server/src/domains/bookmarks/ with repository.ts, service.ts, routes.ts, and an
internal types.ts only when an internal row type is needed.
The repository receives both bookmarkId and current userId for detail or delete operations. Its
SQL owns the authorization predicate:
DELETE FROM bookmarks
WHERE bookmark_id = $1
AND user_id = $2
RETURNING bookmark_id;The service translates no returned row into the deliberate private-resource not-found result. It
also translates the unique-constraint conflict into a stable application error. The route mounts
sessionAuth, obtains identity with requireUserId(c), validates with the shared Zod schema, and
maps the result to the standard response helper. It contains no SQL and never reads user_id from
the request.
Mount the route once in apps/server/src/app.ts, then run the server and contracts typechecks. The
typed Hono client should expose the route without a manually duplicated browser interface.
4. Add the browser feature
Create apps/web/src/services/bookmarks.service.ts with one query-key factory, a paginated list
query, a create mutation, and a delete mutation. Each mutation invalidates only the affected
bookmark list or detail key.
Create apps/web/src/modules/bookmarks/ with an accessible form and list. Add a lazy /saved-links
route and navigation only after the page exists. The page must cover:
- a labeled URL and label form with invalid-input feedback;
- pending save/delete affordances that prevent accidental double-submit;
- empty, loading, error, and populated list states;
- keyboard operation, visible focus, reduced motion, and a narrow viewport;
- a clear outcome after duplicate-save conflict and after delete.
The page calls its service hook, not fetch, and it does not treat the browser as the authority for
the current user or access policy.
5. Prove the behavior at the right boundaries
| Layer | Proof |
|---|---|
| Contract | Request parser accepts a valid link and rejects invalid, blank, oversized, or unknown fields. |
| Migration/repository | The owner can list/delete; another user cannot read or delete; the uniqueness constraint holds under a second insert. |
| HTTP/auth | No session returns 401; malformed input returns 400; another user gets the non-disclosing result; no cross-owner mutation occurs. |
| Browser | A signed-in user creates and sees a link, receives the empty state before creation, and can delete it. |
| Release | Migration reversal, database-enabled tests, E2E, root checks, builds, and a custom generated buyer pass. |
Add the browser behavior to e2e/bookmarks.spec.ts using accessible labels and roles. See
End-to-end testing for the disposable stack and failure artifacts.
6. Decide what comes next
Sharing a bookmark, adding a team workspace, importing bookmarks asynchronously, charging for a limit, attaching preview images, or exposing a public integration are separate product decisions. They change ownership, failure recovery, cost, and security boundaries. Do not add them as an unreviewed extension of this small personal domain.
Use the domain golden path as the full change checklist and Go live before accepting real users.