Command Palette

Search for a command to run...

0
Blog
PreviousNext

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()), 201

Node.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.py

Real 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 == 200

Node.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 app

Node.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.