Skip to content

Repository files navigation

TinyURL

A high-performance, distributed URL shortener built as a system-design learning project, featuring Cassandra + Redis storage, real-time click analytics, a sliding-window rate limiter, and independently scalable read/write services.
Have a question? · Request Feature

Technologies

This project was built using the Go programming language, following Clean Architecture principles (domain / use cases / infrastructure / delivery layers) with dependency injection throughout. The HTTP layer is powered by Hertz, chosen for its high-performance I/O and production-ready ergonomics. Data is persisted in Cassandra (durable source of truth) and cached in Redis (fast reads, real-time counters, and rate limiting via Lua scripting). HAProxy load-balances traffic across independently scalable read and write service groups. The project uses Docker and Docker Compose for containerization, k6 for load testing, and Go's built-in testing framework (with TDD and hand-written mocks) for unit tests. API documentation is generated with swag/Swagger.

Architecture

This project intentionally splits reads and writes into separate services (a CQRS-style split), since redirects vastly outnumber URL creations in a real-world shortener:

  • write — handles POST /shorten. Generates a distributed, collision-free short code (Snowflake ID + Base62 encoding) and persists it to Cassandra, warming Redis on write.
  • read — handles GET /:short_code (redirect) and GET /stats/:short_code (click analytics). Reads cache-first from Redis, falling back to Cassandra on a miss.
  • lb — HAProxy, routing /shorten to writer replicas and everything else to reader replicas.
  • redis — cache-aside layer for URLs and stats, plus the backing store for the sliding-window rate limiter (atomic via Lua scripting).
  • cassandra — durable source of truth for URLs and click statistics.

Every use case depends only on small, interface-based repository ports — the caching strategy, Redis, and Cassandra are entirely invisible above the infrastructure layer.

Getting Started

Prerequisites

For running this project, you will need the following:

And you need to download the project to your computer.

Installation

After fulfilling all requirements, you need to install the dependencies:

go mod download

Usage

The fastest way to get the whole system running — API services, load balancer, Redis, and Cassandra — is with Docker Compose:

docker compose up --build

This starts:

  • lb — HAProxy, exposed at http://localhost:9999
  • write-1 / write-2 — writer replicas (internal only, routed via lb)
  • read — reader replicas (internal only, routed via lb)
  • redis — exposed at localhost:6379
  • cassandra — exposed at localhost:9042
  • cassandra-init — one-shot keyspace/table initialization, runs once and exits

Once everything is healthy, the API is reachable at http://localhost:9999. For example:

curl -X POST http://localhost:9999/shorten \
  -H "Content-Type: application/json" \
  -d '{"long_url": "https://example.com"}'

To stop everything:

docker compose down

To also remove persisted Redis/Cassandra data volumes:

docker compose down -v

Makefile commands

The Makefile wraps common development and testing tasks:

Command Description
make test Runs the full unit test suite (internal/ and pkg/).
make covertest Runs all tests with coverage reporting.
make racetest Runs the concurrent Snowflake ID generation test under Go's race detector, to verify no duplicate IDs are produced under concurrent access.
make swag Regenerates the Swagger/OpenAPI documentation from source annotations.
make smoke_test Runs a lightweight end-to-end sanity check (create → redirect → stats → 404) against the running Docker stack via k6.
make write_test Runs a k6 load test against the write path (POST /shorten) at steady-state and peak QPS.
make read_test Runs a k6 load test against the read path (redirects) at steady-state and peak QPS.
make load_test Runs the combined read+write k6 load test, simulating realistic mixed traffic at the real-world read:write ratio.

All *_test load-testing commands run k6 through Docker Compose's loadtest profile, so they never start automatically with docker compose up — they're opt-in and run against the already-running stack.

API Documentation

Interactive Swagger UI is available in non-production environments at:

http://localhost:9999/swagger/index.html

To regenerate the documentation after changing handler annotations:

make swag

Load Testing

Load tests are written in k6 and modeled on the following system-design targets:

  • Writes: ~115 QPS steady state, ~350 QPS peak (3x)
  • Reads: ~11,500 QPS steady state, ~35,000 QPS peak (3x)

Run a quick sanity check before any real load test:

make smoke_test

Then run the isolated or combined load tests:

make write_test
make read_test
make load_test

Note: sustaining the peak read target (35,000 QPS) requires meaningful hardware for both the k6 load generator and the backing services — a local single-node Cassandra/Redis setup is expected to bottleneck well below that number. The goal of these tests is to observe where the system saturates first, not to guarantee hitting the raw target on a laptop.

Contributing

If you'd like to contribute to this project, please follow these steps:

  1. Fork this repository.
  2. Create a branch: git checkout -b feat/your-feature.
  3. Make your changes and commit them: git commit -m 'Add some feature'.
  4. Push to the original branch: git push origin feat/your-feature.
  5. Create a pull request.

Author


Julio Martins

Made by Julio Martins 👋🏽 Contact me!

Linkedin Badge

(back to top)