URI Design Principles (Resource Naming): Plurals, Hierarchy, Filtering/Sorting/Pagination, and What to Avoid



URI Design Principles (Resource Naming): Plurals, Hierarchy, Filtering/Sorting/Pagination, and What to Avoid

In API design, URIs are not just “addresses.” They are a contract that shapes how developers discover, understand, and use your system. A well-designed URI set feels predictable: clients can guess endpoints, errors become easier to debug, and your API remains stable even as features grow. This post explains beginner-friendly URI rules (plural nouns, clean hierarchy, filtering/sorting/pagination), common anti-patterns (verb-based URIs, excessive nesting), and a hands-on exercise designing URIs for a discussion board, orders, and shipping.

Table of Contents

Key Points (Quick Rules)

  • Use nouns for resources (e.g., /orders), and use HTTP methods for actions (GET/POST/PATCH/DELETE).
  • Prefer plural collection names: /users, /posts, /orders.
  • Design a shallow hierarchy: keep nesting limited and only when it clarifies ownership (e.g., /orders/{orderId}/items).
  • Use query parameters for filtering, sorting, and pagination: ?status=paid&sort=-createdAt&page=2&size=20.
  • Avoid verb-based URIs like /createOrder or /getPosts.
  • Avoid “tunnel” URIs with deep nesting like /users/1/teams/2/projects/3/tasks/4/comments.

Core URI Principles for Beginners

A resource is a “thing” your API exposes: a user, a post, an order, a shipment. A URI should name that thing in a consistent way. The operation you want to perform is expressed using HTTP methods:

  • GET reads resources (list or detail).
  • POST creates a new resource inside a collection.
  • PUT replaces a resource (less common for partial changes).
  • PATCH partially updates a resource.
  • DELETE removes a resource.

This separation is the reason you should avoid embedding actions into the URI itself. If you keep URIs noun-based, your API becomes easier to extend: adding new filters, sort fields, or pagination options does not require inventing new endpoints.

Good URI Rules: Plurals, Hierarchy, Query Options

1) Prefer Plural Nouns for Collections

Collections represent lists. Plural names read naturally and avoid confusion:

  • Good: /posts, /orders, /shipments
  • Good (single resource): /posts/{postId}
  • Avoid mixing: /post for listing and /posts for detail (pick one pattern and stay consistent).

2) Use Hierarchy Only When It Represents Containment

Nesting is useful when the child resource “belongs to” the parent and the relationship is part of the meaning:

  • Good: /posts/{postId}/comments (comments are related to a post)
  • Good: /orders/{orderId}/items (items belong to an order)
  • Potentially unnecessary: /users/{userId}/orders (useful if you want “orders for a user,” but consider also supporting /orders?userId=...)

A practical rule: keep nesting to one level deep for most APIs. If you frequently need two or three levels, it may be a sign you should introduce top-level collections plus filtering.

3) Filtering: Use Query Parameters

Filtering changes which subset of a collection you retrieve. It belongs in the query string:

  • Example: GET /orders?status=paid
  • Multiple filters: GET /orders?status=paid&minTotal=50&createdFrom=2026-01-01

Guideline: query parameter names should be predictable, stable, and documented (including allowed values and types).

4) Sorting: Use sort With a Clear Convention

Sorting should also use query parameters. A common convention is:

  • sort=createdAt for ascending
  • sort=-createdAt for descending

Example: GET /posts?sort=-createdAt

5) Pagination: Use page and size (or Cursor)

For beginners, page + size is easiest to implement and understand:

  • GET /posts?page=1&size=20
  • GET /orders?status=paid&sort=-createdAt&page=2&size=50

For very large datasets or “infinite scroll” behavior, cursor-based pagination (e.g., cursor or after) can be more stable, but page/size is a solid starting point for many internal and public APIs.

Anti-Patterns to Avoid: Verbs and Over-Nesting

1) Verb-Based URIs

If you see verbs like “get,” “create,” “update,” “delete,” “ship,” “cancel,” or “approve” in your path, pause and reconsider. In REST-style design, the method expresses the action.

  • Avoid: /getOrders, /createPost, /deleteComment
  • Prefer: GET /orders, POST /posts, DELETE /comments/{commentId}

2) Excessive Nesting

Deep nesting makes URIs hard to read and harder to evolve because the “path structure” becomes too rigid. If a resource can be identified on its own (by ID), consider giving it a top-level endpoint and using filters for relationships.

  • Avoid: /boards/1/posts/2/comments/3/likes/4
  • Prefer: /comments/{commentId} or /likes?commentId={commentId}

Hands-On Practice: Board / Orders / Shipping URI Design

A) Discussion Board URIs

Assume you have boards, posts, comments, and reactions (likes). A clean, beginner-friendly URI set might look like this:

  • GET /boards (list boards)
  • GET /boards/{boardId} (board detail)
  • GET /posts?boardId={boardId}&sort=-createdAt&page=1&size=20 (posts in a board, filtered via query)
  • POST /posts (create post; include boardId in the request body)
  • GET /posts/{postId} (post detail)
  • PATCH /posts/{postId} (edit post)
  • DELETE /posts/{postId} (delete post)
  • GET /posts/{postId}/comments (comments for a post)
  • POST /posts/{postId}/comments (create comment under a post)
  • DELETE /comments/{commentId} (delete a comment by ID)
  • POST /posts/{postId}/reactions (add a reaction to a post)
  • DELETE /reactions/{reactionId} (remove a reaction)

Notice the pattern: collections are plural, resources use IDs, and actions are handled by methods. Also notice a design choice: listing posts in a board uses /posts?boardId=.... This helps keep the hierarchy shallow while still enabling a “board posts” view.

B) Order URIs

Orders naturally include items and payments. A clean design:

  • GET /orders?status=paid&sort=-createdAt&page=1&size=20
  • POST /orders (create an order)
  • GET /orders/{orderId}
  • GET /orders/{orderId}/items
  • POST /orders/{orderId}/items (add item)
  • PATCH /orders/{orderId} (update order fields like address notes)

If you need “cancel” as a domain action, consider modeling it as a state update rather than a verb endpoint: PATCH /orders/{orderId} with a request body like {"status":"canceled"}, plus validation rules on the server.

C) Shipping/Delivery URIs

Shipping is often a separate lifecycle from orders, so it is common to treat shipments as their own top-level resource:

  • GET /shipments?orderId={orderId} (shipments for an order)
  • POST /shipments (create shipment; include orderId in body)
  • GET /shipments/{shipmentId}
  • PATCH /shipments/{shipmentId} (update carrier, tracking number, status)
  • GET /shipments/{shipmentId}/events (tracking events)

This avoids locking your shipping model into /orders/{orderId}/shipments/{shipmentId} everywhere. You can still offer that nested view if needed, but the top-level collection keeps the system flexible.

Implementation Example (Routes + Query Parameters)

The following example shows how you might implement these URI patterns with Express. It includes a consistent approach to filtering, sorting, and pagination on list endpoints.

import express from "express";

const app = express();
app.use(express.json());

// Example: GET /orders?status=paid&sort=-createdAt&page=2&size=20
app.get("/orders", async (req, res) => {
  const status = req.query.status; // e.g., "paid"
  const sort = req.query.sort || "-createdAt"; // default descending
  const page = Math.max(parseInt(req.query.page || "1", 10), 1);
  const size = Math.min(Math.max(parseInt(req.query.size || "20", 10), 1), 100);

  // Convert sorting convention: "-createdAt" => { createdAt: "desc" }
  const sortField = String(sort).startsWith("-") ? String(sort).slice(1) : String(sort);
  const sortDir = String(sort).startsWith("-") ? "desc" : "asc";

  // In a real app, build a database query here.
  const filter = {};
  if (status) filter.status = status;

  const result = {
    filter,
    sort: { field: sortField, direction: sortDir },
    pagination: { page, size, offset: (page - 1) * size },
    data: []
  };

  res.json(result);
});

// Nested sub-resource example: GET /orders/:orderId/items
app.get("/orders/:orderId/items", async (req, res) => {
  const { orderId } = req.params;
  res.json({ orderId, items: [] });
});

// Create resources with POST on collections
app.post("/orders", async (req, res) => {
  const payload = req.body; // includes userId, items, address, etc.
  res.status(201).json({ id: "ord_123", ...payload });
});

app.listen(3000, () => console.log("API listening on port 3000"));

Key takeaway: list endpoints (/orders, /posts, /shipments) stay consistent because filtering, sorting, and pagination are all query parameters. This avoids creating a separate endpoint for every combination of options.

Checklist Table (Do vs Don’t)

Topic Recommended Avoid Example
Resource naming Nouns, plural collections Verbs in paths /orders vs /createOrder
Hierarchy Shallow, 1-level nesting Deep nesting chains /orders/{id}/items vs /users/{u}/orders/{o}/items/{i}
Filtering Query parameters Path-based filters /orders?status=paid vs /orders/paid
Sorting Single sort convention Multiple ad-hoc endpoints ?sort=-createdAt vs /orders/latest
Pagination page + size (or cursor) Hard-coded limits ?page=2&size=50

Step-by-Step Workflow to Design URIs

  1. List your domain nouns (resources) first: boards, posts, comments, orders, shipments, shipment events.
  2. Decide which are top-level collections (/posts, /orders, /shipments) and which are sub-resources (/orders/{id}/items).
  3. Choose a consistent naming style: plural nouns, lowercase, hyphenated words if needed (e.g., /shipment-events if you do not want nested /events).
  4. Define list behavior once: filtering, sorting, and pagination via query parameters on every collection endpoint.
  5. Keep nesting shallow: only nest when the relationship clarifies meaning or simplifies authorization.
  6. Map actions to methods: create via POST, update via PATCH, delete via DELETE.
  7. Write examples for the most common user journeys (browse, search, detail, create, edit, delete) and confirm they feel predictable.

Extra Considerations

  • Consistency beats perfection: a “good enough” consistent convention is easier to use than a “perfect” but inconsistent one.
  • Case and separators: prefer lowercase paths and hyphens for multi-word segments (e.g., /order-items) rather than camelCase.
  • Trailing slash: pick a rule and keep it consistent (many APIs omit trailing slashes: /orders not /orders/).
  • Versioning: if you must version in the URI, keep it simple (e.g., /v1/orders). If you use headers for versioning, document it clearly.
  • Errors and validation: predictable URIs help, but clear error responses (status code + message + field errors) complete the developer experience.

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

Reactions

댓글 쓰기

0 댓글