device.controller.ts
βββ types/
βββ device.types.ts
#### 2. Type Definitions and In-Memory Store
Define the resource interface and a type-safe store. Using a `Map` instead of an array provides constant-time lookups, which is essential for performance as the dataset grows.
```typescript
// src/types/device.types.ts
export interface SmartDevice {
id: string;
name: string;
status: 'online' | 'offline';
firmwareVersion: string;
}
// src/store/device.store.ts
import { SmartDevice } from '../types/device.types';
// Map provides O(1) retrieval vs O(n) for Array.find
export const deviceStore = new Map<string, SmartDevice>();
// Seed data
deviceStore.set('dev-001', {
id: 'dev-001',
name: 'Thermostat-LivingRoom',
status: 'online',
firmwareVersion: '2.1.0',
});
3. Controller Implementation
Controllers handle the request/response cycle and business logic. They should remain pure functions relative to the Express request object.
// src/controllers/device.controller.ts
import { Request, Response } from 'express';
import { deviceStore } from '../store/device.store';
import { SmartDevice } from '../types/device.types';
import { v4 as uuidv4 } from 'uuid';
export const createDevice = (req: Request, res: Response): void => {
const { name, firmwareVersion } = req.body;
if (!name || !firmwareVersion) {
res.status(400).json({ error: 'Name and firmwareVersion are required' });
return;
}
// Server generates ID; client never provides it
const newDevice: SmartDevice = {
id: uuidv4(),
name,
status: 'offline',
firmwareVersion,
};
deviceStore.set(newDevice.id, newDevice);
res.status(201).json(newDevice);
};
export const getDeviceById = (req: Request, res: Response): void => {
const deviceId = req.params.id;
const device = deviceStore.get(deviceId);
if (!device) {
res.status(404).json({ error: 'Device not found' });
return;
}
res.json(device);
};
export const updateDeviceStatus = (req: Request, res: Response): void => {
const deviceId = req.params.id;
const { status } = req.body;
const device = deviceStore.get(deviceId);
if (!device) {
res.status(404).json({ error: 'Device not found' });
return;
}
// PATCH semantics: partial update
device.status = status;
deviceStore.set(deviceId, device);
res.json(device);
};
export const deleteDevice = (req: Request, res: Response): void => {
const deviceId = req.params.id;
const deleted = deviceStore.delete(deviceId);
if (!deleted) {
res.status(404).json({ error: 'Device not found' });
return;
}
res.status(204).send();
};
4. Modular Router Configuration
Use express.Router() to encapsulate routes. This allows you to mount the router at a specific path prefix and apply middleware selectively.
// src/routes/device.routes.ts
import { Router } from 'express';
import * as deviceController from '../controllers/device.controller';
const deviceRouter = Router();
// GET /devices/:id
deviceRouter.get('/:id', deviceController.getDeviceById);
// POST /devices
deviceRouter.post('/', deviceController.createDevice);
// PATCH /devices/:id
deviceRouter.patch('/:id', deviceController.updateDeviceStatus);
// DELETE /devices/:id
deviceRouter.delete('/:id', deviceController.deleteDevice);
export default deviceRouter;
5. Application Entry Point
Mount the router in the main application file. This keeps app.ts clean and declarative.
// src/app.ts
import express, { Request, Response, NextFunction } from 'express';
import deviceRouter from './routes/device.routes';
const app = express();
// Global middleware
app.use(express.json());
// Mount router with versioning prefix
app.use('/api/v1/devices', deviceRouter);
// Global error handler
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal Server Error' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Architecture Decisions:
- TypeScript: Enforces type safety across routes, controllers, and stores, reducing runtime errors.
- Map Store: Replaces array iteration with hash map lookups, improving performance for
GET, PATCH, and DELETE operations.
- Controller Separation: Decouples routing logic from business logic, enabling independent unit testing of controllers.
- UUID Generation: Server-side ID generation prevents ID collisions and security risks associated with client-provided identifiers.
- HTTP Status Codes: Explicit use of
201, 204, 400, and 404 ensures clients receive accurate feedback.
Pitfall Guide
-
The String-Number Type Trap
- Explanation:
req.params values are always strings. Comparing req.params.id === 1 will always fail because "1" !== 1.
- Fix: Convert params explicitly using
Number(req.params.id) or parseInt(), or use string-based IDs (like UUIDs) consistently throughout the stack.
-
Route Collision and Order Sensitivity
- Explanation: Express matches routes in the order they are defined. A wildcard route like
/:id will match before a specific route like /search if defined first.
- Fix: Define specific routes before parameterized routes. Place
/devices/search before /devices/:id.
-
Missing Body Parsing Middleware
- Explanation: Accessing
req.body without express.json() results in undefined, causing silent failures in POST/PUT handlers.
- Fix: Apply
app.use(express.json()) globally or attach it to specific routes that require body parsing.
-
Client-Generated Resource IDs
- Explanation: Allowing clients to send IDs during creation (
POST) can lead to collisions, overwrites, or security vulnerabilities.
- Fix: Always generate IDs server-side using a reliable generator like
uuid or database sequences. Reject ID fields in creation payloads.
-
Silent Failures and Missing Status Codes
- Explanation: Using
res.send() without a status code defaults to 200 OK, even for errors. This misleads clients and monitoring tools.
- Fix: Always chain
res.status(code) before sending the response. Use 201 for creation, 204 for deletion, 400 for validation errors, and 404 for missing resources.
-
Confusing PUT vs PATCH Semantics
- Explanation:
PUT implies a full replacement of the resource, while PATCH indicates a partial update. Using PUT for partial updates can unintentionally nullify fields not included in the payload.
- Fix: Use
PATCH for partial updates and PUT only when the client sends the complete resource representation. Document the distinction clearly.
-
Monolithic Route Definitions
- Explanation: Defining all routes in
app.ts creates a bottleneck file that grows unmanageably and increases merge conflicts in team environments.
- Fix: Use
express.Router() to split routes by feature or domain. Mount routers in the entry file to maintain a clean architecture.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Prototype / Script | Inline routes in app.ts | Minimal boilerplate; fastest to implement. | Low |
| Production API | Modular Routers + Controllers | Separation of concerns; testable; scalable. | Medium |
| High-Traffic Service | Routers + Caching Layer + DB Index | Reduces load on origin; improves latency. | High |
| Microservices | Gateway Router + Service Routers | Centralized routing; service isolation. | High |
Configuration Template
Copy this template to bootstrap a production-ready Express application with routing, validation, and error handling.
// src/app.ts
import express, { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import deviceRouter from './routes/device.routes';
const app = express();
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Request logging
app.use((req: Request, _res: Response, next: NextFunction) => {
console.log(`${req.method} ${req.path}`);
next();
});
// Routes
app.use('/api/v1/devices', deviceRouter);
// Validation helper example
export const validate = (schema: z.ZodSchema) => {
return (req: Request, res: Response, next: NextFunction) => {
try {
schema.parse({
body: req.body,
query: req.query,
params: req.params,
});
next();
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'Validation failed', details: err.errors });
} else {
next(err);
}
}
};
};
// Global Error Handler
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal Server Error' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server ready on port ${PORT}`));
Quick Start Guide
- Initialize Project: Run
npm init -y and install dependencies: npm install express @types/express uuid.
- Create Router: Define a new router in
src/routes/index.ts using express.Router() and export it.
- Mount Router: Import the router in
src/app.ts and mount it using app.use('/api/v1', router).
- Add Middleware: Apply
express.json() globally to enable body parsing for all routes.
- Run Server: Start the application with
ts-node src/app.ts and verify routes using curl or Postman.