Build a URL Shortener: Setup Project & API

Giờ là lúc viết code. Trong bài này, chúng ta sẽ đi từ một thư mục trống đến URL shortener API hoạt động được. Bạn paste một URL dài, nhận lại short code, và truy cập short code đó để được redirect — tất cả chạy local với in-memory storage.
Chưa có database. Chưa có Redis. Chưa có frontend. Chỉ Express, TypeScript, và clean API design. Chúng ta sẽ thêm persistence ở bài tiếp theo.
Thời gian: 1–2 giờ
Prerequisites: Tổng Quan Series & Kiến Trúc
Những gì chúng ta sẽ build trong bài này:
✅ Express + TypeScript project với folder structure chuẩn
✅ POST /api/shorten — shorten URL và nhận short code
✅ GET /:code — redirect đến URL gốc
✅ GET /api/urls/:code — lấy thông tin URL và click count
✅ URL validation, error handling middleware và environment config
Project Scaffolding
Khởi tạo project:
mkdir url-shortener && cd url-shortener
npm init -yInstall Dependencies
# Runtime dependencies
npm install express dotenv nanoid@3
# Development dependencies
npm install -D typescript @types/express @types/node tsx nodemonTại sao chọn các packages này:
- express — HTTP framework
- dotenv — load environment variables từ
.env - nanoid@3 — fast, secure, URL-friendly ID generator (v3 cho CommonJS compatibility)
- tsx — chạy TypeScript trực tiếp không cần build step (development)
- nodemon — auto-restart khi file thay đổi
TypeScript Configuration
npx tsc --initUpdate tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}Những lựa chọn quan trọng:
target: ES2022— JavaScript features hiện đại (top-level await,Array.at())strict: true— bắt bugs sớm với full type checkingoutDir/rootDir— tách biệt rõ ràng giữa source và build output
NPM Scripts
Update package.json:
{
"scripts": {
"dev": "nodemon --watch src --ext ts --exec tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "tsc --noEmit"
}
}Environment Configuration
Tạo .env:
PORT=3000
BASE_URL=http://localhost:3000
NODE_ENV=developmentTạo .env.example (commit vào git):
PORT=3000
BASE_URL=http://localhost:3000
NODE_ENV=developmentThêm .gitignore:
node_modules/
dist/
.env
*.logFolder Structure
Đây là cấu trúc chúng ta sẽ build. Cố tình đơn giản lúc đầu — sẽ thêm directories khi project phát triển:
url-shortener/
├── src/
│ ├── config/
│ │ └── env.ts # Load environment variables
│ ├── middleware/
│ │ ├── errorHandler.ts # Global error handling
│ │ └── validateRequest.ts # Request validation helper
│ ├── routes/
│ │ ├── urlRoutes.ts # URL shortening endpoints
│ │ └── redirectRoutes.ts # Redirect endpoint
│ ├── services/
│ │ └── urlService.ts # Business logic
│ ├── store/
│ │ └── memoryStore.ts # In-memory URL storage
│ ├── utils/
│ │ └── shortCode.ts # Short code generation
│ ├── types/
│ │ └── url.ts # TypeScript interfaces
│ ├── app.ts # Express app setup
│ └── index.ts # Server entry point
├── .env
├── .env.example
├── .gitignore
├── package.json
└── tsconfig.jsonTại sao cấu trúc này:
config/— centralized environment config với validationroutes/— route definitions tách riêng khỏi logicservices/— business logic decouple khỏi HTTP layerstore/— data access layer (memory hiện tại, PostgreSQL sau)middleware/— reusable Express middlewaretypes/— shared TypeScript interfaces
Đây không phải overengineering — đây là cấu trúc tối thiểu cho phép chúng ta swap storage layer mà không cần rewrite routes hay services.
Type Definitions
Bắt đầu với các core types. Chúng define URL record trông như thế nào:
// src/types/url.ts
export interface UrlRecord {
id: string;
shortCode: string;
originalUrl: string;
createdAt: Date;
clickCount: number;
}
export interface CreateUrlRequest {
url: string;
customAlias?: string;
}
export interface CreateUrlResponse {
shortCode: string;
shortUrl: string;
originalUrl: string;
createdAt: Date;
}
export interface UrlStatsResponse {
shortCode: string;
shortUrl: string;
originalUrl: string;
createdAt: Date;
clickCount: number;
}Tách request/response types khỏi internal record cho chúng ta flexibility. UrlRecord có field id, nhưng chúng ta không expose nó trong API responses — short codes là public identifier.
Environment Configuration
Load và validate environment variables một lần lúc startup:
// src/config/env.ts
import dotenv from 'dotenv';
dotenv.config();
export const env = {
port: parseInt(process.env.PORT || '3000', 10),
baseUrl: process.env.BASE_URL || 'http://localhost:3000',
nodeEnv: process.env.NODE_ENV || 'development',
} as const;Dùng as const khiến các values này readonly ở type level. Nếu variable nào thiếu, chúng ta fall back về defaults hợp lý.
In-Memory Storage
Trước khi thêm database, chúng ta cần nơi lưu URLs. Map là hoàn hảo cho development:
// src/store/memoryStore.ts
import { UrlRecord } from '../types/url';
const urls = new Map<string, UrlRecord>();
export const memoryStore = {
save(record: UrlRecord): UrlRecord {
urls.set(record.shortCode, record);
return record;
},
findByShortCode(shortCode: string): UrlRecord | undefined {
return urls.get(shortCode);
},
findByOriginalUrl(originalUrl: string): UrlRecord | undefined {
for (const record of urls.values()) {
if (record.originalUrl === originalUrl) {
return record;
}
}
return undefined;
},
incrementClickCount(shortCode: string): void {
const record = urls.get(shortCode);
if (record) {
record.clickCount += 1;
}
},
exists(shortCode: string): boolean {
return urls.has(shortCode);
},
};Interface ở đây — save, findByShortCode, findByOriginalUrl, incrementClickCount — giống hệt những gì chúng ta sẽ implement với Prisma sau. Khi swap sang PostgreSQL, chỉ file này thay đổi.
Short Code Generation
Trái tim của mọi URL shortener. Chúng ta cần codes mà:
- Ngắn — 7 ký tự mặc định
- URL-safe — chỉ ký tự alphanumeric
- Unique — không collision (hoặc ít nhất, handle chúng)
// src/utils/shortCode.ts
import { nanoid, customAlphabet } from 'nanoid';
// Base62 alphabet: a-z, A-Z, 0-9
const ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const DEFAULT_LENGTH = 7;
const generate = customAlphabet(ALPHABET, DEFAULT_LENGTH);
export function generateShortCode(length: number = DEFAULT_LENGTH): string {
return generate(length);
}
export function isValidCustomAlias(alias: string): boolean {
if (alias.length < 3 || alias.length > 30) {
return false;
}
// Cho phép alphanumeric, hyphens và underscores
const validPattern = /^[a-zA-Z0-9_-]+$/;
return validPattern.test(alias);
}Tại Sao nanoid Thay Vì UUID Hay Math.random?
| Approach | Ví dụ | Độ dài | URL-safe | Collision Risk |
|---|---|---|---|---|
| UUID v4 | 550e8400-e29b-41d4-a716-446655440000 | 36 | Không (hyphens) | Gần như 0 |
| Math.random | 0.7381629 | Thay đổi | Không (dấu chấm) | Cao |
| nanoid (Base62) | aBc1X9z | 7 | Có | Thấp* |
*Với 7 ký tự từ bảng chữ cái 62 ký tự, chúng ta có 62^7 = 3.5 nghìn tỷ codes khả dụng. Với 1,000 URLs mỗi ngày, phải mất ~9.6 triệu năm mới hết. Dù vậy chúng ta vẫn sẽ handle collisions ở Bài #4.
Tại Sao customAlphabet?
nanoid() mặc định dùng A-Za-z0-9_- (64 ký tự). Chúng ta dùng customAlphabet để giữ Base62 thuần (62 ký tự) — không hyphens hay underscores trong codes. Giữ short URLs clean và tránh vấn đề với URL parsing.
URL Validation
Chúng ta cần validate rằng users submit URLs thật, không phải rác:
// src/middleware/validateRequest.ts
import { Request, Response, NextFunction } from 'express';
export function validateShortenRequest(
req: Request,
res: Response,
next: NextFunction
): void {
const { url, customAlias } = req.body;
// Kiểm tra URL có tồn tại
if (!url || typeof url !== 'string') {
res.status(400).json({
error: 'URL is required',
message: 'Please provide a valid URL in the request body',
});
return;
}
// Trim và validate URL format
const trimmedUrl = url.trim();
try {
const parsed = new URL(trimmedUrl);
// Chỉ cho phép http và https protocols
if (!['http:', 'https:'].includes(parsed.protocol)) {
res.status(400).json({
error: 'Invalid protocol',
message: 'Only HTTP and HTTPS URLs are supported',
});
return;
}
} catch {
res.status(400).json({
error: 'Invalid URL',
message: 'The provided string is not a valid URL',
});
return;
}
// Validate custom alias nếu có
if (customAlias !== undefined) {
if (typeof customAlias !== 'string') {
res.status(400).json({
error: 'Invalid alias',
message: 'Custom alias must be a string',
});
return;
}
const validPattern = /^[a-zA-Z0-9_-]+$/;
if (customAlias.length < 3 || customAlias.length > 30 || !validPattern.test(customAlias)) {
res.status(400).json({
error: 'Invalid alias',
message: 'Custom alias must be 3-30 characters, alphanumeric with hyphens and underscores',
});
return;
}
}
// Gắn cleaned URL vào request
req.body.url = trimmedUrl;
next();
}Tại Sao URL Validation Quan Trọng
Không có validation, attacker có thể:
- Submit
javascript:alert('xss')— XSS qua redirect - Submit
file:///etc/passwd— truy cập file local - Submit empty strings hoặc payloads khổng lồ — crash server
Chúng ta giới hạn protocol http: và https: và dùng built-in URL constructor, xử lý edge cases như malformed Unicode và missing TLDs.
URL Service (Business Logic)
Service layer chứa toàn bộ logic. Routes gọi service — service không biết gì về Express:
// src/services/urlService.ts
import { memoryStore } from '../store/memoryStore';
import { generateShortCode, isValidCustomAlias } from '../utils/shortCode';
import { env } from '../config/env';
import { CreateUrlResponse, UrlStatsResponse } from '../types/url';
export class UrlService {
async shortenUrl(originalUrl: string, customAlias?: string): Promise<CreateUrlResponse> {
// Kiểm tra URL đã được shorten chưa
const existing = memoryStore.findByOriginalUrl(originalUrl);
if (existing && !customAlias) {
return this.toCreateResponse(existing.shortCode, existing.originalUrl, existing.createdAt);
}
// Dùng custom alias hoặc generate short code
let shortCode: string;
if (customAlias) {
if (!isValidCustomAlias(customAlias)) {
throw new AppError('Invalid custom alias. Use 3-30 alphanumeric characters, hyphens, or underscores.', 400);
}
if (memoryStore.exists(customAlias)) {
throw new AppError('Custom alias is already taken', 409);
}
shortCode = customAlias;
} else {
shortCode = await this.generateUniqueCode();
}
// Lưu record
const record = memoryStore.save({
id: crypto.randomUUID(),
shortCode,
originalUrl,
createdAt: new Date(),
clickCount: 0,
});
return this.toCreateResponse(record.shortCode, record.originalUrl, record.createdAt);
}
async resolveAndTrack(shortCode: string): Promise<string> {
const record = memoryStore.findByShortCode(shortCode);
if (!record) {
throw new AppError('Short URL not found', 404);
}
// Tăng click count
memoryStore.incrementClickCount(shortCode);
return record.originalUrl;
}
async getUrlStats(shortCode: string): Promise<UrlStatsResponse> {
const record = memoryStore.findByShortCode(shortCode);
if (!record) {
throw new AppError('Short URL not found', 404);
}
return {
shortCode: record.shortCode,
shortUrl: `${env.baseUrl}/${record.shortCode}`,
originalUrl: record.originalUrl,
createdAt: record.createdAt,
clickCount: record.clickCount,
};
}
private async generateUniqueCode(): Promise<string> {
const MAX_RETRIES = 5;
for (let i = 0; i < MAX_RETRIES; i++) {
const code = generateShortCode();
if (!memoryStore.exists(code)) {
return code;
}
}
throw new AppError('Failed to generate unique short code. Please try again.', 500);
}
private toCreateResponse(shortCode: string, originalUrl: string, createdAt: Date): CreateUrlResponse {
return {
shortCode,
shortUrl: `${env.baseUrl}/${shortCode}`,
originalUrl,
createdAt,
};
}
}
// Custom error class với HTTP status code
export class AppError extends Error {
constructor(
message: string,
public statusCode: number
) {
super(message);
this.name = 'AppError';
}
}Key Design Decisions
URL deduplication — nếu ai đó shorten cùng URL hai lần (không dùng custom alias), chúng ta trả về short code hiện có. Ngăn database đầy duplicates. Chúng ta sẽ làm cho configurable sau.
Retry loop cho collisions — generateUniqueCode() thử tối đa 5 lần để tìm code chưa dùng. Với 3.5 nghìn tỷ codes 7 ký tự khả dụng, collisions cực kỳ hiếm. Nhưng handle chúng là sự khác biệt giữa demo và hệ thống thực.
Service là async — dù memoryStore là synchronous, chúng ta dùng async methods. Điều này có nghĩa zero changes khi swap sang Prisma async database calls.
Error Handling Middleware
Express cần centralized error handler. Không có nó, unhandled errors sẽ crash server:
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../services/urlService';
export function errorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction
): void {
// Handle known application errors
if (err instanceof AppError) {
res.status(err.statusCode).json({
error: err.message,
});
return;
}
// Handle JSON parse errors
if (err.type === 'entity.parse.failed') {
res.status(400).json({
error: 'Invalid JSON in request body',
});
return;
}
// Log unexpected errors
console.error('Unexpected error:', err);
res.status(500).json({
error: 'Internal server error',
});
}Class AppError mang theo HTTP status code. Service throw AppError với code đúng (400, 404, 409), và error handler chuyển nó thành JSON response. Unknown errors luôn trả 500 — không bao giờ leak stack traces cho clients.
Lưu ý: Check
err.typedùng property được Express body-parser đặt khi JSON parsing fail. TypeScript có thể flag — bạn có thể dùng(err as any).typehoặc extend Error type.
Routes
URL Shortening Routes
// src/routes/urlRoutes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { UrlService } from '../services/urlService';
import { validateShortenRequest } from '../middleware/validateRequest';
const router = Router();
const urlService = new UrlService();
// POST /api/shorten — tạo short URL
router.post(
'/shorten',
validateShortenRequest,
async (req: Request, res: Response, next: NextFunction) => {
try {
const { url, customAlias } = req.body;
const result = await urlService.shortenUrl(url, customAlias);
res.status(201).json(result);
} catch (error) {
next(error);
}
}
);
// GET /api/urls/:code — lấy thông tin URL và stats
router.get(
'/urls/:code',
async (req: Request, res: Response, next: NextFunction) => {
try {
const stats = await urlService.getUrlStats(req.params.code);
res.json(stats);
} catch (error) {
next(error);
}
}
);
export default router;Redirect Routes
// src/routes/redirectRoutes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { UrlService } from '../services/urlService';
const router = Router();
const urlService = new UrlService();
// GET /:code — redirect đến URL gốc
router.get(
'/:code',
async (req: Request, res: Response, next: NextFunction) => {
try {
const originalUrl = await urlService.resolveAndTrack(req.params.code);
res.redirect(302, originalUrl);
} catch (error) {
next(error);
}
}
);
export default router;Tại Sao Tách Riêng Route Files?
Redirect route (GET /:code) mount ở root. API routes (/api/*) mount dưới prefix. Tách riêng giúp thứ tự mount rõ ràng trong app.ts — API routes phải đặt trước catch-all redirect route.
301 vs 302 Redirects
Chúng ta dùng 302 (temporary redirect) hiện tại. Lý do:
| Status | Loại | Hành vi Browser | SEO Effect |
|---|---|---|---|
| 301 | Permanent | Cache mãi mãi, không bao giờ hit server nữa | Chuyển link equity sang destination |
| 302 | Temporary | Hit server mỗi lần | Không chuyển link equity |
Chúng ta muốn 302 vì:
- Analytics — mỗi redirect đi qua server, để đếm clicks
- Flexibility — có thể thay destination URL sau
- Expiration — có thể disable short URLs mà browsers không cache redirect cũ
Chúng ta sẽ làm configurable ở bài sau. Một số use cases (như permanent marketing links) hưởng lợi từ 301.
Express App Setup
Kết nối mọi thứ lại:
// src/app.ts
import express from 'express';
import urlRoutes from './routes/urlRoutes';
import redirectRoutes from './routes/redirectRoutes';
import { errorHandler } from './middleware/errorHandler';
const app = express();
// Middleware
app.use(express.json({ limit: '10kb' }));
// Health check
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API routes (phải đặt trước redirect catch-all)
app.use('/api', urlRoutes);
// Redirect catch-all (phải đặt cuối)
app.use(redirectRoutes);
// Error handler (phải đặt sau tất cả routes)
app.use(errorHandler);
export default app;Thứ tự quan trọng:
express.json()— parse JSON bodies trước/health— health check trước logic phức tạp/api/*— API routes match trước/:code— catch-all redirect match cuốierrorHandler— bắt mọi errors từ trên
Nếu đặt /:code trước /api/*, requests tới /api/shorten sẽ match /:code với code = "api" và cố redirect — không phải điều chúng ta muốn.
Body Size Limit
express.json({ limit: '10kb' }) ngăn attackers gửi JSON payloads khổng lồ. Một URL thường dưới 2KB. 10KB cho đủ chỗ trong khi block abuse.
Server Entry Point
// src/index.ts
import app from './app';
import { env } from './config/env';
const server = app.listen(env.port, () => {
console.log(`🚀 URL Shortener running at ${env.baseUrl}`);
console.log(` Environment: ${env.nodeEnv}`);
console.log(` Health check: ${env.baseUrl}/health`);
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM received. Shutting down gracefully...');
server.close(() => {
console.log('Server closed');
process.exit(0);
});
});Graceful shutdown quan trọng ngay cả trong development. Khi bạn nhấn Ctrl+C, server hoàn thành xử lý các in-flight requests trước khi exit thay vì drop connections.
Test Thử
Start development server:
npm run devBạn sẽ thấy:
🚀 URL Shortener running at http://localhost:3000
Environment: development
Health check: http://localhost:3000/healthTest 1: Health Check
curl http://localhost:3000/health{
"status": "ok",
"timestamp": "2026-03-20T10:00:00.000Z"
}Test 2: Shorten một URL
curl -X POST http://localhost:3000/api/shorten \
-H "Content-Type: application/json" \
-d '{"url": "https://github.com/expressjs/express"}'{
"shortCode": "aBc1X9z",
"shortUrl": "http://localhost:3000/aBc1X9z",
"originalUrl": "https://github.com/expressjs/express",
"createdAt": "2026-03-20T10:00:05.000Z"
}Test 3: Shorten với Custom Alias
curl -X POST http://localhost:3000/api/shorten \
-H "Content-Type: application/json" \
-d '{"url": "https://github.com/expressjs/express", "customAlias": "express-repo"}'{
"shortCode": "express-repo",
"shortUrl": "http://localhost:3000/express-repo",
"originalUrl": "https://github.com/expressjs/express",
"createdAt": "2026-03-20T10:00:10.000Z"
}Test 4: Redirect
curl -v http://localhost:3000/aBc1X9z< HTTP/1.1 302 Found
< Location: https://github.com/expressjs/expressHoặc mở http://localhost:3000/aBc1X9z trong browser — nó redirect sang GitHub.
Test 5: Lấy URL Stats
curl http://localhost:3000/api/urls/aBc1X9z{
"shortCode": "aBc1X9z",
"shortUrl": "http://localhost:3000/aBc1X9z",
"originalUrl": "https://github.com/expressjs/express",
"createdAt": "2026-03-20T10:00:05.000Z",
"clickCount": 1
}Test 6: Error Cases
URL không hợp lệ:
curl -X POST http://localhost:3000/api/shorten \
-H "Content-Type: application/json" \
-d '{"url": "not-a-url"}'{
"error": "Invalid URL",
"message": "The provided string is not a valid URL"
}Thiếu URL:
curl -X POST http://localhost:3000/api/shorten \
-H "Content-Type: application/json" \
-d '{}'{
"error": "URL is required",
"message": "Please provide a valid URL in the request body"
}Short code không tồn tại:
curl http://localhost:3000/api/urls/doesnotexist{
"error": "Short URL not found"
}Duplicate custom alias:
curl -X POST http://localhost:3000/api/shorten \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "customAlias": "express-repo"}'{
"error": "Custom alias is already taken"
}Hiểu Request Flow
Hãy trace chuyện gì xảy ra khi user shorten một URL:
Và chuyện gì xảy ra khi redirect:
Trạng Thái Project Hiện Tại
Đây là file tree hoàn chỉnh sau bài này:
url-shortener/
├── src/
│ ├── config/
│ │ └── env.ts
│ ├── middleware/
│ │ ├── errorHandler.ts
│ │ └── validateRequest.ts
│ ├── routes/
│ │ ├── urlRoutes.ts
│ │ └── redirectRoutes.ts
│ ├── services/
│ │ └── urlService.ts
│ ├── store/
│ │ └── memoryStore.ts
│ ├── utils/
│ │ └── shortCode.ts
│ ├── types/
│ │ └── url.ts
│ ├── app.ts
│ └── index.ts
├── .env
├── .env.example
├── .gitignore
├── package.json
└── tsconfig.jsonTóm Tắt API
| Method | Endpoint | Mô tả | Status Codes |
|---|---|---|---|
POST | /api/shorten | Shorten một URL | 201, 400, 409, 500 |
GET | /api/urls/:code | Lấy thông tin URL và stats | 200, 404 |
GET | /:code | Redirect đến URL gốc | 302, 404 |
GET | /health | Health check | 200 |
Sai Lầm Thường Gặp Cần Tránh
1. Không Validate URLs
Không bao giờ trust user input. Không có validation, ai đó có thể submit javascript:alert(1) và shortener của bạn trở thành XSS vector.
2. Thứ Tự Routes Quan Trọng
// SAI — /:code bắt hết mọi thứ, kể cả /api/*
app.use(redirectRoutes); // GET /:code
app.use('/api', urlRoutes);
// ĐÚNG — /api/* match trước
app.use('/api', urlRoutes);
app.use(redirectRoutes); // GET /:code3. Quên Error Handling Trong Async Routes
// SAI — uncaught async error crash server
router.post('/shorten', async (req, res) => {
const result = await urlService.shortenUrl(req.body.url);
res.json(result);
});
// ĐÚNG — errors forward sang error handler
router.post('/shorten', async (req, res, next) => {
try {
const result = await urlService.shortenUrl(req.body.url);
res.json(result);
} catch (error) {
next(error);
}
});4. Dùng 301 Cho Redirects Quá Sớm
301 (permanent redirect) bị browsers cache — bạn không thể undo. Bắt đầu với 302 và chỉ chuyển sang 301 khi chắc chắn destination sẽ không thay đổi.
Tiếp Theo Là Gì?
URL shortener của chúng ta hoạt động rồi, nhưng nó quên hết mọi thứ khi server restart. Trong Bài #3, chúng ta sẽ thêm PostgreSQL với Prisma ORM:
- Docker Compose cho PostgreSQL
- Prisma schema design với migrations
- Thay
memoryStorebằng database queries - Indexing strategy cho fast short code lookups
- Connection pooling configuration
Cái đẹp của layered architecture: chỉ store/ layer thay đổi. Routes và services giữ nguyên.
Series: Build a URL Shortener
Previous: Tổng Quan Series & Kiến Trúc
Next: Phase 2: Database Design & URL Storage
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.