OData
What is OData?
- OData (Open Data Protocol) is an open standard protocol originally developed by Microsoft that defines best practices for building and consuming queryable RESTful APIs.
- Often described as "SQL for the Web," OData adds a powerful querying layer directly over your HTTP endpoints, allowing clients to request exactly the data they need through standard URL parameters
Why Use OData?
- Reduces Custom Endpoint Bloat: Instead of writing separate endpoints like
/api/products/get-all,/api/products/filter-by-category, or/api/products/sort-by-price, you expose a single endpoint/api/productsand let the client handle filtering dynamically. - Minimizes Over-Fetching and Under-Fetching: Clients can use projection parameters to select only specific fields, reducing payload size and network latency.
- Out-of-the-Box Server-Side Execution: OData automatically translates URL query parameters directly into LINQ expressions, which Entity Framework converts into optimized SQL queries executed at the database level.
- Strict Standardization: Unlike vanilla REST APIs where teams reinvent parameter names (e.g.,
?limit=10vs?pageSize=10), OData enforces universal rules for request structures and data payloads.
When to Use
| When to Use | When to Skip |
|---|---|
| Data-heavy dashboard applications requiring flexible sorting, filtering, and paging. | Simple APIs with deterministic, fixed, and unchangeable responses. |
| Building APIs exposed to third-party clients who need custom data extractions. | Internal microservices where payloads are strict, tiny, and performance-critical. |
| When you need GraphQL-like dynamic data reshaping but prefer to stick to standard REST conventions. | High-frequency transactional or command-based actions (DDD patterns, CQRS write sides). |
Core Building Blocks
- Entity Data Model (EDM): The abstract model that defines the types, relationships, and structures of the exposed data.
- Query Options: System keywords added to the URL query string to shape data:
$filter: Filters results based on criteria (e.g.,/Products?$filter=Price gt 50).$select: Projects specific properties (e.g.,/Products?$select=Name,Id).$expand: Eagerly loads navigation/related properties (e.g.,/Products?$expand=Category).$orderby: Sorts data ascending or descending (e.g.,/Products?$orderby=Price desc).$topand$skip: Implements server-side or client-driven pagination.
- Metadata Endpoint (
$metadata): A machine-readable XML document describing the API structure, entities, and actions, allowing clients to auto-generate client code.
How to Implement OData in ASP.NET Core
- Install Package
Install-Package Microsoft.AspNetCore.OData
Install-Package Microsoft.OData.ModelBuilder
- Define Model
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
}
- Build the EDM and Configure Services
using Microsoft.AspNetCore.OData;
using Microsoft.OData.ModelBuilder;
var builder = WebApplication.CreateBuilder(args);
// Create the EDM model
var modelBuilder = new ODataConventionModelBuilder();
modelBuilder.EntitySet<Product>("Products");
// Register OData services
builder.Services.AddControllers().AddOData(options =>
options.Select().Filter().OrderBy().Expand().Count().SetMaxTop(100)
.AddRouteComponents("odata", modelBuilder.GetEdmModel()));
var app = builder.Build();
app.UseAuthorization();
app.MapControllers();
app.Run();
- Create the Controller
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OData.Query;
using Microsoft.AspNetCore.OData.Routing.Controllers;
public class ProductsController : ODataController
{
private readonly AppDbContext _context; // Your Entity Framework Context
public ProductsController(AppDbContext context)
{
_context = context;
}
[EnableQuery] // This enables the runtime OData magic
public IActionResult Get()
{
return Ok(_context.Products); // Returns an IQueryable
}
}
- Consume the Endpoint
Run your application and issue a request using the standardized paths:
Base data: GET
http://localhost:5000/odata/ProductsWith Filters & Sorting: GEThttp://localhost:5000/odata/Products?$filter=Price lt 100&$orderby=Name asc
Actions & Functions
In OData, Actions and Functions allow you to extend your API beyond standard CRUD operations (Create, Read, Update, Delete) to handle custom business logic or calculations directly on the server.
While standard OData queries are great for grabbing or filtering raw table rows, Actions and Functions let you execute complex behaviors or computational processes directly over your URL endpoints.
Key Differences
| Feature | Functions 🧮 | Actions ⚡ |
|---|---|---|
| Primary Intent | Read data, calculate, or retrieve information. | Mutate state, execute complex logic, trigger workflows. |
| Side Effects | Strictly Side-Effect Free (Cannot modify data). | Allowed to have side effects (Creates, updates, deletes). |
| HTTP Verb | Must use GET |
Must use POST |
| Parameters | Passed via the URL query string or route path. | Passed inside the JSON Request Body. |
| Can Be Composed? | Yes (You can append $filter or $select to the output). |
No (It returns a static final output or status). |
Types of Placement: Bound vs. Unbound
Both Actions and Functions can be registered in two distinct ways depending on your architecture:
- Bound: Attached directly to an existing Entity or Collection.
- Example: Releasing a specific invoice.
/odata/Invoices(5)/Release
- Example: Releasing a specific invoice.
- Unbound (Custom Routing): Operates globally as a standalone endpoint. It is not attached to a specific entity.
- Example: Getting the current tax rate for a state.
/odata/GetTaxRate(State='NY')
- Example: Getting the current tax rate for a state.
Implementation
- Update program.cs
var modelBuilder = new ODataConventionModelBuilder();
var productsEntitySet = modelBuilder.EntitySet<Product>("Products");
// 1. Define a BOUND FUNCTION (Returns a calculated value, does not change database)
// Access path: GET /odata/Products(1)/CalculateDiscount(percentage=15)
productsEntitySet.EntityType
.Function("CalculateDiscount")
.Returns<decimal>()
.Parameter<int>("percentage");
// 2. Define a BOUND ACTION (Performs an update/side-effect)
// Access path: POST /odata/Products(1)/Discontinue
productsEntitySet.EntityType
.Action("Discontinue")
.Returns<bool>(); // Returns true/false indicating success
- Update Controller
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OData.Routing.Controllers;
using Microsoft.AspNetCore.OData.Formatter;
public class ProductsController : ODataController
{
private readonly AppDbContext _context;
public ProductsController(AppDbContext context) => _context = context;
// Implementation of the BOUND FUNCTION
[HttpGet("odata/Products({key})/CalculateDiscount(percentage={percentage})")]
public IActionResult CalculateDiscount([FromODataUri] int key, [FromODataUri] int percentage)
{
var product = _context.Products.Find(key);
if (product == null) return NotFound();
decimal discountAmount = product.Price * (percentage / 100m);
return Ok(discountAmount);
}
// Implementation of the BOUND ACTION
[HttpPost("odata/Products({key})/Discontinue")]
public IActionResult Discontinue([FromODataUri] int key)
{
var product = _context.Products.Find(key);
if (product == null) return NotFound();
// Business Logic: Trigger database mutation side-effect
product.IsActive = false;
_context.SaveChanges();
return Ok(true);
}
}