Introduction to REST API URL Design

Best practices for designing clean, intuitive, and RESTful URLs for your APIs

URLs (Uniform Resource Locators) form the foundation of any REST API design. They serve as the primary interface between clients and your API, making them one of the most critical aspects of your API architecture. Well-designed URLs improve discoverability, usability, and the overall developer experience.

Why URL Design Matters

A thoughtful URL design offers several significant benefits:

  • Intuitive Navigation: Well-structured URLs serve as a natural map of your API’s resources
  • Self-Documentation: Clear URLs can reduce the learning curve for API consumers
  • Consistency: Standardized URL patterns make your API more predictable
  • Developer Experience: URLs are often the first thing developers see and evaluate
  • SEO Benefits: For public APIs, well-designed URLs can improve search engine visibility
  • Longevity: Good URL design reduces the need for breaking changes

URLs represent a contract between your API and its consumers. Once published, changing your URL structure constitutes a breaking change that impacts all clients. This makes careful upfront design essential for long-term API stability.

REST URL Design Principles

Resource-Oriented Design

REST URLs should identify resources, not actions or operations on those resources. Resources are the nouns of your API, while HTTP methods represent the verbs that act upon them. This separation is fundamental to RESTful design.

Naming, Collections and Nouns

The purpose of a URI is to uniquely address resources. The URI structure should be designed to allow intuitive navigation through your data model, making it understandable even to humans who are browsing your API for the first time.

Collections and Singular Resources

Resources in REST APIs are typically organized into collections (plural nouns) and individual resources (identified by unique IDs):

  • Collections: Use plural nouns to represent collections of resources

    • /users - All users in the system
    • /products - All products in the catalog
    • /orders - All orders in the system
  • Individual Resources: Identify specific resources using unique identifiers

    • /users/42 - The user with ID 42
    • /products/xyz-89 - The product with ID xyz-89
    • /orders/2023-01-15 - The order from January 15, 2023

Consistent Resource Naming

Follow these naming conventions for clear, consistent URLs:

  1. Use Nouns, Not Verbs

    • Good: /orders (resource)
    • Avoid: /getOrders (action)
  2. Use Plural for Collections

    • Good: /products
    • Avoid: /product
  3. Use Kebab Case for Multi-Word Resources

    • Good: /shipping-addresses
    • Avoid: /shippingAddresses or /shipping_addresses
  4. Be Consistent with Casing

    • Choose one standard (typically kebab-case for URLs) and apply it consistently
    • Good: /order-items
    • Avoid mixing: /orderItems and /shipping-addresses
  5. Avoid Special Characters

    • Good: /reports/annual-2025
    • Avoid: /reports/annual%202025

Example: Retrieving Task Collections and Individual Tasks

When a client makes a GET request to the tasks collection, the API should return a list of task resources:

// GET /tasks
[
    {
        "id": 123,
        "title": "Buy flowers",
        "isDone": false,
        "dueDate": "2025-05-15T10:00:00Z",
        "priority": "medium"
    },
    {
        "id": 789,
        "title": "Water flowers",
        "isDone": false,
        "dueDate": "2025-05-16T17:00:00Z",
        "priority": "low"
    }
    // ... more tasks here
]

Each resource should include a unique identifier (id) that can be used to address the individual resource:

// GET /tasks/123
{
    "id": 123,
    "title": "Buy flowers",
    "isDone": false,
    "dueDate": "2025-05-15T10:00:00Z",
    "priority": "medium",
    "description": "Purchase bouquet for anniversary",
    "assignee": {
        "id": 42,
        "name": "John Doe"
    },
    "tags": ["personal", "important"]
}

Implementation Example in ASP.NET Core

// ASP.NET Core controller implementing the resource pattern
[ApiController]
[Route("api/tasks")]
public class TasksController : ControllerBase
{
    private readonly ITaskRepository _taskRepository;
    
    public TasksController(ITaskRepository taskRepository)
    {
        _taskRepository = taskRepository;
    }
    
    // GET api/tasks
    [HttpGet]
    public ActionResult<IEnumerable<TaskDto>> GetTasks()
    {
        IEnumerable<TaskDto> tasks = _taskRepository.GetAll();
        return Ok(tasks);
    }
    
    // GET api/tasks/{id}
    [HttpGet("{id}")]
    public ActionResult<TaskDto> GetTask(int id)
    {
        TaskDto task = _taskRepository.GetById(id);
        
        if (task == null)
        {
            return NotFound();
        }
        
        return Ok(task);
    }
}

Actions are HTTP Verbs

Verbs represent actions on resources that are implemented in REST via HTTP Request Methods. Each verb has a specific semantic meaning that should be respected in your API design.

Standard HTTP Verbs for CRUD Operations

HTTP VerbCRUDResource TargetExamplesAction DescriptionExpected ResponseCommon Status Codes
GETReadCollection or individual resource/tasks, /tasks/123Retrieve a resource or collectionThe requested resource(s)200 OK, 404 Not Found, 304 Not Modified
POSTCreateCollection/tasksAdd a new resource to a collectionThe created resource or a reference to it201 Created, 400 Bad Request, 409 Conflict
PUTUpdate/ReplaceIndividual resource/tasks/123Replace an existing resource completelyThe updated resource200 OK, 404 Not Found, 409 Conflict
PATCHUpdate/ModifyIndividual resource/tasks/123Apply partial updates to a resourceThe updated resource or status200 OK, 404 Not Found, 409 Conflict
DELETEDeleteIndividual resource/tasks/123Remove an existing resourceEmpty response or deleted resource200 OK, 204 No Content, 404 Not Found, 409 Conflict

Less Common but Useful HTTP Verbs

HTTP VerbPurposeExamples
HEADRetrieve headers onlyCheck if a resource exists or has been modified
OPTIONSDiscover available operationsCORS preflight requests, API capabilities discovery

Examples of HTTP Verbs in Action

Due to the different verbs, a single URL endpoint can offer different functionality based on the HTTP method used:

# Creating a new task
POST /tasks HTTP/1.1
Content-Type: application/json

{
  "title": "Learn REST API design",
  "dueDate": "2025-06-01T12:00:00Z",
  "priority": "high"
}

# Response (201 Created)
{
  "id": 456,
  "title": "Learn REST API design",
  "dueDate": "2025-06-01T12:00:00Z",
  "priority": "high",
  "isDone": false
}

# Retrieving all tasks
GET /tasks HTTP/1.1
Accept: application/json

# Updating a task completely
PUT /tasks/456 HTTP/1.1
Content-Type: application/json

{
  "title": "Master REST API design",
  "dueDate": "2025-06-15T12:00:00Z",
  "priority": "critical",
  "isDone": false
}

# Partially updating a task
PATCH /tasks/456 HTTP/1.1
Content-Type: application/json

{
  "priority": "critical",
  "dueDate": "2025-06-20T12:00:00Z"
}

# Deleting a task
DELETE /tasks/456 HTTP/1.1

Implementation in ASP.NET Core

[ApiController]
[Route("api/tasks")]
public class TasksController : ControllerBase
{
    private readonly ITaskRepository _taskRepository;
    
    public TasksController(ITaskRepository taskRepository)
    {
        _taskRepository = taskRepository;
    }
    
    // GET: api/tasks
    [HttpGet]
    public ActionResult<IEnumerable<TaskDto>> GetTasks()
    {
        IEnumerable<TaskDto> tasks = _taskRepository.GetAll();
        return Ok(tasks);
    }
    
    // GET: api/tasks/{id}
    [HttpGet("{id}")]
    public ActionResult<TaskDto> GetTask(int id)
    {
        TaskDto task = _taskRepository.GetById(id);
        
        if (task == null)
        {
            return NotFound();
        }
        
        return Ok(task);
    }
    
    // POST: api/tasks
    [HttpPost]
    public ActionResult<TaskDto> CreateTask(CreateTaskDto createTaskDto)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }
        
        TaskDto newTask = _taskRepository.Create(createTaskDto);
        
        // Return 201 Created with the resource location and the created resource
        return CreatedAtAction(
            nameof(GetTask),
            new { id = newTask.Id },
            newTask
        );
    }
    
    // PUT: api/tasks/{id}
    [HttpPut("{id}")]
    public IActionResult UpdateTask(int id, UpdateTaskDto updateTaskDto)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }
        
        if (id != updateTaskDto.Id)
        {
            return BadRequest("ID mismatch");
        }
        
        bool exists = _taskRepository.Exists(id);
        if (!exists)
        {
            return NotFound();
        }
        
        TaskDto updatedTask = _taskRepository.Update(updateTaskDto);
        return Ok(updatedTask);
    }
    
    // PATCH: api/tasks/{id}
    [HttpPatch("{id}")]
    public IActionResult PartialUpdateTask(int id, JsonPatchDocument<TaskDto> patchDoc)
    {
        TaskDto existingTask = _taskRepository.GetById(id);
        
        if (existingTask == null)
        {
            return NotFound();
        }
        
        patchDoc.ApplyTo(existingTask, ModelState);
        
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }
        
        TaskDto updatedTask = _taskRepository.Update(existingTask);
        return Ok(updatedTask);
    }
    
    // DELETE: api/tasks/{id}
    [HttpDelete("{id}")]
    public IActionResult DeleteTask(int id)
    {
        TaskDto existingTask = _taskRepository.GetById(id);
        
        if (existingTask == null)
        {
            return NotFound();
        }
        
        _taskRepository.Delete(id);
        return NoContent();  // 204 No Content is often preferred for DELETE operations
    }
}

Best Practices for HTTP Methods

  1. Respect HTTP Method Semantics: Use the correct HTTP method for each operation

  2. Use GET for Safe Operations: GET requests should never alter state or have side effects

  3. Use PUT for Complete Resource Updates: When replacing an entire resource

  4. Use PATCH for Partial Updates: When updating only specific fields of a resource

  5. Return Appropriate Status Codes: Match the status code to the operation result

  6. Include Location Headers: For resource creation, include the URL of the new resource

  7. Consider Idempotency: PUT, DELETE, and GET operations should be idempotent

Hierarchical Resources and Relationships

Many resources have relationships and hierarchies that should be reflected in your URL design. REST APIs should provide intuitive ways to navigate these relationships.

Resource Relationships in URLs

Resources are often related to each other in one of these ways:

  1. Parent-Child Relationship: A parent resource contains or owns child resources

    • /articles/{id}/comments - All comments belonging to a specific article
    • /users/{id}/addresses - All addresses belonging to a specific user
  2. Association Relationship: Resources are related but neither owns the other

    • /products/{id}/related - Products related to a specific product
    • /users/{id}/followers - Users who follow a specific user

Design Patterns for Resource Relationships

Consider the following guidelines when designing URLs for related resources:

  1. Nested Resources for Ownership

    • /articles/123/comments - All comments for article 123
    • /articles/123/comments/456 - Specific comment 456 on article 123
  2. Separate Resources with Cross-References

    • /articles/123 - The article resource contains comment IDs
    • /comments/456 - Comment resources can be accessed directly
  3. Resource Expansion

    • /articles/123?expand=comments - Retrieves article with embedded comments

Example of Hierarchical Resource

For complex resources like articles with authors, comments, and tags:

// GET /articles/123
{
    "id": 123,
    "createdOn": "2025-01-27T10:27:00+03:00",
    "modifiedOn": "2025-02-03T16:47:00+01:00",
    "title": "REST is nice! Use it now in your public HTTP API!",
    "text": "Lorem ipsum dolor sit amet, consetetur sadipscing elitr...",
    "textType": "PlainText",
    "author": {
        "id": 3105,
        "name": "Benjamin Abt",
        "email": "[email protected]",
        "nickname": "Ben",
        "twitter": {
            "url": "https://twitter.com/abt_benjamin",
            "username": "abt_benjamin",
            "avatarUrl": "https://pbs.twimg.com/profile_images/1404387914344243203/oUO_LNAp_400x400.jpg"
        }
    },
    "tags": [".NET", "Azure", "API", "REST"],
    "comments": [
        {
            "id": 8758,
            "text": "Lorem ipsum dolor sit amet...",
            "on": "2025-01-29T16:47:00+01:00",
            "by": {
                "id": 789,
                "name": "Batman"
            }
        },
        {
            "id": 485,
            "text": "Lorem ipsum dolor sit amet...",
            "on": "2025-01-30T16:47:00+01:00",
            "by": {
                "id": 325,
                "name": "Robin"
            }
        }
    ],
    "_links": {
        "self": { "href": "/articles/123" },
        "author": { "href": "/authors/3105" },
        "comments": { "href": "/articles/123/comments" }
    }
}

URL Design for Hierarchical Resources

For the article example, the following URL structure provides logical access to related resources:

# Primary resource access
GET /articles/123          # Get specific article
GET /articles              # Get all articles

# Accessing related resources
GET /articles/123/author   # Get author of article 123
GET /articles/123/comments # Get all comments for article 123
GET /articles/123/tags     # Get all tags for article 123

# Direct access to related resources
GET /authors/3105          # Get author with ID 3105
GET /comments/485          # Get specific comment 485

Implementation in ASP.NET Core

[ApiController]
[Route("api/articles")]
public class ArticlesController : ControllerBase
{
    private readonly IArticleRepository _articleRepository;
    private readonly ICommentRepository _commentRepository;
    
    public ArticlesController(
        IArticleRepository articleRepository,
        ICommentRepository commentRepository)
    {
        _articleRepository = articleRepository;
        _commentRepository = commentRepository;
    }
    
    // GET: api/articles
    [HttpGet]
    public ActionResult<IEnumerable<ArticleDto>> GetArticles()
    {
        IEnumerable<ArticleDto> articles = _articleRepository.GetAll();
        return Ok(articles);
    }
    
    // GET: api/articles/{id}
    [HttpGet("{id}")]
    public ActionResult<ArticleDto> GetArticle(int id)
    {
        ArticleDto article = _articleRepository.GetById(id);
        
        if (article == null)
        {
            return NotFound();
        }
        
        return Ok(article);
    }
    
    // GET: api/articles/{id}/comments
    [HttpGet("{id}/comments")]
    public ActionResult<IEnumerable<CommentDto>> GetArticleComments(int id)
    {
        bool articleExists = _articleRepository.Exists(id);
        
        if (!articleExists)
        {
            return NotFound("Article not found");
        }
        
        IEnumerable<CommentDto> comments = _commentRepository.GetByArticleId(id);
        return Ok(comments);
    }
    
    // GET: api/articles/{articleId}/comments/{commentId}
    [HttpGet("{articleId}/comments/{commentId}")]
    public ActionResult<CommentDto> GetArticleComment(int articleId, int commentId)
    {
        bool articleExists = _articleRepository.Exists(articleId);
        
        if (!articleExists)
        {
            return NotFound("Article not found");
        }
        
        CommentDto comment = _commentRepository.GetById(commentId);
        
        if (comment == null || comment.ArticleId != articleId)
        {
            return NotFound("Comment not found for this article");
        }
        
        return Ok(comment);
    }
    
    // POST: api/articles/{id}/comments
    [HttpPost("{id}/comments")]
    public ActionResult<CommentDto> AddComment(int id, CreateCommentDto commentDto)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }
        
        bool articleExists = _articleRepository.Exists(id);
        
        if (!articleExists)
        {
            return NotFound("Article not found");
        }
        
        CommentDto newComment = _commentRepository.Create(id, commentDto);
        
        return CreatedAtAction(
            nameof(GetArticleComment),
            new { articleId = id, commentId = newComment.Id },
            newComment
        );
    }
}

Best Practices for Hierarchical Resources

  1. Consistency: Use consistent patterns for expressing relationships

  2. Limit Nesting: Avoid deep nesting (more than 2-3 levels) as it becomes unwieldy

    • Good: /articles/123/comments/456
    • Avoid: /users/42/articles/123/comments/456/replies/789
  3. Multiple Access Paths: Provide direct access to resources when appropriate

    • /articles/123/comments/456 - Nested access
    • /comments/456 - Direct access
  4. Use Links: Include hypermedia links in responses to guide clients to related resources

  5. Consider Query Parameters for Filtering Related Resources

    • /comments?articleId=123 as an alternative to /articles/123/comments
  6. Balance between Embedding and Linking: Decide whether to embed related resources or link to them based on typical usage patterns

Query Parameters for Filtering, Sorting, and Pagination

REST APIs often need to handle large collections of resources, requiring mechanisms for clients to filter results, control sorting order, and paginate through large result sets.

Filtering

Filtering allows clients to retrieve subsets of a resource collection by specifying criteria. While REST doesn’t standardize filtering syntax, several common patterns have emerged:

Basic Filtering by Resource Properties

Use query parameters that match property names:

# Filter by single property
GET /articles?author=3105
GET /products?category=electronics
GET /tasks?isDone=false

# Filter by multiple properties (AND logic)
GET /articles?author=3105&isPublished=true
GET /products?category=electronics&inStock=true

Advanced Filtering

For more complex filtering needs, consider a structured filtering syntax:

# Range filtering
GET /products?price[min]=100&price[max]=500

# Array value filtering
GET /products?tags=electronics&tags=sale

# Search/contains filtering
GET /articles?title[contains]=REST

Implementation in ASP.NET Core

[ApiController]
[Route("api/articles")]
public class ArticlesController : ControllerBase
{
    private readonly IArticleRepository _repository;
    
    public ArticlesController(IArticleRepository repository)
    {
        _repository = repository;
    }
    
    [HttpGet]
    public ActionResult<IEnumerable<ArticleDto>> GetArticles(
        [FromQuery] int? authorId = null,
        [FromQuery] bool? isPublished = null,
        [FromQuery] string titleContains = null,
        [FromQuery] string[] tags = null)
    {
        // Create a filter specification
        ArticleFilterSpecification filter = new ArticleFilterSpecification
        {
            AuthorId = authorId,
            IsPublished = isPublished,
            TitleContains = titleContains,
            Tags = tags
        };
        
        // Apply filtering
        IEnumerable<ArticleDto> articles = _repository.GetFiltered(filter);
        
        return Ok(articles);
    }
}

Sorting

Sorting allows clients to specify the order of results. Common conventions include:

# Sort by a single field (ascending by default)
GET /articles?sort=title

# Explicit sort direction
GET /articles?sort=title:asc
GET /products?sort=price:desc

# Multiple sort criteria (applied in order)
GET /articles?sort=author:asc,publishDate:desc

Implementation in ASP.NET Core

[ApiController]
[Route("api/articles")]
public class ArticlesController : ControllerBase
{
    private readonly IArticleRepository _repository;
    
    public ArticlesController(IArticleRepository repository)
    {
        _repository = repository;
    }
    
    [HttpGet]
    public ActionResult<IEnumerable<ArticleDto>> GetArticles(
        [FromQuery] string sort = null)
    {
        // Parse and apply sorting
        List<SortCriteria> sortCriteria = ParseSortParameter(sort);
        IEnumerable<ArticleDto> articles = _repository.GetSorted(sortCriteria);
        
        return Ok(articles);
    }
    
    private List<SortCriteria> ParseSortParameter(string sort)
    {
        List<SortCriteria> criteria = new List<SortCriteria>();
        
        if (string.IsNullOrEmpty(sort))
        {
            return criteria;
        }
        
        string[] sortComponents = sort.Split(',');
        
        foreach (string component in sortComponents)
        {
            string[] parts = component.Split(':');
            string property = parts[0];
            bool ascending = parts.Length == 1 || parts[1].ToLower() == "asc";
            
            criteria.Add(new SortCriteria
            {
                PropertyName = property,
                Ascending = ascending
            });
        }
        
        return criteria;
    }
}

Pagination

Pagination is essential for APIs that return large collections of resources. Two common approaches are:

Offset-Based Pagination

Uses skip (or offset) and limit (or take) parameters:

# Return the first 10 items
GET /articles?limit=10

# Skip the first 20 items and return the next 10
GET /articles?skip=20&limit=10

Cursor-Based Pagination

Uses a pointer to the last item to determine the next page:

# First page
GET /articles?limit=10

# Get next page (using the last item's ID as a cursor)
GET /articles?after=abc123&limit=10

# Get previous page
GET /articles?before=def456&limit=10

Pagination Metadata

Return pagination metadata to help clients navigate through pages:

{
    "data": [
        // array of resources...
    ],
    "metadata": {
        "totalCount": 738,
        "pageSize": 10,
        "currentPage": 3,
        "totalPages": 74,
        "hasNext": true,
        "hasPrevious": true
    },
    "_links": {
        "self": { "href": "/articles?page=3&pageSize=10" },
        "first": { "href": "/articles?page=1&pageSize=10" },
        "prev": { "href": "/articles?page=2&pageSize=10" },
        "next": { "href": "/articles?page=4&pageSize=10" },
        "last": { "href": "/articles?page=74&pageSize=10" }
    }
}

Implementation in ASP.NET Core

[ApiController]
[Route("api/articles")]
public class ArticlesController : ControllerBase
{
    private readonly IArticleRepository _repository;
    
    public ArticlesController(IArticleRepository repository)
    {
        _repository = repository;
    }
    
    [HttpGet]
    public ActionResult<PagedResponse<ArticleDto>> GetArticles(
        [FromQuery] int page = 1,
        [FromQuery] int pageSize = 10)
    {
        // Apply validation and limits
        pageSize = Math.Min(pageSize, 100); // Maximum page size
        page = Math.Max(1, page); // Minimum page number
        
        // Get paginated results
        PagedResult<ArticleDto> result = _repository.GetPaged(page, pageSize);
        
        // Create response with metadata and HATEOAS links
        PagedResponse<ArticleDto> response = new PagedResponse<ArticleDto>
        {
            Data = result.Items,
            Metadata = new PaginationMetadata
            {
                TotalCount = result.TotalCount,
                PageSize = pageSize,
                CurrentPage = page,
                TotalPages = (int)Math.Ceiling(result.TotalCount / (double)pageSize),
                HasNext = page < (int)Math.Ceiling(result.TotalCount / (double)pageSize),
                HasPrevious = page > 1
            }
        };
        
        // Add HATEOAS links
        response.AddLink("self", Url.Action(nameof(GetArticles), new { page, pageSize }));
        
        if (response.Metadata.HasPrevious)
        {
            response.AddLink("prev", Url.Action(nameof(GetArticles), new { page = page - 1, pageSize }));
        }
        
        if (response.Metadata.HasNext)
        {
            response.AddLink("next", Url.Action(nameof(GetArticles), new { page = page + 1, pageSize }));
        }
        
        response.AddLink("first", Url.Action(nameof(GetArticles), new { page = 1, pageSize }));
        response.AddLink("last", Url.Action(nameof(GetArticles), new { page = response.Metadata.TotalPages, pageSize }));
        
        return Ok(response);
    }
}

Best Practices for Query Parameters

  1. Be Conservative with Defaults: Apply sensible default limits (e.g., 20-50 items per page)
  2. Enforce Maximum Limits: To prevent excessive resource usage
  3. Validate and Sanitize Input: Protect against malformed or malicious query parameters
  4. Document Query Parameters: Clearly document all available parameters
  5. Consistent Naming: Use consistent parameter naming across your API
  6. Consider Response Formats: Include metadata to help clients process results

Security Considerations for URLs

URL design has important security implications that should be carefully considered.

Query Parameters and Sensitive Data

IMPORTANT: Query parameters in URLs are not secure for sensitive data, even with HTTPS:

  1. Exposure in Logs: URLs are commonly stored in server logs, browser history, and proxy logs
  2. Cached by Browsers: URLs might be cached in browser history
  3. Visible in Referer Headers: URLs are sent in Referer headers to third-party sites
  4. Bookmarks and Sharing: URLs can be easily shared or bookmarked

Solution: Never include sensitive data such as:

  • Authentication tokens or credentials
  • Personal identifiable information (PII)
  • Financial information
  • Any confidential business data

Instead, use:

  • HTTP Headers: For API keys, tokens, and other authentication data
  • Request Body: For sensitive data in POST/PUT/PATCH requests
  • HTTP Authorization Header: For credentials and tokens

Implementation Example (ASP.NET Core)

// AVOID THIS - Security risk
[HttpGet("user-data")]
public IActionResult GetUserDataInsecure([FromQuery] string token, [FromQuery] int userId)
{
    // Token in URL is insecure
    return Ok();
}

// DO THIS INSTEAD
[HttpGet("user-data")]
public IActionResult GetUserDataSecure([FromHeader(Name = "Authorization")] string token)
{
    // Token from header is more secure
    // User ID from claims rather than URL parameter
    int userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
    return Ok();
}

API Versioning

API versioning is essential for evolving your API while maintaining backward compatibility. Several strategies exist for versioning REST APIs:

URL Path Versioning

Include the version in the URL path:

/api/v1/articles
/api/v2/articles

Advantages:

  • Explicit and visible
  • Easy to route to different codebases
  • Simple to understand

Disadvantages:

  • Changes the resource URL when version changes
  • Can lead to code duplication

Query Parameter Versioning

Pass the version as a query parameter:

/api/articles?version=1
/api/articles?version=2

Advantages:

  • Preserves the same resource URL
  • Easy to implement

Disadvantages:

  • Less visible in documentation
  • Can be overlooked by developers

Header-Based Versioning

Use a custom header to specify the API version:

GET /api/articles
Accept-Version: 1

GET /api/articles
Accept-Version: 2

Advantages:

  • Keeps URLs clean and consistent
  • Follows HTTP’s content negotiation philosophy

Disadvantages:

  • Less visible, harder to test manually
  • Requires custom header support

Media Type Versioning

Embed version in the Accept header using content type:

GET /api/articles
Accept: application/vnd.company.api.v1+json

GET /api/articles
Accept: application/vnd.company.api.v2+json

Advantages:

  • Leverages existing HTTP content negotiation
  • Sophisticated approach for complex APIs

Disadvantages:

  • More complex to implement
  • Less intuitive for API consumers

ASP.NET Core Implementation

// Program.cs setup for URL path versioning
builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;
    options.ApiVersionReader = new UrlSegmentApiVersionReader();
});

builder.Services.AddVersionedApiExplorer(options =>
{
    options.GroupNameFormat = "'v'VVV";
    options.SubstituteApiVersionInUrl = true;
});

// Controller example
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/articles")]
public class ArticlesV1Controller : ControllerBase
{
    // V1 implementation
}

[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/articles")]
public class ArticlesV2Controller : ControllerBase
{
    // V2 implementation with new features
}

Best Practice for API Versioning

  1. Version from the Start: Even your first API should be versioned
  2. Choose One Strategy: Pick the versioning strategy that best fits your needs and use it consistently
  3. Major Versions Only: Only increment versions for breaking changes
  4. Support Multiple Versions: Plan to support at least one previous version
  5. Document Changes: Clearly document differences between versions
  6. Deprecation Policy: Establish a clear deprecation policy with timelines

URL Design Best Practices Summary

  1. Resource-Oriented: Use nouns to represent resources
  2. Consistent Conventions: Apply consistent naming and formatting
  3. Hierarchical Structure: Reflect resource relationships in URL paths
  4. Proper HTTP Methods: Use appropriate HTTP methods for operations
  5. Query Parameters: Use for filtering, sorting, and pagination
  6. Security-Conscious: Never expose sensitive data in URLs
  7. Version Appropriately: Choose a versioning strategy and apply consistently
  8. Limit Path Depth: Aim for URLs with 2-3 path levels for readability
  9. Use Plural Nouns: Represent collections with plural nouns
  10. Descriptive IDs: Consider using slugs or UUIDs when appropriate

Following these principles will help you create intuitive, consistent, and maintainable REST API URLs that provide a solid foundation for your API design.

Additional Resources