Files
classroomio/apps/api/ROUTE_NAMING_BEST_PRACTICES.md

14 KiB

API Route Naming and Structure Best Practices

This guide outlines best practices for naming and structuring API routes, inspired by Stripe's API design principles. These conventions ensure consistency, predictability, and maintainability across all API endpoints.


Core Principles

1. RESTful Resource Naming

Use plural nouns for resource names, following REST conventions:

// ✅ GOOD - Plural nouns
GET    /courses
POST   /courses
GET    /courses/:id
PUT    /courses/:id
DELETE /courses/:id

GET    /organizations
POST   /organizations
GET    /organizations/:id

// ❌ BAD - Singular nouns
GET    /course
POST   /course
GET    /organization

Why? Resources represent collections. Even when fetching a single item, you're accessing it from a collection.


2. URL Structure Hierarchy

Organize routes hierarchically to reflect resource relationships:

// ✅ GOOD - Clear hierarchy
GET    /organizations/:orgId/courses
POST   /organizations/:orgId/courses
GET    /organizations/:orgId/courses/:courseId
PUT    /organizations/:orgId/courses/:courseId

GET    /courses/:courseId/lessons
POST   /courses/:courseId/lessons
GET    /courses/:courseId/lessons/:lessonId

// ❌ BAD - Flat structure loses context
GET    /courses (which org?)
GET    /lessons (which course?)

Exception: Top-level resources that don't belong to a parent:

// ✅ OK - Top-level resources
GET / account;
PUT / account / user;
GET / account / subscription;

3. HTTP Method Semantics

Use HTTP methods correctly for their intended purpose:

Method Purpose Idempotent Response Body
GET Retrieve resource(s) ✅ Yes Always
POST Create new resource ❌ No Created resource
PUT Replace entire resource ✅ Yes Updated resource
PATCH Partial update ✅ Yes Updated resource
DELETE Remove resource ✅ Yes Usually empty (204)
// ✅ GOOD - Correct method usage
GET    /courses/:id              // Retrieve course
POST   /courses                  // Create course
PUT    /courses/:id              // Replace entire course
PATCH  /courses/:id              // Partial update
DELETE /courses/:id              // Delete course

// ❌ BAD - Wrong method
POST   /courses/:id/update       // Should be PUT or PATCH
GET    /courses/:id/delete       // Should be DELETE
POST   /courses/:id              // Should be PUT/PATCH for updates

4. Nested Resources

Nest resources when there's a clear parent-child relationship:

// ✅ GOOD - Nested resources
GET    /courses/:courseId/lessons
POST   /courses/:courseId/lessons
GET    /courses/:courseId/lessons/:lessonId
PUT    /courses/:courseId/lessons/:lessonId

GET    /organizations/:orgId/members
POST   /organizations/:orgId/members
DELETE /organizations/:orgId/members/:memberId

// ❌ BAD - Flat structure with IDs
GET    /lessons?courseId=123     // Less clear hierarchy
POST   /lessons?courseId=123     // Relationship not obvious

When to nest:

  • ✅ Child resource only exists within parent context
  • ✅ Child resource ID is unique within parent (e.g., lessonId within courseId)
  • ✅ Operations typically scoped to parent

When NOT to nest:

  • ❌ Resource can exist independently
  • ❌ Resource ID is globally unique
  • ❌ Operations span multiple parents
// ✅ OK - Independent resource
GET    /users/:userId            // User exists independently
GET    /courses/:courseId        // Course exists independently

// ✅ BAD - Unnecessary nesting
GET    /organizations/:orgId/users/:userId  // User is global, not org-specific

5. Action Endpoints (Non-Resource Operations)

For operations that don't map to CRUD, use action verbs as sub-resources:

// ✅ GOOD - Action endpoints
POST   /courses/:id/clone
POST   /courses/:id/publish
POST   /courses/:id/unpublish
POST   /organizations/:id/invite
POST   /subscriptions/:id/cancel
POST   /subscriptions/:id/reactivate

// ❌ BAD - Verbs in main path
POST   /clone-course/:id
POST   /publish-course/:id
GET    /get-courses              // Redundant verb

Pattern: POST /resource/:id/action

When to use:

  • ✅ State transitions (publish, unpublish, activate, cancel)
  • ✅ Side effects (clone, duplicate, export)
  • ✅ Complex operations that don't fit CRUD

6. Query Parameters

Use query parameters for filtering, sorting, pagination, and optional data:

// ✅ GOOD - Query parameters
GET    /courses?status=published
GET    /courses?orgId=123&status=published
GET    /courses?page=1&limit=20
GET    /courses?sort=created_at&order=desc
GET    /courses?search=javascript
GET    /organizations?siteName=acme

// ❌ BAD - Path parameters for filters
GET    /courses/status/published
GET    /courses/page/1
GET    /courses/search/javascript

Common query parameter patterns:

Purpose Parameter Example
Filtering status, type, role ?status=active&type=course
Search q, search ?q=javascript
Pagination page, limit, offset ?page=1&limit=20
Sorting sort, order ?sort=created_at&order=desc
Date range startDate, endDate ?startDate=2024-01-01
Boolean flags include, expand ?include=lessons&expand=metadata

7. URL Parameter Naming

Use camelCase for URL parameters to match JavaScript conventions:

// ✅ GOOD - camelCase
GET    /courses/:courseId
GET    /organizations/:orgId
GET    /users/:userId
GET    /lessons/:lessonId

// ❌ BAD - snake_case or kebab-case
GET    /courses/:course_id
GET    /courses/:course-id

Exception: When matching database column names exactly:

// ✅ OK - Matching database convention
GET    /courses/:id              // Generic ID
GET    /courses?siteName=acme    // Matches DB column

8. Consistent Response Structure

Maintain consistent response formats across all endpoints:

// ✅ GOOD - Consistent structure
// Single resource
{
  "success": true,
  "data": { ... }
}

// Collection
{
  "success": true,
  "data": [ ... ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 100,
    "totalPages": 5
  }
}

// Error
{
  "success": false,
  "error": "Human-readable message",
  "code": "ERROR_CODE",
  "field": "fieldName"  // Optional, for validation errors
}

9. Versioning Strategy

Plan for API versioning from the start:

// Current (v1 implied)
.route('/courses', courseRouter)

// Future versioning
.route('/v1/courses', courseRouterV1)
.route('/v2/courses', courseRouterV2)

// Or use headers (Stripe style)
// X-API-Version: 2024-01-01

Recommendation: Start without versioning, add /v1/ prefix when breaking changes are needed.


10. Route Organization in Code

Organize routes by domain/resource:

// ✅ GOOD - Organized by resource
apps/api/src/routes/
  ├── course/
  │   ├── course.ts          // Main course routes
  │   ├── lesson.ts          // Lesson routes
  │   ├── clone.ts           // Clone action
  │   └── index.ts           // Re-exports
  ├── organization/
  │   ├── organization.ts    // Main org routes
  │   ├── team.ts            // Team member routes
  │   └── index.ts
  └── account/
      ├── account.ts         // Account routes
      └── index.ts

Route registration:

// apps/api/src/app.ts
import { courseRouter } from '@api/routes/course';
import { organizationRouter } from '@api/routes/organization';
import { accountRouter } from '@api/routes/account';

export const app = new Hono()
  .route('/course', courseRouter) // /course/*
  .route('/organization', organizationRouter) // /organization/*
  .route('/account', accountRouter); // /account/*

Complete Examples

Example 1: Course Management

// apps/api/src/routes/course/course.ts
export const courseRouter = new Hono()
  // List courses (with filters)
  .get('/', authMiddleware, zValidator('query', ZListCourses), async (c) => {
    // GET /course?orgId=123&status=published&page=1&limit=20
  })

  // Get single course
  .get('/:courseId', authMiddleware, zValidator('param', ZGetCourse), async (c) => {
    // GET /course/:courseId
  })

  // Create course
  .post('/', authMiddleware, zValidator('json', ZCreateCourse), async (c) => {
    // POST /course
  })

  // Update course (full)
  .put('/:courseId', authMiddleware, zValidator('json', ZUpdateCourse), async (c) => {
    // PUT /course/:courseId
  })

  // Update course (partial)
  .patch('/:courseId', authMiddleware, zValidator('json', ZPatchCourse), async (c) => {
    // PATCH /course/:courseId
  })

  // Delete course
  .delete('/:courseId', authMiddleware, zValidator('param', ZDeleteCourse), async (c) => {
    // DELETE /course/:courseId
  })

  // Actions
  .post('/:courseId/clone', authMiddleware, zValidator('json', ZCloneCourse), async (c) => {
    // POST /course/:courseId/clone
  })

  .post('/:courseId/publish', authMiddleware, zValidator('param', ZPublishCourse), async (c) => {
    // POST /course/:courseId/publish
  });

Example 2: Nested Resources

// apps/api/src/routes/course/lesson.ts
export const lessonRouter = new Hono()
  // List lessons for a course
  .get('/', authMiddleware, zValidator('query', ZListLessons), async (c) => {
    // GET /course/:courseId/lesson?page=1&limit=20
  })

  // Get single lesson
  .get('/:lessonId', authMiddleware, zValidator('param', ZGetLesson), async (c) => {
    // GET /course/:courseId/lesson/:lessonId
  })

  // Create lesson
  .post('/', authMiddleware, zValidator('json', ZCreateLesson), async (c) => {
    // POST /course/:courseId/lesson
  })

  // Update lesson
  .put('/:lessonId', authMiddleware, zValidator('json', ZUpdateLesson), async (c) => {
    // PUT /course/:courseId/lesson/:lessonId
  })

  // Delete lesson
  .delete('/:lessonId', authMiddleware, zValidator('param', ZDeleteLesson), async (c) => {
    // DELETE /course/:courseId/lesson/:lessonId
  });

// Register nested route
// apps/api/src/routes/course/course.ts
export const courseRouter = new Hono()
  // ... course routes ...
  .route('/:courseId/lesson', lessonRouter); // Nested under course

Example 3: Organization with Team

// apps/api/src/routes/organization/organization.ts
export const organizationRouter = new Hono()
  // List organizations
  .get('/', authMiddleware, zValidator('query', ZListOrgs), async (c) => {
    // GET /organization?siteName=acme
  })

  // Get organization
  .get('/:orgId', authMiddleware, zValidator('param', ZGetOrg), async (c) => {
    // GET /organization/:orgId
  })

  // Create organization
  .post('/', authMiddleware, zValidator('json', ZCreateOrg), async (c) => {
    // POST /organization
  })

  // Update organization
  .put('/:orgId', authMiddleware, zValidator('json', ZUpdateOrg), async (c) => {
    // PUT /organization/:orgId
  })

  // Team management (nested)
  .get('/:orgId/team', authMiddleware, zValidator('param', ZGetTeam), async (c) => {
    // GET /organization/:orgId/team
  })

  .post('/:orgId/team/invite', authMiddleware, zValidator('json', ZInviteTeam), async (c) => {
    // POST /organization/:orgId/team/invite
  })

  .delete('/:orgId/team/:memberId', authMiddleware, zValidator('param', ZRemoveMember), async (c) => {
    // DELETE /organization/:orgId/team/:memberId
  });

Anti-Patterns to Avoid

❌ Don't: Use verbs in resource names

// ❌ BAD
GET    /get-courses
POST   /create-course
POST   /update-course/:id
GET    /delete-course/:id

// ✅ GOOD
GET    /courses
POST   /courses
PUT    /courses/:id
DELETE /courses/:id

❌ Don't: Mix singular and plural

// ❌ BAD
GET / course;
POST / courses;
GET / organization;
POST / organizations;

// ✅ GOOD
GET / courses;
POST / courses;
GET / organizations;
POST / organizations;

❌ Don't: Use query parameters for required identifiers

// ❌ BAD
GET    /courses?id=123
GET    /courses?courseId=123

// ✅ GOOD
GET    /courses/:courseId
GET    /courses/123

❌ Don't: Create deep nesting (>3 levels)

// ❌ BAD - Too deep
GET    /organizations/:orgId/courses/:courseId/lessons/:lessonId/exercises/:exerciseId

// ✅ GOOD - Flatten when appropriate
GET    /exercises/:exerciseId?courseId=123&lessonId=456
// Or use a different resource structure
GET    /lessons/:lessonId/exercises/:exerciseId

❌ Don't: Use inconsistent naming

// ❌ BAD - Mixed conventions
GET    /courses/:course_id
GET    /organizations/:orgId
GET    /users/:userId

// ✅ GOOD - Consistent camelCase
GET    /courses/:courseId
GET    /organizations/:orgId
GET    /users/:userId

Checklist for New Routes

When creating a new route, ensure:

  • Resource name is plural (e.g., /courses, not /course)
  • URL parameters use camelCase (e.g., :courseId, not :course_id)
  • HTTP method matches operation (GET=read, POST=create, PUT=replace, PATCH=update, DELETE=remove)
  • Nested resources reflect parent-child relationships
  • Action endpoints use POST /resource/:id/action pattern
  • Query parameters used for filtering, pagination, sorting
  • Route is registered in main app.ts
  • Validation schema exists in packages/utils/src/validation/
  • Route follows existing patterns in codebase
  • Response format is consistent with other routes

Summary

Key Takeaways:

  1. Plural nouns for resources (/courses, /organizations)
  2. camelCase for parameters (:courseId, :orgId)
  3. Hierarchical nesting for related resources
  4. HTTP methods used semantically (GET, POST, PUT, PATCH, DELETE)
  5. Action verbs as sub-resources for non-CRUD operations
  6. Query parameters for filtering, pagination, sorting
  7. Consistent structure across all endpoints
  8. Organized by domain in code structure

Following these conventions ensures your API is predictable, maintainable, and developer-friendly.