A production-ready URL shortening backend built with Spring Boot, MySQL, and JWT authentication. Designed with SOLID principles throughout.
| Layer | Technology |
|---|---|
| Language | Java 17 |
| Framework | Spring Boot 3.2.x |
| Database | MySQL 8.x |
| ORM | Spring Data JPA + Hibernate |
| Security | Spring Security + JWT (jjwt 0.12.3) |
| Build Tool | Maven |
| Utilities | Lombok, Validation |
src/main/java/com/example/URLShortner/
├── config/
│ └── SecurityConfig.java # Spring Security config, JWT filter chain
├── controller/
│ ├── AuthController.java # Register + login endpoints
│ ├── UrlController.java # Shorten, redirect, list, delete endpoints
│ └── GlobalExceptionHandler.java # Centralized error handling
├── dto/
│ ├── request/
│ │ ├── RegisterRequest.java # Email + password input
│ │ ├── LoginRequest.java # Email + password input
│ │ └── ShortenRequest.java # URL + optional alias + optional expiry
│ └── response/
│ ├── AuthResponse.java # JWT token + user info
│ ├── UrlResponse.java # Short URL details
│ └── ErrorResponse.java # Standardized error shape
├── model/
│ ├── User.java # users table entity
│ └── Url.java # urls table entity
├── repository/
│ ├── UserRepository.java # User DB access
│ └── UrlRepository.java # URL DB access
├── scheduler/
│ └── UrlCleanupScheduler.java # Nightly expired URL cleanup
├── security/
│ ├── JwtUtil.java # Token generation + validation
│ ├── JwtFilter.java # Intercepts every request
│ └── UserDetailsServiceImpl.java # Loads user from DB for Spring Security
├── service/
│ ├── AuthService.java # Auth interface (OCP)
│ ├── UrlService.java # URL interface (OCP)
│ └── impl/
│ ├── AuthServiceImpl.java # Register + login logic
│ └── UrlServiceImpl.java # Shorten + redirect + manage logic
└── util/
└── Base62Encoder.java # Converts auto-increment ID → short code
| Column | Type | Constraints |
|---|---|---|
| id | BIGINT | PK, AUTO_INCREMENT |
| VARCHAR(100) | NOT NULL, UNIQUE | |
| password | VARCHAR(255) | NOT NULL (BCrypt hashed) |
| role | ENUM(USER, ADMIN) | NOT NULL |
| created_at | DATETIME | NOT NULL |
| is_enabled | TINYINT(1) | NOT NULL, DEFAULT 1 |
| Column | Type | Constraints |
|---|---|---|
| id | BIGINT | PK, AUTO_INCREMENT |
| short_code | VARCHAR(20) | UNIQUE |
| long_url | VARCHAR(2048) | NOT NULL |
| user_id | BIGINT | FK → users(id), NULLABLE |
| expires_at | DATETIME | NULLABLE |
| is_active | TINYINT(1) | NOT NULL, DEFAULT 1 |
| click_count | BIGINT | NOT NULL, DEFAULT 0 |
| created_at | DATETIME | NOT NULL |
| custom_alias | VARCHAR(50) | UNIQUE, NULLABLE |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /api/auth/register |
Public | Create account, returns JWT |
| POST | /api/auth/login |
Public | Login, returns JWT |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /api/urls/shorten |
Public | Shorten a URL |
| GET | /api/urls/my |
Required | Get all my URLs |
| DELETE | /api/urls/{shortCode} |
Required | Soft delete a URL |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /{shortCode} |
Public | Redirect to original URL |
Each class has exactly one job. AuthController only handles HTTP routing. AuthServiceImpl only handles auth logic. Base62Encoder only encodes numbers to short codes. No class mixes responsibilities.
AuthService and UrlService are interfaces. Controllers depend only on interfaces — never on concrete implementations. To add a new auth strategy (e.g. OAuth), create a new class implementing AuthService without touching the controller.
Any implementation of AuthService or UrlService is safely swappable. The controller works identically regardless of which implementation Spring injects. Demonstrated by the clean interface-to-implementation separation throughout.
Every URL row gets an auto-increment integer ID from MySQL. That ID is encoded to Base62 to create the short code.
Characters: abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789
Base: 62
id = 1 → "b"
id = 100 → "bM"
id = 100523 → "q7X"
The encoding is deterministic — same ID always produces the same code. Zero collision possible because MySQL guarantees unique auto-increment IDs.
This approach was chosen over random generation (collision risk at scale) and UUID (too long for a short URL).
1. POST /api/auth/login with email + password
2. Server verifies BCrypt password hash
3. Server generates JWT: { subject: email, expiry: 24h }
4. Client stores token
5. Client sends: Authorization: Bearer <token>
6. JwtFilter intercepts every request
7. Validates token signature and expiry
8. Sets user in Spring Security context
9. Controller receives authenticated request
Tokens are stateless — the server stores nothing. Every request is self-contained.
POST /api/urls/shorten { longUrl: "https://google.com" }
↓
1. Check custom alias not taken (if provided)
2. Save Url row to MySQL with shortCode = null
3. MySQL returns auto-increment id (e.g. 1)
4. Base62 encode id → shortCode (e.g. "b")
5. Update row with shortCode
6. Return { shortUrl: "http://localhost:8080/b", ... }
GET /b
↓
1. Look up shortCode "b" in MySQL
2. Check is_active = true
3. Check not expired
4. Increment click_count
5. Return 302 redirect to "https://google.com"
- Java 17+
- MySQL 8.x
- Maven 3.x
CREATE DATABASE url_shortener_db;spring.datasource.url=jdbc:mysql://localhost:3306/url_shortener_db
spring.datasource.username=root
spring.datasource.password=your_password
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
app.jwt.secret=404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
app.jwt.expiration-ms=86400000
app.base-url=http://localhost:8080
server.port=8080mvn spring-boot:runApp starts on http://localhost:8080
POST http://localhost:8080/api/auth/register
Content-Type: application/json
{
"email": "user@gmail.com",
"password": "secret123"
}
POST http://localhost:8080/api/auth/login
Content-Type: application/json
{
"email": "user@gmail.com",
"password": "secret123"
}
POST http://localhost:8080/api/urls/shorten
Content-Type: application/json
{
"longUrl": "https://www.google.com",
"customAlias": "google",
"expiryDays": 30
}
Open in browser: http://localhost:8080/google
→ Redirects to https://www.google.com
GET http://localhost:8080/api/urls/my
Authorization: Bearer <token>
| Feature | Technology | Why |
|---|---|---|
| URL caching | Redis | Redirect in ~3ms instead of ~50ms |
| Rate limiting | Redis sliding window | Prevent abuse |
| Click analytics | Kafka + MongoDB | Async processing, geo + device breakdown |
| Cleanup | Enhanced scheduler | Redis cache eviction on expiry |
Why soft delete instead of hard delete?
Setting is_active = false instead of deleting the row preserves click history and allows recovery. A hard delete would lose all analytics data permanently.
Why save twice when shortening? MySQL must assign the auto-increment ID before we can Base62-encode it. First save creates the row and returns the ID. Second save updates the shortCode. This guarantees zero collisions without any random generation or UUID.
Why separate request and response DTOs?
The User entity has sensitive fields like password and isEnabled that must never be exposed in API responses. DTOs give full control over what enters and exits the API layer.
Why JWT over sessions? REST APIs should be stateless. JWT tokens carry all auth information inside themselves — the server stores nothing between requests. This makes the app horizontally scalable — any server instance can validate any token.