Building APIs I Don't Hate Maintaining (Flask / Node Lessons Learned)
Hard-won lessons from maintaining production APIs in Flask and Node.js—patterns that actually make your life easier, not harder.
I've built and maintained dozens of APIs over the years. Some were a joy to work on. Others made me want to quit programming. Here's what I learned building APIs in Flask and Node.js that I actually don't hate maintaining.
The Pain Points Nobody Talks About
Before diving into solutions, let's acknowledge the real problems:
Three months after launch:
- "Why is this endpoint so slow?"
- "Where do we validate the email format?"
- "Which version of the API is the mobile app using?"
- "Why did this validation pass in dev but fail in production?"
- "How do I test this without breaking production data?"
Sound familiar? These are maintenance problems, not development problems.
Lesson One: Consistent Error Handling Saves Your Sanity
The Wrong Way (My First APIs)
Flask version:
# routes/users.py
@app.route('/users/<user_id>')
def get_user(user_id):
user = db.query(f"SELECT * FROM users WHERE id = {user_id}")
if not user:
return "User not found", 404
return jsonify(user)
# routes/posts.py
@app.route('/posts/<post_id>')
def get_post(post_id):
post = Post.query.get(post_id)
if not post:
return {"error": "Post not found"}, 404
return jsonify(post)
# routes/comments.py
@app.route('/comments/<comment_id>')
def get_comment(comment_id):
comment = Comment.find(comment_id)
if not comment:
return {'message': 'Comment not found', 'status': 404}, 404
return jsonify(comment)Three different error formats. Debugging was a nightmare.
Node.js version:
// routes/users.js
app.get("/users/:id", async (req, res) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).send("Not found");
}
res.json(user);
} catch (err) {
res.status(500).send(err.message);
}
});
// routes/posts.js
app.get("/posts/:id", async (req, res) => {
const post = await Post.findById(req.params.id);
if (!post) {
return res.status(404).json({ error: "Post not found" });
}
res.json(post);
});Inconsistent error responses make client integration painful.
The Right Way (What I Do Now)
Flask version with custom exceptions:
# exceptions.py
class APIException(Exception):
"""Base exception for all API errors"""
status_code = 500
def __init__(self, message=None, status_code=None, payload=None):
super().__init__()
self.message = message
if status_code is not None:
self.status_code = status_code
self.payload = payload
def to_dict(self):
rv = dict(self.payload or ())
rv['message'] = self.message
rv['status'] = self.status_code
return rv
class NotFoundError(APIException):
status_code = 404
class ValidationError(APIException):
status_code = 400
class UnauthorizedError(APIException):
status_code = 401
# app.py
@app.errorhandler(APIException)
def handle_api_exception(error):
response = jsonify(error.to_dict())
response.status_code = error.status_code
return response
@app.errorhandler(Exception)
def handle_unexpected_error(error):
# Log the full error
app.logger.error(f"Unexpected error: {error}", exc_info=True)
# Return safe error to client
return jsonify({
'message': 'Internal server error',
'status': 500
}), 500
# routes/users.py
@app.route('/users/<user_id>')
def get_user(user_id):
user = User.query.get(user_id)
if not user:
raise NotFoundError('User not found')
return jsonify(user.to_dict())
@app.route('/users', methods=['POST'])
def create_user():
data = request.get_json()
if not data.get('email'):
raise ValidationError('Email is required')
if User.query.filter_by(email=data['email']).first():
raise ValidationError('Email already exists')
user = User(**data)
db.session.add(user)
db.session.commit()
return jsonify(user.to_dict()), 201Node.js version with error middleware:
// errors/ApiError.js
class ApiError extends Error {
constructor(statusCode, message, isOperational = true, stack = "") {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
if (stack) {
this.stack = stack;
} else {
Error.captureStackTrace(this, this.constructor);
}
}
}
class NotFoundError extends ApiError {
constructor(message = "Resource not found") {
super(404, message);
}
}
class ValidationError extends ApiError {
constructor(message) {
super(400, message);
}
}
class UnauthorizedError extends ApiError {
constructor(message = "Unauthorized") {
super(401, message);
}
}
module.exports = {
ApiError,
NotFoundError,
ValidationError,
UnauthorizedError,
};
// middleware/errorHandler.js
const { ApiError } = require("../errors/ApiError");
const errorHandler = (err, req, res, next) => {
let { statusCode, message } = err;
// Log all errors
console.error("Error:", {
message: err.message,
stack: err.stack,
url: req.url,
method: req.method,
});
// Handle operational errors
if (err instanceof ApiError) {
res.status(statusCode).json({
status: statusCode,
message: message,
});
return;
}
// Handle unexpected errors
res.status(500).json({
status: 500,
message: "Internal server error",
});
};
module.exports = errorHandler;
// app.js
const express = require("express");
const errorHandler = require("./middleware/errorHandler");
const app = express();
// Routes
app.use("/api/users", userRoutes);
app.use("/api/posts", postRoutes);
// Error handling middleware (must be last)
app.use(errorHandler);
// routes/users.js
const { NotFoundError, ValidationError } = require("../errors/ApiError");
router.get("/:id", async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
throw new NotFoundError("User not found");
}
res.json(user);
} catch (error) {
next(error);
}
});
router.post("/", async (req, res, next) => {
try {
const { email, name } = req.body;
if (!email) {
throw new ValidationError("Email is required");
}
const existingUser = await User.findOne({ email });
if (existingUser) {
throw new ValidationError("Email already exists");
}
const user = await User.create({ email, name });
res.status(201).json(user);
} catch (error) {
next(error);
}
});Benefits:
- Consistent error format across all endpoints
- Easy to add new error types
- Proper logging for debugging
- Safe error messages to clients
- Single place to modify error handling
Lesson Two: Request Validation Should Be Declarative
The Wrong Way
Flask - Manual validation:
@app.route('/users', methods=['POST'])
def create_user():
data = request.get_json()
# Validation hell
if 'email' not in data:
return {'error': 'Email is required'}, 400
if not re.match(r'^[\w\.-]+@[\w\.-]+\.\w+$', data['email']):
return {'error': 'Invalid email format'}, 400
if 'password' not in data:
return {'error': 'Password is required'}, 400
if len(data['password']) < 8:
return {'error': 'Password must be at least 8 characters'}, 400
if 'name' not in data:
return {'error': 'Name is required'}, 400
if len(data['name']) < 2:
return {'error': 'Name must be at least 2 characters'}, 400
# Actually create the user...Node.js - Manual validation:
app.post("/users", async (req, res) => {
const { email, password, name } = req.body;
// Validation spaghetti
if (!email) {
return res.status(400).json({ error: "Email is required" });
}
if (!/^[\w\.-]+@[\w\.-]+\.\w+$/.test(email)) {
return res.status(400).json({ error: "Invalid email" });
}
if (!password) {
return res.status(400).json({ error: "Password is required" });
}
if (password.length < 8) {
return res.status(400).json({ error: "Password too short" });
}
// Finally create the user...
});Problems:
- Validation mixed with business logic
- Hard to reuse validation rules
- Difficult to test
- Error messages inconsistent
The Right Way
Flask with Marshmallow:
# schemas.py
from marshmallow import Schema, fields, validate, ValidationError
class UserSchema(Schema):
email = fields.Email(required=True)
password = fields.Str(
required=True,
validate=validate.Length(min=8, max=100)
)
name = fields.Str(
required=True,
validate=validate.Length(min=2, max=100)
)
age = fields.Int(validate=validate.Range(min=18, max=120))
role = fields.Str(
validate=validate.OneOf(['user', 'admin', 'moderator'])
)
class PostSchema(Schema):
title = fields.Str(
required=True,
validate=validate.Length(min=1, max=200)
)
content = fields.Str(required=True)
tags = fields.List(fields.Str(), validate=validate.Length(max=10))
published = fields.Boolean(missing=False)
# decorators.py
from functools import wraps
from flask import request
def validate_schema(schema_class):
"""Decorator to validate request data against a schema"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
schema = schema_class()
try:
validated_data = schema.load(request.get_json())
request.validated_data = validated_data
return f(*args, **kwargs)
except ValidationError as err:
return jsonify({'errors': err.messages}), 400
return decorated_function
return decorator
# routes/users.py
@app.route('/users', methods=['POST'])
@validate_schema(UserSchema)
def create_user():
# request.validated_data is already validated!
user = User(**request.validated_data)
db.session.add(user)
db.session.commit()
return jsonify(user.to_dict()), 201
@app.route('/users/<user_id>', methods=['PUT'])
@validate_schema(UserSchema)
def update_user(user_id):
user = User.query.get_or_404(user_id)
for key, value in request.validated_data.items():
setattr(user, key, value)
db.session.commit()
return jsonify(user.to_dict())Node.js with Joi:
// validators/userValidator.js
const Joi = require("joi");
const userSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).max(100).required(),
name: Joi.string().min(2).max(100).required(),
age: Joi.number().integer().min(18).max(120),
role: Joi.string().valid("user", "admin", "moderator"),
});
const postSchema = Joi.object({
title: Joi.string().min(1).max(200).required(),
content: Joi.string().required(),
tags: Joi.array().items(Joi.string()).max(10),
published: Joi.boolean().default(false),
});
module.exports = { userSchema, postSchema };
// middleware/validate.js
const { ValidationError } = require("../errors/ApiError");
const validate = (schema) => {
return (req, res, next) => {
const { error, value } = schema.validate(req.body, {
abortEarly: false, // Return all errors, not just the first
stripUnknown: true, // Remove unknown fields
});
if (error) {
const errors = error.details.map((detail) => ({
field: detail.path.join("."),
message: detail.message,
}));
throw new ValidationError(JSON.stringify(errors));
}
// Replace body with validated and sanitized data
req.body = value;
next();
};
};
module.exports = validate;
// routes/users.js
const validate = require("../middleware/validate");
const { userSchema } = require("../validators/userValidator");
router.post("/", validate(userSchema), async (req, res, next) => {
try {
// req.body is already validated and sanitized
const user = await User.create(req.body);
res.status(201).json(user);
} catch (error) {
next(error);
}
});
router.put("/:id", validate(userSchema), async (req, res, next) => {
try {
const user = await User.findByIdAndUpdate(req.params.id, req.body, {
new: true,
runValidators: true,
});
if (!user) {
throw new NotFoundError("User not found");
}
res.json(user);
} catch (error) {
next(error);
}
});Benefits:
- Validation is declarative and reusable
- Easy to test schemas independently
- Consistent validation across endpoints
- Clear error messages
- Unknown fields automatically stripped
Lesson Three: Structure for Growth, Not for Show
The Wrong Way (Premature Organization)
my-api/
├── controllers/
│ ├── userController.js
│ ├── postController.js
│ └── commentController.js
├── services/
│ ├── userService.js
│ ├── postService.js
│ └── commentService.js
├── repositories/
│ ├── userRepository.js
│ ├── postRepository.js
│ └── commentRepository.js
├── models/
├── validators/
├── middleware/
└── utils/
Problem: Three layers for a simple CRUD app. Over-engineered from day one.
The Right Way (Start Simple, Grow Organically)
Small API (MVP):
my-api/
├── routes/
│ ├── users.js
│ ├── posts.js
│ └── auth.js
├── models/
│ ├── User.js
│ └── Post.js
├── middleware/
│ ├── auth.js
│ └── errorHandler.js
└── app.js
Medium API (adding complexity as needed):
my-api/
├── features/
│ ├── users/
│ │ ├── routes.js
│ │ ├── service.js # Only when business logic gets complex
│ │ ├── model.js
│ │ └── validator.js
│ ├── posts/
│ │ ├── routes.js
│ │ ├── service.js
│ │ ├── model.js
│ │ └── validator.js
│ └── auth/
│ ├── routes.js
│ ├── service.js
│ └── middleware.js
├── shared/
│ ├── middleware/
│ ├── errors/
│ └── utils/
└── app.js
Flask version:
# Small API structure
my_api/
├── routes/
│ ├── __init__.py
│ ├── users.py
│ ├── posts.py
│ └── auth.py
├── models/
│ ├── __init__.py
│ ├── user.py
│ └── post.py
├── schemas/
│ ├── __init__.py
│ ├── user.py
│ └── post.py
└── app.py
# As it grows
my_api/
├── features/
│ ├── users/
│ │ ├── __init__.py
│ │ ├── routes.py
│ │ ├── service.py # Extract when business logic grows
│ │ ├── model.py
│ │ └── schema.py
│ ├── posts/
│ │ ├── __init__.py
│ │ ├── routes.py
│ │ ├── service.py
│ │ ├── model.py
│ │ └── schema.py
│ └── auth/
│ ├── __init__.py
│ ├── routes.py
│ ├── service.py
│ └── decorators.py
├── shared/
│ ├── middleware.py
│ ├── exceptions.py
│ └── utils.py
└── app.pyReal example - User feature evolution:
// Version 1: Simple route (good for MVP)
// routes/users.js
router.post("/", validate(userSchema), async (req, res, next) => {
try {
const user = await User.create(req.body);
res.status(201).json(user);
} catch (error) {
next(error);
}
});
// Version 2: Extract service when logic grows
// features/users/service.js
class UserService {
async createUser(userData) {
// Now includes email verification, welcome email, analytics
const user = await User.create(userData);
await this.sendWelcomeEmail(user.email);
await this.trackSignup(user.id);
return user;
}
async sendWelcomeEmail(email) {
// Email logic
}
async trackSignup(userId) {
// Analytics logic
}
}
// features/users/routes.js
const userService = new UserService();
router.post("/", validate(userSchema), async (req, res, next) => {
try {
const user = await userService.createUser(req.body);
res.status(201).json(user);
} catch (error) {
next(error);
}
});Lesson Four: Authentication Done Right
The Pattern I Use Everywhere
Flask with JWT:
# auth/service.py
import jwt
from datetime import datetime, timedelta
from werkzeug.security import generate_password_hash, check_password_hash
class AuthService:
def __init__(self, secret_key, algorithm='HS256'):
self.secret_key = secret_key
self.algorithm = algorithm
def hash_password(self, password):
return generate_password_hash(password)
def verify_password(self, password_hash, password):
return check_password_hash(password_hash, password)
def generate_token(self, user_id, expires_in=3600):
payload = {
'user_id': user_id,
'exp': datetime.utcnow() + timedelta(seconds=expires_in),
'iat': datetime.utcnow()
}
return jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
def verify_token(self, token):
try:
payload = jwt.decode(
token,
self.secret_key,
algorithms=[self.algorithm]
)
return payload['user_id']
except jwt.ExpiredSignatureError:
raise UnauthorizedError('Token has expired')
except jwt.InvalidTokenError:
raise UnauthorizedError('Invalid token')
# auth/decorators.py
from functools import wraps
from flask import request, g
def require_auth(f):
@wraps(f)
def decorated_function(*args, **kwargs):
auth_header = request.headers.get('Authorization')
if not auth_header:
raise UnauthorizedError('No authorization header')
try:
token = auth_header.split(' ')[1] # Bearer <token>
user_id = auth_service.verify_token(token)
# Load user and attach to request context
g.current_user = User.query.get(user_id)
if not g.current_user:
raise UnauthorizedError('User not found')
return f(*args, **kwargs)
except IndexError:
raise UnauthorizedError('Invalid authorization header')
return decorated_function
def require_role(*roles):
def decorator(f):
@wraps(f)
@require_auth
def decorated_function(*args, **kwargs):
if g.current_user.role not in roles:
raise UnauthorizedError('Insufficient permissions')
return f(*args, **kwargs)
return decorated_function
return decorator
# routes/auth.py
@app.route('/auth/login', methods=['POST'])
@validate_schema(LoginSchema)
def login():
email = request.validated_data['email']
password = request.validated_data['password']
user = User.query.filter_by(email=email).first()
if not user or not auth_service.verify_password(user.password, password):
raise UnauthorizedError('Invalid credentials')
token = auth_service.generate_token(user.id)
return jsonify({
'token': token,
'user': user.to_dict()
})
# Protected route example
@app.route('/users/me')
@require_auth
def get_current_user():
return jsonify(g.current_user.to_dict())
@app.route('/admin/users')
@require_role('admin')
def list_all_users():
users = User.query.all()
return jsonify([u.to_dict() for u in users])Node.js with JWT:
// auth/authService.js
const jwt = require("jsonwebtoken");
const bcrypt = require("bcryptjs");
const { UnauthorizedError } = require("../errors/ApiError");
class AuthService {
constructor(secretKey, algorithm = "HS256") {
this.secretKey = secretKey;
this.algorithm = algorithm;
}
async hashPassword(password) {
return bcrypt.hash(password, 10);
}
async verifyPassword(passwordHash, password) {
return bcrypt.compare(password, passwordHash);
}
generateToken(userId, expiresIn = "1h") {
return jwt.sign({ userId }, this.secretKey, {
expiresIn,
algorithm: this.algorithm,
});
}
verifyToken(token) {
try {
const payload = jwt.verify(token, this.secretKey);
return payload.userId;
} catch (error) {
if (error.name === "TokenExpiredError") {
throw new UnauthorizedError("Token has expired");
}
throw new UnauthorizedError("Invalid token");
}
}
}
module.exports = new AuthService(process.env.JWT_SECRET);
// middleware/auth.js
const authService = require("../auth/authService");
const User = require("../models/User");
const { UnauthorizedError } = require("../errors/ApiError");
const requireAuth = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader) {
throw new UnauthorizedError("No authorization header");
}
const token = authHeader.split(" ")[1]; // Bearer <token>
const userId = authService.verifyToken(token);
const user = await User.findById(userId);
if (!user) {
throw new UnauthorizedError("User not found");
}
req.user = user;
next();
} catch (error) {
next(error);
}
};
const requireRole = (...roles) => {
return async (req, res, next) => {
try {
if (!req.user) {
throw new UnauthorizedError("Authentication required");
}
if (!roles.includes(req.user.role)) {
throw new UnauthorizedError("Insufficient permissions");
}
next();
} catch (error) {
next(error);
}
};
};
module.exports = { requireAuth, requireRole };
// routes/auth.js
const authService = require("../auth/authService");
const validate = require("../middleware/validate");
const { loginSchema } = require("../validators/authValidator");
router.post("/login", validate(loginSchema), async (req, res, next) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) {
throw new UnauthorizedError("Invalid credentials");
}
const isValid = await authService.verifyPassword(user.password, password);
if (!isValid) {
throw new UnauthorizedError("Invalid credentials");
}
const token = authService.generateToken(user._id);
res.json({
token,
user: user.toJSON(),
});
} catch (error) {
next(error);
}
});
// Protected routes
const { requireAuth, requireRole } = require("../middleware/auth");
router.get("/me", requireAuth, (req, res) => {
res.json(req.user);
});
router.get(
"/admin/users",
requireAuth,
requireRole("admin"),
async (req, res, next) => {
try {
const users = await User.find();
res.json(users);
} catch (error) {
next(error);
}
}
);Lesson Five: Testing That Actually Helps
The Tests I Actually Write
Flask testing setup:
# tests/conftest.py
import pytest
from app import create_app, db
@pytest.fixture
def app():
app = create_app('testing')
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def auth_headers(client):
# Create a test user and login
response = client.post('/auth/register', json={
'email': 'test@example.com',
'password': 'password123',
'name': 'Test User'
})
token = response.json['token']
return {'Authorization': f'Bearer {token}'}
# tests/test_users.py
def test_create_user(client):
response = client.post('/users', json={
'email': 'new@example.com',
'password': 'password123',
'name': 'New User'
})
assert response.status_code == 201
assert response.json['email'] == 'new@example.com'
assert 'password' not in response.json
def test_create_user_duplicate_email(client):
# Create first user
client.post('/users', json={
'email': 'duplicate@example.com',
'password': 'password123',
'name': 'First User'
})
# Try to create duplicate
response = client.post('/users', json={
'email': 'duplicate@example.com',
'password': 'password456',
'name': 'Second User'
})
assert response.status_code == 400
assert 'already exists' in response.json['message'].lower()
def test_get_user_unauthorized(client):
response = client.get('/users/1')
assert response.status_code == 401
def test_get_user_authorized(client, auth_headers):
response = client.get('/users/1', headers=auth_headers)
assert response.status_code == 200Node.js testing setup:
// tests/setup.js
const mongoose = require("mongoose");
const { MongoMemoryServer } = require("mongodb-memory-server");
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
afterEach(async () => {
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany();
}
});
// tests/helpers.js
const request = require("supertest");
const app = require("../app");
async function createUser(userData = {}) {
const defaultData = {
email: "test@example.com",
password: "password123",
name: "Test User",
};
const response = await request(app)
.post("/api/users")
.send({ ...defaultData, ...userData });
return response.body;
}
async function loginUser(email = "test@example.com", password = "password123") {
const response = await request(app)
.post("/api/auth/login")
.send({ email, password });
return response.body.token;
}
module.exports = { createUser, loginUser };
// tests/users.test.js
const request = require("supertest");
const app = require("../app");
const { createUser, loginUser } = require("./helpers");
describe("Users API", () => {
describe("POST /api/users", () => {
it("should create a new user", async () => {
const response = await request(app).post("/api/users").send({
email: "new@example.com",
password: "password123",
name: "New User",
});
expect(response.status).toBe(201);
expect(response.body.email).toBe("new@example.com");
expect(response.body.password).toBeUndefined();
});
it("should reject duplicate email", async () => {
await createUser({ email: "duplicate@example.com" });
const response = await request(app).post("/api/users").send({
email: "duplicate@example.com",
password: "password456",
name: "Second User",
});
expect(response.status).toBe(400);
expect(response.body.message).toMatch(/already exists/i);
});
it("should validate required fields", async () => {
const response = await request(app)
.post("/api/users")
.send({ email: "test@example.com" }); // Missing password and name
expect(response.status).toBe(400);
});
});
describe("GET /api/users/:id", () => {
it("should return 401 without auth", async () => {
const response = await request(app).get("/api/users/123");
expect(response.status).toBe(401);
});
it("should return user with auth", async () => {
const user = await createUser();
const token = await loginUser();
const response = await request(app)
.get(`/api/users/${user._id}`)
.set("Authorization", `Bearer ${token}`);
expect(response.status).toBe(200);
expect(response.body.email).toBe(user.email);
});
});
});Lesson Six: Logging and Monitoring
What I Log
Flask with structured logging:
# logging_config.py
import logging
import json
from datetime import datetime
class JSONFormatter(logging.Formatter):
def format(self, record):
log_data = {
'timestamp': datetime.utcnow().isoformat(),
'level': record.levelname,
'message': record.getMessage(),
'module': record.module,
'function': record.funcName,
}
# Add exception info if present
if record.exc_info:
log_data['exception'] = self.formatException(record.exc_info)
# Add extra fields
if hasattr(record, 'user_id'):
log_data['user_id'] = record.user_id
if hasattr(record, 'request_id'):
log_data['request_id'] = record.request_id
return json.dumps(log_data)
def setup_logging(app):
handler = logging.StreamHandler()
handler.setFormatter(JSONFormatter())
app.logger.addHandler(handler)
app.logger.setLevel(logging.INFO)
# middleware/logging.py
import uuid
from flask import g, request
import time
@app.before_request
def before_request():
g.request_id = str(uuid.uuid4())
g.start_time = time.time()
@app.after_request
def after_request(response):
duration = time.time() - g.start_time
app.logger.info('Request completed', extra={
'request_id': g.request_id,
'method': request.method,
'path': request.path,
'status_code': response.status_code,
'duration_ms': round(duration * 1000, 2),
'user_id': getattr(g, 'current_user', {}).get('id'),
})
return response
# Usage in routes
@app.route('/users/<user_id>')
@require_auth
def get_user(user_id):
app.logger.info('Fetching user', extra={
'request_id': g.request_id,
'user_id': user_id,
'requester_id': g.current_user.id,
})
user = User.query.get(user_id)
if not user:
app.logger.warning('User not found', extra={
'request_id': g.request_id,
'user_id': user_id,
})
raise NotFoundError('User not found')
return jsonify(user.to_dict())Node.js with Winston:
// config/logger.js
const winston = require("winston");
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || "info",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: "my-api" },
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
}),
],
});
module.exports = logger;
// middleware/logging.js
const { v4: uuidv4 } = require("uuid");
const logger = require("../config/logger");
const requestLogger = (req, res, next) => {
req.id = uuidv4();
req.startTime = Date.now();
// Log incoming request
logger.info("Incoming request", {
requestId: req.id,
method: req.method,
path: req.path,
ip: req.ip,
});
// Log response
res.on("finish", () => {
const duration = Date.now() - req.startTime;
logger.info("Request completed", {
requestId: req.id,
method: req.method,
path: req.path,
statusCode: res.statusCode,
durationMs: duration,
userId: req.user?.id,
});
});
next();
};
module.exports = requestLogger;
// Usage in routes
router.get("/:id", requireAuth, async (req, res, next) => {
try {
logger.info("Fetching user", {
requestId: req.id,
userId: req.params.id,
requesterId: req.user.id,
});
const user = await User.findById(req.params.id);
if (!user) {
logger.warn("User not found", {
requestId: req.id,
userId: req.params.id,
});
throw new NotFoundError("User not found");
}
res.json(user);
} catch (error) {
logger.error("Error fetching user", {
requestId: req.id,
userId: req.params.id,
error: error.message,
stack: error.stack,
});
next(error);
}
});Lesson Seven: Environment Configuration
Configuration I Can Trust
Flask configuration:
# config.py
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
"""Base configuration"""
SECRET_KEY = os.getenv('SECRET_KEY')
SQLALCHEMY_TRACK_MODIFICATIONS = False
# Validate required config
@classmethod
def validate(cls):
required = ['SECRET_KEY', 'DATABASE_URL']
missing = [key for key in required if not getattr(cls, key, None)]
if missing:
raise ValueError(f"Missing required config: {', '.join(missing)}")
class DevelopmentConfig(Config):
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.getenv(
'DATABASE_URL',
'postgresql://localhost/myapi_dev'
)
SQLALCHEMY_ECHO = True
class ProductionConfig(Config):
DEBUG = False
SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL')
# Production-specific settings
SQLALCHEMY_POOL_SIZE = 10
SQLALCHEMY_MAX_OVERFLOW = 20
class TestingConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig,
}
# app.py
def create_app(config_name=None):
if config_name is None:
config_name = os.getenv('FLASK_ENV', 'development')
app = Flask(__name__)
app.config.from_object(config[config_name])
# Validate configuration
config[config_name].validate()
return appNode.js configuration:
// config/index.js
require("dotenv").config();
const requiredEnvVars = ["NODE_ENV", "PORT", "DATABASE_URL", "JWT_SECRET"];
// Validate required environment variables
const missingEnvVars = requiredEnvVars.filter((key) => !process.env[key]);
if (missingEnvVars.length > 0) {
throw new Error(
`Missing required environment variables: ${missingEnvVars.join(", ")}`
);
}
const config = {
env: process.env.NODE_ENV || "development",
port: parseInt(process.env.PORT, 10) || 3000,
database: {
url: process.env.DATABASE_URL,
options: {
useNewUrlParser: true,
useUnifiedTopology: true,
poolSize: process.env.NODE_ENV === "production" ? 10 : 5,
},
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN || "1h",
},
// Feature flags
features: {
emailVerification: process.env.ENABLE_EMAIL_VERIFICATION === "true",
rateLimit: process.env.ENABLE_RATE_LIMIT === "true",
},
};
module.exports = config;
// Usage
const config = require("./config");
if (config.features.rateLimit) {
app.use(rateLimiter);
}The Checklist I Use for Every API
Before deploying any API, I check:
Security:
- Input validation on all endpoints
- Authentication required where needed
- Authorization checks in place
- Secrets in environment variables
- HTTPS enforced in production
- Rate limiting configured
Error Handling:
- Consistent error format
- Proper HTTP status codes
- Safe error messages (no stack traces to clients)
- Logging for all errors
Testing:
- Happy path tests
- Error case tests
- Authentication tests
- Integration tests for critical flows
Documentation:
- README with setup instructions
- Environment variables documented
- API endpoints documented
- Example requests/responses
Monitoring:
- Structured logging
- Request/response logging
- Error tracking
- Performance monitoring
Conclusion
Building maintainable APIs isn't about using the fanciest tools or following every pattern. It's about:
Consistency: Same patterns throughout the codebase
Clarity: Code that's easy to understand six months later
Safety: Validation and error handling that prevents bugs
Observability: Logging that helps you debug production issues
Simplicity: Start simple, add complexity only when needed
The APIs I'm proud of aren't the ones with the most sophisticated architecture. They're the ones I can modify confidently without breaking things.
Start with these patterns, and you'll build APIs you actually enjoy maintaining.
What patterns have saved you when maintaining APIs? Share your lessons in the comments.