A URL shortener built with Node.js, TypeScript and Fastify: a modular monolith with hexagonal architecture, used as a reference project for posts on poschuler.com.
Status: complete. Shorten and Resolve work end to end, with an OpenAPI reference and a load test. The repository stays as it is, as the reference for the posts.
- Shorten: turns a Target URL into a Short URL with a seven-character, case-sensitive Short Code.
- Resolve: redirects a Visitor who follows a Short URL to its Target URL.
With Docker running:
cp .env.example .env
docker compose up -d --build --wait- Open
http://localhost:3001/docs, the API reference ofwrite-api, and sendPOST /linkswith{"url": "https://example.com"}. The response has thecodeand theshortUrl. - Paste the
shortUrl(http://localhost:3002/<code>) into the browser:read-apianswers 302 and the browser lands on the Target URL. Follow it from the address bar, not from the API reference: the browser blocks a redirect to another site there. - Try the errors:
http://localhost:3002/abcis a 400 (a Short Code has seven characters), a well-formed code that no Link has is a 404, and shorteningftp://example.comis a 400 that names the rule.
docker compose down stops the stack. The .env.example key is public, which is fine for trying it out; see Getting started before using it for anything else.
Writes and reads run as separate processes built from the same codebase: write-api for Shorten and read-api for Resolve (ADR-0014). For development, api runs both in one process.
flowchart LR
creator["Anyone who shortens<br/>[Person or program]"]
visitor["Visitor<br/>[Browser]"]
subgraph system["url-shortener"]
write["write-api<br/>[Node.js, Fastify]<br/>Shorten · port 3001"]
read["read-api<br/>[Node.js, Fastify]<br/>Resolve · port 3002"]
migrate["migrate<br/>[Node.js, one-off job]<br/>Database migrations"]
pg[("Postgres 17<br/>Links, number sequence")]
redis[("Redis 7<br/>Cache, no disk")]
end
creator -- "POST /links" --> write
visitor -- "GET /:code → 302" --> read
migrate -- "before the APIs start" --> pg
write -- "ranges, inserts" --> pg
read -- "1 · cache first" --> redis
read -- "2 · on a miss" --> pg
- Shorten validates the Target URL, takes a number that
write-apireserved from a Postgres sequence a range at a time (ADR-0010), turns it into a Short Code with a keyed Feistel permutation (ADR-0004) and saves the Link. - Resolve reads the Target URL from Redis, falls back to Postgres on a miss, and caches Short Codes without a Link too (ADR-0017). It redirects with 302 (ADR-0016).
Inside, each module is hexagonal (ADR-0018): its use cases and domain do no I/O and reach Postgres and Redis through ports, and dependency-cruiser enforces the layers (ADR-0009). docs/architecture.md has the three levels of the C4 model, down to the components of the links module, and both paths step by step.
The design of a URL shortener fits in a few files and ADRs. Start with these:
- Short Codes that cannot be enumerated:
short-code-permutation.tsturns a number into a Short Code with a keyed Feistel permutation (ADR-0004). - Numbers without a database round trip per Link:
ranged-number-sequence.tshands out numbers from ranges thatpostgres-range-allocator.tsreserves from a Postgres sequence (ADR-0010, ADR-0011). - The two use cases:
shorten-link.tsandresolve-link.ts, with the cache-aside read and its negative entries inredis-link-cache.ts(ADR-0017). - The decisions behind them: Links are immutable (ADR-0001), shortening the same URL twice gives two Links (ADR-0002), Resolve answers 302 (ADR-0016), writes and reads run as separate processes (ADR-0014) and both are rate limited per client IP (ADR-0015).
The rest is there on purpose, but a URL shortener does not need it. It shows how the service would be built and run in production:
- The hexagonal modules, the minimal DDD and the boundaries that dependency-cruiser enforces (ADR-0018, ADR-0020, ADR-0009), with contract tests and in-memory fakes.
- The hardening of the number sequence: a spare range loaded ahead of time, a single refill shared by every caller, and retries with backoff.
- The k6 load test of the read path (see Load testing).
- The toolchain: TypeScript 7 with oxlint and oxfmt (ADR-0007).
docs/architecture.md: context, containers and components, with diagrams.CONTEXT.md: the domain language (Link, Short Code, Target URL, Short URL, Visitor).docs/adr/: architecture decision records, numbered in the order they were made.docs/openapi.json: the OpenAPI 3.1 document of the API, generated from the routes (ADR-0019). WithDOCS_ENABLED=true, each API also serves it at/docs/openapi.json, with a Scalar reference at/docs.
- Node.js 24 or later
- pnpm 11
- Docker, for integration tests, end-to-end tests and the local stack
pnpm install
cp .env.example .env
openssl rand -hex 16 # paste the result into SHORT_CODE_KEY in .env
pnpm checkSHORT_CODE_KEY is the secret key of the permutation that turns numbers into Short Codes. The one in .env.example is public: with it, anyone can turn a Short Code back into its number and count the Links. Keep your own secret, and never change it once Links exist: a new key maps new numbers onto Short Codes that may already be taken.
To run the whole stack, see Docker. To work on the code, run Postgres and Redis from the stack and the all-in-one api from src/:
docker compose up -d --wait postgres redis
pnpm db:migrate
pnpm dev # api on port 3000The Short URLs that api returns point to PUBLIC_BASE_URL, which .env.example sets to read-api. To follow them with pnpm dev, set PUBLIC_BASE_URL=http://localhost:3000. With pnpm dev, the API reference at http://localhost:3000/docs has both routes.
| Route | Served by | Answers |
|---|---|---|
POST /links |
write-api, api |
201 with the code and the shortUrl for the url in the body; 400 for an invalid Target URL; 429 |
GET /:code |
read-api, api |
302 to the Target URL; 400 for a malformed Short Code; 404 when no Link has it; 429 |
GET /health |
every API | 200 while the process is up |
GET /ready |
every API | 200 when its dependencies are ready, 503 naming the ones that are not |
GET /docs |
every API | The API reference, only with DOCS_ENABLED=true |
Errors are Problem Details (application/problem+json). Every response carries an x-request-id header, the one the request sent or a new one, and the logs use the same id.
write-api is ready once Postgres answers and it has reserved its first range of numbers. read-api is ready once Postgres answers: without Redis it still resolves, from Postgres.
curl -i localhost:3001/links -H 'content-type: application/json' -d '{"url":"https://example.com"}'
curl -i localhost:3002/<code> # the code from the response: 302 to https://example.comEach process reads its variables from the environment, or from .env with pnpm dev, pnpm start and the scripts. It validates only the ones it uses and refuses to start when one is missing or invalid.
| Variable | Default | Used by | What it does |
|---|---|---|---|
NODE_ENV |
development |
every API | development, test or production |
PORT |
3000 (api), 3001 (write), 3002 (read) |
every API | Port of the API |
LOG_LEVEL |
info |
every process | pino log level, from fatal to trace, or silent |
PG_HOST |
required | every process | Postgres host |
PG_PORT |
5432 | every process | Postgres port |
PG_USER, PG_PASSWORD |
required | every process | Postgres credentials |
PG_NAME |
required | every process | Postgres database |
SHORT_CODE_KEY |
required | write-api, api |
32 hexadecimal characters: the secret key of the Short Codes (see Getting started) |
PUBLIC_BASE_URL |
required | write-api, api |
Base of the Short URLs. A Target URL on its hostname is rejected, so a Short URL cannot point to another one |
MAX_URL_LENGTH |
2048 | write-api, api |
Longest Target URL accepted, in its canonical form |
SHORTEN_RATE_LIMIT_MAX |
20 | write-api, api |
POST /links requests per client IP in each window |
RESOLVE_RATE_LIMIT_MAX |
300 | read-api, api |
GET /:code requests per client IP in each window |
RATE_LIMIT_WINDOW_MS |
60000 | every API | Window of both rate limits. The limits are kept in memory, per process (ADR-0015) |
TRUST_PROXY |
empty | every API | Comma-separated addresses or CIDR ranges of the reverse proxies, or loopback, linklocal, uniquelocal. The client IP comes from X-Forwarded-For only through them |
DOCS_ENABLED |
false |
every API | Serves the API reference at /docs |
REDIS_HOST |
localhost |
read-api, api |
Redis host |
REDIS_PORT |
6379 | read-api, api |
Redis port |
REDIS_PASSWORD |
required | read-api, api |
Redis password |
CACHE_TTL_SECONDS |
86400 | read-api, api |
How long a Target URL stays cached |
CACHE_NEGATIVE_TTL_SECONDS |
60 | read-api, api |
How long a Short Code without a Link stays cached |
| Script | What it does |
|---|---|
pnpm check |
Format check, lint, type check, dependency boundaries and tests with coverage thresholds |
pnpm test |
Runs the unit tests once |
pnpm test:watch |
Runs the unit tests on every change |
pnpm test:coverage |
Runs the unit tests with a coverage report in coverage/ |
pnpm test:integration |
Runs the integration tests against Postgres and Redis in containers (needs Docker) |
pnpm test:e2e |
Runs the end-to-end tests against the running compose stack |
pnpm lint / pnpm lint:fix |
Lints with oxlint, optionally fixing what it can |
pnpm format / pnpm format:check |
Formats with oxfmt, or only checks |
pnpm typecheck |
Type checks sources, tests and config files |
pnpm deps:check |
Checks module and layer boundaries with dependency-cruiser |
pnpm openapi |
Regenerates docs/openapi.json from the routes |
pnpm build |
Compiles src/ to dist/ |
pnpm dev |
Runs the all-in-one API from src/, restarting on every change |
pnpm start |
Runs the compiled all-in-one API from dist/ (run pnpm build first) |
pnpm db:migrate |
Applies the pending database migrations to the Postgres in .env |
pnpm db:migrate:down |
Reverts the last applied migration |
compose.yaml runs write-api and read-api from the same image, both with health checks, plus Postgres 17 and Redis 7. It reads its values from .env. A one-off migrate service applies the database migrations first; the APIs start only if it succeeds:
docker compose up -d --build --wait --remove-orphans
docker compose down--remove-orphans removes containers of services that are no longer in compose.yaml, such as an older all-in-one api. docker compose down -v also deletes the Postgres volume.
Redis is a cache only: it keeps no data on disk and, once it reaches its memory limit, evicts the least recently used keys. Postgres and Redis are also published on 127.0.0.1, so pnpm dev can use them from the host. To revert the last migration inside the stack:
docker compose run --rm migrate node dist/entrypoints/migrate.js downThe Dockerfile builds a production image that runs as the non-root node user. By default it starts the all-in-one api; the compose stack overrides the command for migrate, write-api and read-api. To run it on its own:
docker build -t url-shortener .
docker run --rm -p 3000:3000 --env-file .env url-shortener
curl -i localhost:3000/healthdocker run --env-file keeps quotes as part of the value, so keep the values in .env unquoted. The container shuts down gracefully on SIGTERM (docker stop).
test/load/resolve-link.ts is a k6 script for the read path. It runs in the k6 service of the compose stack, from the grafana/k6 image, so nothing has to be installed. It first creates 1000 links, then asks read-api for them at a fixed rate: 500 requests per second for one minute, a tenth of them for Short Codes without a Link.
The stack has to be up with raised rate limits, because every request comes from the same IP. Variables set in the shell take precedence over .env:
SHORTEN_RATE_LIMIT_MAX=100000 RESOLVE_RATE_LIMIT_MAX=10000000 docker compose up -d --build --wait
docker compose run --rm k6
docker compose run --rm -e RATE=1000 -e DURATION=30s k6 # another rate or duration
docker compose up -d --wait # back to the limits in .envk6 exits with an error when more than 1% of the requests fail or when the 95th and 99th percentiles go past 100 ms and 250 ms. The thresholds are loose on purpose: they catch a broken cache or a wall of 429s, not a slower machine.
GitHub Actions runs pnpm check, the integration tests, and the end-to-end tests against the compose stack on every push to main and on every pull request (.github/workflows/ci.yml). pnpm check includes a test that fails when docs/openapi.json no longer matches the routes: run pnpm openapi and commit the result.
src/
modules/<module>/ one hexagonal module per bounded area (domain, application, infrastructure, http)
shared/ code shared across modules; never imports from modules/
entrypoints/ one composition root per process
test/
unit/ mirrors src/
integration/
contract/
e2e/
load/ k6 scripts
docs/
architecture.md C4 diagrams
adr/ architecture decision records
openapi.json the OpenAPI document, generated
MIT