API Design & Development - 1/3

From Express Routes to Professional API Design

You’ve mastered Express.js, built middleware systems, and handled errors gracefully. You can create endpoints that receive requests and send responses. But here’s the uncomfortable truth: most developers build APIs like they’re throwing spaghetti at a wall and seeing what sticks.

The amateur API approach:

app.get("/getUserData", (req, res) => {
  /* ... */
});
app.post("/addNewUser", (req, res) => {
  /* ... */
});
app.get("/deleteUser", (req, res) => {
  /* ... */
}); // Using GET for deletion!
app.post("/users/123/update", (req, res) => {
  /* ... */
}); // Inconsistent patterns
app.get("/api/v1/getAllUsersFromDatabase", (req, res) => {
  /* ... */
}); // Verbose nightmare

This approach creates APIs that are:

  • Inconsistent: No predictable patterns for clients to follow
  • Confusing: Mixing HTTP methods arbitrarily
  • Unmaintainable: Adding new features breaks existing patterns
  • Unprofessional: Real APIs follow established conventions

The professional reality: APIs aren’t just endpoints that work—they’re contracts between systems that need to be intuitive, consistent, and scalable. They’re interfaces that other developers (including future you) will need to understand and integrate with.

What separates professional APIs from amateur code:

  • Consistent resource modeling that makes endpoints predictable
  • Proper HTTP method usage that follows web standards
  • Meaningful status codes that communicate exactly what happened
  • Versioning strategies that allow evolution without breaking clients
  • URL design patterns that scale from simple resources to complex relationships

We’re diving into REST API design because it’s the foundational pattern that powers most of the web. Master these principles, and you’ll build APIs that feel intuitive to consume and are a pleasure to maintain.


REST API Principles: The Foundation of Web APIs

Understanding REST

REST (Representational State Transfer) isn’t a technology—it’s an architectural style that defines constraints for building web services. When people say “REST API,” they’re describing an API that follows these principles.

The REST constraints (and why they matter):

1. Client-Server Separation

Principle: Clear separation between client and server responsibilities.

// Bad: Server trying to manage client state
app.get("/users", (req, res) => {
  const users = getUsersFromDatabase();
  const html = `<ul>${users.map((u) => `<li>${u.name}</li>`).join("")}</ul>`;
  res.send(html); // Server decides how to display data
});

// Good: Server provides data, client decides presentation
app.get("/users", (req, res) => {
  const users = getUsersFromDatabase();
  res.json({ users }); // Client handles how to display this
});

Why it matters: Allows front-end and back-end to evolve independently.

2. Stateless

Principle: Each request contains all information needed to process it.

// Bad: Server storing client session state
let userSessions = {}; // Server maintains state
app.get("/dashboard", (req, res) => {
  const sessionId = req.cookies.sessionId;
  const user = userSessions[sessionId]; // Depends on server state
  if (user) {
    res.json({ dashboard: user.dashboardData });
  } else {
    res.status(401).json({ error: "Not authenticated" });
  }
});

// Good: Stateless with tokens
app.get("/dashboard", (req, res) => {
  const token = req.headers.authorization;
  try {
    const user = verifyJWTToken(token); // All info in the token
    const dashboardData = getDashboardData(user.id);
    res.json({ dashboard: dashboardData });
  } catch (error) {
    res.status(401).json({ error: "Invalid token" });
  }
});

Why it matters: Enables horizontal scaling and simplifies server architecture.

3. Uniform Interface

Principle: Consistent interface across all resources.

This breaks down into several sub-constraints that we’ll explore in detail.

4. Resource-Based

Principle: URLs represent resources (nouns), not actions (verbs).

// Bad: Action-based URLs
app.get("/getUsers", handler);
app.post("/createUser", handler);
app.post("/updateUser", handler);
app.get("/deleteUser", handler);

// Good: Resource-based URLs
app.get("/users", handler); // Get collection
app.post("/users", handler); // Create resource
app.put("/users/:id", handler); // Update resource
app.delete("/users/:id", handler); // Delete resource

Why it matters: Creates predictable, intuitive API patterns.

5. Representation Through Media Types

Principle: Resources can have multiple representations.

app.get("/users/:id", (req, res) => {
  const user = getUserById(req.params.id);

  // Content negotiation based on Accept header
  res.format({
    "application/json": () => res.json({ user }),
    "application/xml": () => res.send(convertToXML(user)),
    "text/plain": () => res.send(`${user.name} (${user.email})`),
    default: () => res.status(406).send("Not Acceptable"),
  });
});

6. HATEOAS (Hypermedia as the Engine of Application State)

Principle: Responses include links to related actions.

app.get("/users/:id", (req, res) => {
  const user = getUserById(req.params.id);

  res.json({
    user,
    _links: {
      self: `/users/${user.id}`,
      edit: `/users/${user.id}`,
      delete: `/users/${user.id}`,
      posts: `/users/${user.id}/posts`,
      profile: `/users/${user.id}/profile`,
    },
  });
});

Reality check: Most APIs don’t fully implement HATEOAS because it’s complex and not always necessary. Focus on the other principles first.


Resource-Based URL Design: Thinking in Resources

The Resource Mindset

Stop thinking in terms of functions. Start thinking in terms of resources.

Traditional programming thinks: “What actions can users perform?” REST thinking: “What resources exist and how can they be manipulated?”

Resource identification:

// Traditional function-based thinking
app.post("/loginUser", handler);
app.get("/getUserPosts", handler);
app.post("/addComment", handler);
app.get("/searchProducts", handler);

// Resource-based thinking
app.post("/sessions", handler); // Create a session (login)
app.get("/users/:id/posts", handler); // Get posts belonging to user
app.post("/posts/:id/comments", handler); // Add comment to post
app.get("/products", handler); // Search via query params

URL Structure Patterns

1. Collection and Item Resources

Collections represent groups of resources:

app.get("/users", getAllUsers); // Get all users
app.post("/users", createUser); // Create new user
app.get("/posts", getAllPosts); // Get all posts
app.post("/posts", createPost); // Create new post

Items represent individual resources:

app.get("/users/:id", getUser); // Get specific user
app.put("/users/:id", updateUser); // Update specific user
app.delete("/users/:id", deleteUser); // Delete specific user

2. Nested Resources

When resources have clear parent-child relationships:

// Posts belonging to a user
app.get("/users/:userId/posts", getUserPosts);
app.post("/users/:userId/posts", createPostForUser);
app.get("/users/:userId/posts/:postId", getUserPost);

// Comments belonging to a post
app.get("/posts/:postId/comments", getPostComments);
app.post("/posts/:postId/comments", createCommentOnPost);
app.delete("/posts/:postId/comments/:commentId", deleteComment);

// Orders for a customer
app.get("/customers/:customerId/orders", getCustomerOrders);
app.post("/customers/:customerId/orders", createOrder);

Nesting guidelines:

  • Avoid deep nesting: /users/:id/posts/:id/comments/:id/likes is too deep
  • Limit to 2-3 levels: Most relationships can be flattened
  • Use independent resources for complex relationships:
// Instead of deep nesting
app.get("/posts/:postId/comments/:commentId/replies/:replyId", handler);

// Use independent resources
app.get("/comments/:commentId", handler); // Comments are resources
app.get("/replies/:replyId", handler); // Replies are resources

3. Query Parameters for Filtering and Options

Use query parameters for optional behaviors:

// Filtering
app.get("/users", (req, res) => {
  const { role, status, department } = req.query;
  const users = filterUsers({ role, status, department });
  res.json({ users });
});
// Usage: GET /users?role=admin&status=active

// Pagination
app.get("/posts", (req, res) => {
  const { page = 1, limit = 10, sort = "created_at" } = req.query;
  const posts = getPaginatedPosts({ page, limit, sort });
  res.json({
    posts,
    pagination: {
      page: parseInt(page),
      limit: parseInt(limit),
      total: getTotalPostCount(),
    },
  });
});
// Usage: GET /posts?page=2&limit=20&sort=title

// Search
app.get("/products", (req, res) => {
  const { q, category, minPrice, maxPrice } = req.query;
  const products = searchProducts({ q, category, minPrice, maxPrice });
  res.json({ products, query: req.query });
});
// Usage: GET /products?q=laptop&category=electronics&minPrice=500

URL Design Anti-Patterns

Avoid these common mistakes:

// ❌ Verbs in URLs
app.get("/getUsers", handler);
app.post("/createUser", handler);
app.post("/deleteUser", handler);

// ✅ Resource-based
app.get("/users", handler);
app.post("/users", handler);
app.delete("/users/:id", handler);

// ❌ Mixed conventions
app.get("/users", handler);
app.get("/userProfiles", handler); // Inconsistent naming
app.get("/user-settings", handler); // Mixed separators

// ✅ Consistent conventions
app.get("/users", handler);
app.get("/users/:id/profile", handler);
app.get("/users/:id/settings", handler);

// ❌ File extensions in URLs
app.get("/users.json", handler);
app.get("/users.xml", handler);

// ✅ Content negotiation via headers
app.get("/users", (req, res) => {
  // Use Accept header for format
  res.format({
    "application/json": () => res.json(users),
    "application/xml": () => res.send(convertToXML(users)),
  });
});

// ❌ CRUD operations in URL
app.post("/users/create", handler);
app.post("/users/update", handler);
app.post("/users/delete", handler);

// ✅ HTTP methods for CRUD
app.post("/users", handler); // Create
app.put("/users/:id", handler); // Update
app.delete("/users/:id", handler); // Delete

HTTP Methods: Choosing the Right Tool for the Job

Understanding HTTP Method Semantics

Each HTTP method has specific semantics and expectations:

GET: Safe and Idempotent

Purpose: Retrieve data without side effects.

// ✅ Proper GET usage
app.get("/users/:id", async (req, res) => {
  const user = await getUserById(req.params.id);
  if (!user) {
    return res.status(404).json({ error: "User not found" });
  }
  res.json({ user });
});

// ❌ GET with side effects (never do this)
app.get("/users/:id/activate", async (req, res) => {
  await activateUser(req.params.id); // Side effect!
  res.json({ message: "User activated" });
});

GET characteristics:

  • Safe: No side effects on server state
  • Idempotent: Multiple identical requests have same effect
  • Cacheable: Responses can be cached by browsers/proxies
  • No request body: Data passed via URL parameters

POST: Create Resources

Purpose: Create new resources or perform operations with side effects.

// ✅ Creating resources
app.post("/users", async (req, res) => {
  const userData = req.body;
  const newUser = await createUser(userData);
  res.status(201).json({
    message: "User created successfully",
    user: newUser,
  });
});

// ✅ Operations with side effects
app.post("/users/:id/send-welcome-email", async (req, res) => {
  await sendWelcomeEmail(req.params.id);
  res.json({ message: "Welcome email sent" });
});

// ✅ Complex operations that don't fit other methods
app.post("/orders/:id/process-payment", async (req, res) => {
  const { paymentDetails } = req.body;
  const result = await processPayment(req.params.id, paymentDetails);
  res.json({ paymentResult: result });
});

POST characteristics:

  • Not safe: Has side effects
  • Not idempotent: Multiple requests create multiple effects
  • Not cacheable: Responses shouldn’t be cached
  • Has request body: Data sent in request body

PUT: Update/Replace Resources

Purpose: Update entire resource or create if it doesn’t exist.

// ✅ Replace entire resource
app.put("/users/:id", async (req, res) => {
  const userId = req.params.id;
  const userData = req.body;

  // PUT should replace the entire resource
  const updatedUser = await replaceUser(userId, userData);

  if (updatedUser.created) {
    res.status(201).json({ user: updatedUser.data });
  } else {
    res.json({ user: updatedUser.data });
  }
});

// ❌ Partial updates with PUT
app.put("/users/:id", async (req, res) => {
  const updates = req.body; // Only has { email: 'new@example.com' }
  const user = await partiallyUpdateUser(req.params.id, updates);
  res.json({ user }); // Other fields remain unchanged - this should be PATCH
});

PUT characteristics:

  • Not safe: Modifies server state
  • Idempotent: Multiple identical requests have same effect
  • Complete replacement: Should replace entire resource
  • Create or update: Can create resource if it doesn’t exist

PATCH: Partial Updates

Purpose: Apply partial modifications to a resource.

// ✅ Partial updates
app.patch("/users/:id", async (req, res) => {
  const userId = req.params.id;
  const updates = req.body; // Only changed fields

  const updatedUser = await partialUpdateUser(userId, updates);
  res.json({ user: updatedUser });
});

// ✅ Specific field updates
app.patch("/users/:id/email", async (req, res) => {
  const { email } = req.body;
  const updatedUser = await updateUserEmail(req.params.id, email);
  res.json({ user: updatedUser });
});

// ✅ Status changes
app.patch("/orders/:id/status", async (req, res) => {
  const { status } = req.body;
  const order = await updateOrderStatus(req.params.id, status);
  res.json({ order });
});

PATCH characteristics:

  • Not safe: Modifies server state
  • Not necessarily idempotent: Depends on the operation
  • Partial modification: Only changes specified fields
  • Flexible format: Can use various patch formats (JSON Patch, etc.)

DELETE: Remove Resources

Purpose: Delete resources.

// ✅ Delete resource
app.delete("/users/:id", async (req, res) => {
  const deleted = await deleteUser(req.params.id);
  if (deleted) {
    res.status(204).end(); // 204 No Content for successful deletion
  } else {
    res.status(404).json({ error: "User not found" });
  }
});

// ✅ Delete with confirmation
app.delete("/users/:id", async (req, res) => {
  const { confirm } = req.body;
  if (confirm !== "DELETE") {
    return res.status(400).json({
      error: "Confirmation required",
      message: 'Send { "confirm": "DELETE" } to confirm deletion',
    });
  }

  await deleteUser(req.params.id);
  res.status(204).end();
});

DELETE characteristics:

  • Not safe: Removes server state
  • Idempotent: Multiple deletions have same effect (resource is gone)
  • No response body typically: Often returns 204 No Content

Method Selection Guidelines

Choosing the right method:

// Resource operations
app.get("/users/:id", getUser); // Read
app.post("/users", createUser); // Create
app.put("/users/:id", replaceUser); // Replace entirely
app.patch("/users/:id", updateUser); // Update partially
app.delete("/users/:id", deleteUser); // Delete

// Collection operations
app.get("/users", getUsers); // List/search
app.post("/users", createUser); // Add to collection
app.delete("/users", deleteAllUsers); // Clear collection (dangerous!)

// Action operations (when CRUD doesn't fit)
app.post("/users/:id/reset-password", resetPassword);
app.post("/orders/:id/cancel", cancelOrder);
app.post("/products/:id/restock", restockProduct);

HTTP Status Codes: Communicating What Happened

Status Code Categories

Status codes are grouped by purpose:

2xx Success

The request was successful.

app.get("/users/:id", async (req, res) => {
  const user = await getUserById(req.params.id);
  res.status(200).json({ user }); // 200 OK - standard success
});

app.post("/users", async (req, res) => {
  const newUser = await createUser(req.body);
  res.status(201).json({ user: newUser }); // 201 Created - resource created
});

app.delete("/users/:id", async (req, res) => {
  await deleteUser(req.params.id);
  res.status(204).end(); // 204 No Content - successful, no response body
});

app.put("/users/:id", async (req, res) => {
  const result = await updateUser(req.params.id, req.body);
  if (result.created) {
    res.status(201).json({ user: result.user }); // Created new resource
  } else {
    res.status(200).json({ user: result.user }); // Updated existing
  }
});

3xx Redirection

Further action needed to complete the request.

app.get("/users/me", (req, res) => {
  if (!req.user) {
    return res.status(401).json({ error: "Not authenticated" });
  }

  // Redirect to specific user endpoint
  res.status(302).redirect(`/users/${req.user.id}`);
});

app.get("/old-api/users", (req, res) => {
  // Permanent redirect to new API version
  res.status(301).redirect("/api/v2/users");
});

app.get("/profile", (req, res) => {
  // Resource hasn't been modified
  const ifModifiedSince = req.headers["if-modified-since"];
  const userModified = getLastModified(req.user.id);

  if (ifModifiedSince && new Date(ifModifiedSince) >= userModified) {
    return res.status(304).end(); // 304 Not Modified
  }

  res.json({ user: req.user });
});

4xx Client Errors

The client made an error.

app.post("/users", async (req, res) => {
  const { name, email } = req.body;

  // 400 Bad Request - malformed request
  if (!name || !email) {
    return res.status(400).json({
      error: "Validation failed",
      details: {
        name: !name ? "Name is required" : null,
        email: !email ? "Email is required" : null,
      },
    });
  }

  // 409 Conflict - resource already exists
  const existingUser = await getUserByEmail(email);
  if (existingUser) {
    return res.status(409).json({
      error: "User already exists",
      message: "A user with this email already exists",
    });
  }

  const newUser = await createUser({ name, email });
  res.status(201).json({ user: newUser });
});

app.get("/users/:id", async (req, res) => {
  const user = await getUserById(req.params.id);

  // 404 Not Found - resource doesn't exist
  if (!user) {
    return res.status(404).json({
      error: "User not found",
      message: `User with ID ${req.params.id} does not exist`,
    });
  }

  res.json({ user });
});

app.get("/admin/users", requireAuth, requireAdmin, async (req, res) => {
  // Middleware handles 401 Unauthorized (not authenticated)
  // and 403 Forbidden (authenticated but insufficient permissions)
  const users = await getAllUsers();
  res.json({ users });
});

const requireAuth = (req, res, next) => {
  if (!req.headers.authorization) {
    return res.status(401).json({ error: "Authentication required" });
  }
  // Verify token and set req.user
  next();
};

const requireAdmin = (req, res, next) => {
  if (req.user.role !== "admin") {
    return res.status(403).json({ error: "Admin access required" });
  }
  next();
};

5xx Server Errors

The server encountered an error.

app.get("/users/:id", async (req, res) => {
  try {
    const user = await getUserById(req.params.id);
    if (!user) {
      return res.status(404).json({ error: "User not found" });
    }
    res.json({ user });
  } catch (error) {
    console.error("Database error:", error);
    res.status(500).json({
      error: "Internal server error",
      message: "Unable to retrieve user data",
    });
  }
});

app.post("/users", async (req, res) => {
  try {
    const newUser = await createUser(req.body);
    res.status(201).json({ user: newUser });
  } catch (error) {
    if (error.code === "SERVICE_UNAVAILABLE") {
      return res.status(503).json({
        error: "Service temporarily unavailable",
        message: "Please try again later",
      });
    }

    res.status(500).json({
      error: "Internal server error",
      message: "Unable to create user",
    });
  }
});

Common Status Codes Quick Reference

Essential status codes every API should use:

const STATUS_CODES = {
  // Success
  OK: 200, // Standard success
  CREATED: 201, // Resource created
  NO_CONTENT: 204, // Success, no response body

  // Redirection
  MOVED_PERMANENTLY: 301, // Resource permanently moved
  FOUND: 302, // Temporary redirect
  NOT_MODIFIED: 304, // Resource hasn't changed

  // Client Errors
  BAD_REQUEST: 400, // Malformed request
  UNAUTHORIZED: 401, // Authentication required
  FORBIDDEN: 403, // Access denied
  NOT_FOUND: 404, // Resource doesn't exist
  METHOD_NOT_ALLOWED: 405, // HTTP method not supported
  CONFLICT: 409, // Resource conflict
  UNPROCESSABLE_ENTITY: 422, // Validation failed
  TOO_MANY_REQUESTS: 429, // Rate limit exceeded

  // Server Errors
  INTERNAL_SERVER_ERROR: 500, // Generic server error
  NOT_IMPLEMENTED: 501, // Feature not implemented
  BAD_GATEWAY: 502, // Upstream server error
  SERVICE_UNAVAILABLE: 503, // Service temporarily down
  GATEWAY_TIMEOUT: 504, // Upstream server timeout
};

// Usage in routes
app.post("/users", async (req, res) => {
  try {
    if (!req.body.email) {
      return res.status(STATUS_CODES.BAD_REQUEST).json({
        error: "Email is required",
      });
    }

    const newUser = await createUser(req.body);
    res.status(STATUS_CODES.CREATED).json({ user: newUser });
  } catch (error) {
    res.status(STATUS_CODES.INTERNAL_SERVER_ERROR).json({
      error: "Unable to create user",
    });
  }
});

API Versioning: Evolution Without Breaking Changes

Why Versioning Matters

APIs evolve. Clients depend on them. Versioning prevents disasters.

The versioning problem:

// Version 1 - Initial API
app.get("/users/:id", (req, res) => {
  res.json({
    id: user.id,
    name: user.name,
    email: user.email,
  });
});

// Later: You need to add more user data
app.get("/users/:id", (req, res) => {
  res.json({
    id: user.id,
    fullName: user.name, // Changed: name -> fullName
    email: user.email,
    profile: {
      // Added: nested profile object
      avatar: user.avatar,
      bio: user.bio,
    },
    preferences: user.preferences, // Added: new field
  });
});
// This breaks existing clients expecting the old format!

URL-Based Versioning

Most common and straightforward approach:

// Version 1
app.get("/api/v1/users/:id", (req, res) => {
  res.json({
    id: user.id,
    name: user.name,
    email: user.email,
  });
});

// Version 2 - New format
app.get("/api/v2/users/:id", (req, res) => {
  res.json({
    id: user.id,
    fullName: user.name,
    email: user.email,
    profile: {
      avatar: user.avatar,
      bio: user.bio,
    },
    preferences: user.preferences,
  });
});

// Both versions coexist - no breaking changes

Organized versioning with Express Router:

// routes/v1/users.js
const express = require("express");
const router = express.Router();

router.get("/:id", (req, res) => {
  // V1 user format
  res.json({
    id: user.id,
    name: user.name,
    email: user.email,
  });
});

module.exports = router;

// routes/v2/users.js
const express = require("express");
const router = express.Router();

router.get("/:id", (req, res) => {
  // V2 user format
  res.json({
    id: user.id,
    fullName: user.name,
    email: user.email,
    profile: {
      avatar: user.avatar,
      bio: user.bio,
    },
  });
});

module.exports = router;

// server.js
app.use("/api/v1/users", require("./routes/v1/users"));
app.use("/api/v2/users", require("./routes/v2/users"));

Header-Based Versioning

Versioning through HTTP headers:

app.get("/api/users/:id", (req, res) => {
  const version = req.headers["api-version"] || "v1";

  switch (version) {
    case "v1":
      res.json({
        id: user.id,
        name: user.name,
        email: user.email,
      });
      break;

    case "v2":
      res.json({
        id: user.id,
        fullName: user.name,
        email: user.email,
        profile: {
          avatar: user.avatar,
          bio: user.bio,
        },
      });
      break;

    default:
      res.status(400).json({
        error: "Unsupported API version",
        supportedVersions: ["v1", "v2"],
      });
  }
});

Using Accept header for versioning:

app.get("/api/users/:id", (req, res) => {
  const acceptHeader = req.headers.accept;

  if (acceptHeader.includes("application/vnd.myapi.v2+json")) {
    // V2 format
    res.set("Content-Type", "application/vnd.myapi.v2+json");
    res.json({
      /* v2 format */
    });
  } else if (acceptHeader.includes("application/vnd.myapi.v1+json")) {
    // V1 format
    res.set("Content-Type", "application/vnd.myapi.v1+json");
    res.json({
      /* v1 format */
    });
  } else {
    // Default to latest version
    res.json({
      /* latest format */
    });
  }
});

Version Deprecation Strategy

Plan for version lifecycle:

// Deprecation middleware
const deprecationWarning = (version, sunsetDate) => (req, res, next) => {
  res.set("Deprecation", "true");
  res.set("Sunset", sunsetDate);
  res.set("Link", '</api/v2/users>; rel="successor-version"');

  console.warn(`Deprecated API v${version} accessed: ${req.method} ${req.url}`);
  next();
};

// Apply to deprecated versions
app.use("/api/v1", deprecationWarning("1", "2024-12-31"));
app.use("/api/v1/users", require("./routes/v1/users"));

// Current version - no deprecation warning
app.use("/api/v2/users", require("./routes/v2/users"));

Versioning Best Practices

Guidelines for API versioning:

// ✅ Semantic versioning for major changes
app.use("/api/v1", v1Routes); // Breaking changes
app.use("/api/v2", v2Routes); // Breaking changes

// ✅ Backward compatibility when possible
const formatUser = (user, version = "v2") => {
  const baseUser = {
    id: user.id,
    email: user.email,
  };

  if (version === "v1") {
    return { ...baseUser, name: user.name };
  }

  // v2 and later
  return {
    ...baseUser,
    fullName: user.name,
    profile: {
      avatar: user.avatar,
      bio: user.bio,
    },
  };
};

// ✅ Default to latest stable version
app.get("/api/users/:id", (req, res) => {
  const version = req.headers["api-version"] || "v2";
  const user = getUserById(req.params.id);
  res.json({ user: formatUser(user, version) });
});

// ✅ Version documentation and migration guides
app.get("/api", (req, res) => {
  res.json({
    versions: {
      v1: {
        status: "deprecated",
        sunsetDate: "2024-12-31",
        documentation: "/docs/v1",
      },
      v2: {
        status: "current",
        documentation: "/docs/v2",
      },
    },
    migrationGuide: "/docs/migration/v1-to-v2",
  });
});

Key Takeaways

Professional API design isn’t about making endpoints work—it’s about creating interfaces that are intuitive, consistent, and evolvable. The principles we’ve covered form the foundation of APIs that scale from prototype to enterprise.

The mindset shifts you need:

  • From functions to resources: Think in terms of data entities, not actions
  • From arbitrary patterns to REST constraints: Follow established conventions
  • From “works now” to “works forever”: Design for evolution and backward compatibility
  • From guessing to communicating: Use status codes to tell clients exactly what happened

What distinguishes professional APIs:

  • Consistent resource-based URL patterns that are predictable
  • Proper HTTP method usage that follows web standards
  • Meaningful status codes that communicate results clearly
  • Versioning strategies that allow evolution without breaking clients

What’s Next

We’ve established the architectural foundation of professional API design. In the next article, we’ll dive into the implementation details—request/response formats, input validation, error standardization, API documentation, and testing strategies that ensure your well-designed APIs actually work reliably.

The design principles are your blueprint. Next, we’ll build the implementation that brings those principles to life in production code.

You’re transitioning from “developer who can build endpoints” to “engineer who designs scalable API architectures.” The complexity increases, but so does the impact of what you can build.