Recently, my team wanted one endpoint that could return an entity together with its related data. Our standard approach was a separate endpoint per resource, but the extra HTTP round trips added up on the client: one call for the entity, then one more per related resource, an N+1 pattern over the network. We took inspiration from how Stripe and Atlassian handle this in their APIs and built our own resource expansion: an ?expand= query parameter that embeds related data in the response.
What is resource expansion?
With resource expansion, the client names the related data it needs, and the server embeds exactly that data in the main response.
Say part of the application you are building shows a gym membership. A request for the membership returns its own fields:
GET /api/memberships/1001
{
"id": 1001,
"number": "MEM-1001",
"plan": "Premium",
"status": "active",
"startsOn": "2026-01-01",
"endsOn": "2026-12-31",
"monthlyFeeCents": 4900,
"memberId": 1,
"member": null,
"visits": null
}
That is enough for a membership card. The front-desk screen needs more: the member’s name and email, and the clubs where they checked in most recently. With separate endpoints, the client fetches the membership, reads memberId, calls /api/members/1, then calls /api/memberships/1001/visits, and finally /api/clubs/{id} for each club. It then stitches the responses into one view model.
With expansion, the client names the relationships it wants in a query parameter:
client API
| GET /api/memberships/1001 |
|--------------------------------------->| membership only
| |
| GET /api/memberships/1001 |
| ?expand=member,visits.club |
|--------------------------------------->| membership + member
|<---------------------------------------| + recent visits, each with its club
GET /api/memberships/1001?expand=member,visits.club&relatedLimit=3
{
"id": 1001,
"number": "MEM-1001",
"plan": "Premium",
"status": "active",
"startsOn": "2026-01-01",
"endsOn": "2026-12-31",
"monthlyFeeCents": 4900,
"memberId": 1,
"member": { "id": 1, "name": "Alice Morgan", "email": "[email protected]" },
"visits": {
"data": [
{ "id": 3, "checkedInAt": "2026-09-27T06:45:00+00:00", "clubId": 1,
"club": { "id": 1, "name": "Downtown", "city": "Seattle" } },
{ "id": 2, "checkedInAt": "2026-09-24T18:15:00+00:00", "clubId": 2,
"club": { "id": 2, "name": "Riverside", "city": "Bellevue" } },
{ "id": 1, "checkedInAt": "2026-09-20T07:30:00+00:00", "clubId": 1,
"club": { "id": 1, "name": "Downtown", "city": "Seattle" } }
],
"limit": 3,
"hasMore": false
}
}
One HTTP request now returns the membership, the member, and three visits with their clubs.
Fewer HTTP requests don’t guarantee fewer database queries. That depends on how the backend loads the data.
Implementing resource expansion in ASP.NET Core
First, the foundation: entities, request and response, and the endpoint.
Entities
public sealed class Membership
{
public int Id { get; set; }
public required string Number { get; set; }
public required string Plan { get; set; }
public required string Status { get; set; }
public DateOnly StartsOn { get; set; }
public DateOnly EndsOn { get; set; }
public long MonthlyFeeCents { get; set; }
public int MemberId { get; set; }
public Member Member { get; set; } = null!;
public List<Visit> Visits { get; set; } = [];
}
public sealed class Member
{
public int Id { get; set; }
public required string Name { get; set; }
public required string Email { get; set; }
}
public sealed class Visit
{
public int Id { get; set; }
public int MembershipId { get; set; }
public DateTimeOffset CheckedInAt { get; set; }
public int ClubId { get; set; }
public Club Club { get; set; } = null!;
}
public sealed class Club
{
public int Id { get; set; }
public required string Name { get; set; }
public required string City { get; set; }
}
A member can have multiple memberships, a membership can have multiple visits (check-ins), and each visit belongs to one club. Fees are stored as integer cents.
Request
The route and the query string bind to one record through [AsParameters]. Id comes from the route; Expand and RelatedLimit come from the query string:
public abstract record ExpandableRequest(string? Expand, int? RelatedLimit);
public sealed record GetMembershipRequest(int Id, string? Expand, int? RelatedLimit)
: ExpandableRequest(Expand, RelatedLimit);
Expand holds the comma-separated relationships the client wants. RelatedLimit caps how many visits come back; more on that in the limits section. Both come from the ExpandableRequest base record, so the shared expansion code can read them from any resource’s request, and each resource adds only its own route values.
Response
// Unrequested relationships are null. Requested but empty visits are { "data": [] }.
public sealed record MembershipResponse(
int Id, string Number, string Plan, string Status, DateOnly StartsOn, DateOnly EndsOn,
long MonthlyFeeCents, int MemberId,
MemberResponse? Member,
Related<VisitResponse>? Visits);
public sealed record MemberResponse(int Id, string Name, string Email);
public sealed record VisitResponse(int Id, DateTimeOffset CheckedInAt, int ClubId, ClubResponse? Club);
public sealed record ClubResponse(int Id, string Name, string City);
Member and Visits are nullable, and the API fills them only when the client requests the corresponding relationship. The same applies one level down: each visit’s Club stays null unless the client asks for visits.club.
Endpoint
The endpoint lives in its own feature folder, as in a vertical slice architecture, and maps GET /api/memberships/{id} to a handler:
public static void MapGetMembership(this IEndpointRouteBuilder app) =>
app.MapGet("/api/memberships/{id:int}", HandleAsync);
The handler does three things: validate, load, and map.
Parse and validate the expand parameter using an allowlist
The endpoint supports three expansion paths:
GET /api/memberships/1001?expand=member
GET /api/memberships/1001?expand=visits
GET /api/memberships/1001?expand=visits.club
Clients can combine them, for example ?expand=member,visits.club. visits.club is a nested path: it loads the visits and the club of each visit.
Since expand is a query parameter, clients can send any value. You need an allowlist of supported relationships and input validation. Each allowed path maps to a typed EF Core Include:
public static class MembershipExpansions
{
public static readonly ExpansionRules<Membership> Rules = new ExpansionRules<Membership>()
.Allow("member", (query, _) => query.Include(membership => membership.Member))
.Allow("visits", IncludeVisits)
.Allow("visits.club", (query, take) => IncludeVisits(query, take).ThenInclude(visit => visit.Club));
// Most recent visits first; Id breaks ties so the preview is stable.
private static IIncludableQueryable<Membership, IEnumerable<Visit>> IncludeVisits(IQueryable<Membership> query, int take) =>
query.Include(membership => membership.Visits
.OrderByDescending(visit => visit.CheckedInAt).ThenByDescending(visit => visit.Id).Take(take));
}
The code only compares the client’s string with these paths. It never reaches EF Core’s string-based Include("..."), so a client cannot load a navigation you did not choose to expose.
Handling the input takes two steps:
- Parsing and normalization. Split the value into relationship names, trim whitespace, ignore empty entries, lowercase each name, and remove duplicates.
- Validation. Check every parsed name against the allowlist.
Parsing is one helper on ExpandPlan:
public static HashSet<string> SplitPaths(string? expand) => (expand ?? "")
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Select(path => path.ToLowerInvariant())
.ToHashSet();
So ?expand= MEMBER ,visits.club,member means the same as ?expand=member,visits.club.
I used FluentValidation for the validation step:
public sealed class ExpandRequestValidator<TRequest> : AbstractValidator<TRequest>
where TRequest : ExpandableRequest
{
public ExpandRequestValidator(IReadOnlyList<string> allowed)
{
// Report only the first failing rule for each parameter.
RuleLevelCascadeMode = CascadeMode.Stop;
RuleFor(request => request.Expand)
.Must(expand => TooDeep(expand).Length == 0)
.WithMessage(request => $"Expansion depth cannot exceed {ExpandPlan.MaxDepth}: " +
$"{string.Join(", ", TooDeep(request.Expand))}.")
.Must(expand => Unsupported(expand, allowed).Length == 0)
.WithMessage(request => $"Unsupported expansion: {string.Join(", ", Unsupported(request.Expand, allowed))}. " +
$"Supported: {string.Join(", ", allowed)}.")
.OverridePropertyName("expand");
RuleFor(request => request.RelatedLimit)
.InclusiveBetween(1, ExpandPlan.MaxRelatedLimit)
.WithMessage($"relatedLimit must be between 1 and {ExpandPlan.MaxRelatedLimit}.")
.OverridePropertyName("relatedLimit");
}
private static string[] TooDeep(string? expand) => ExpandPlan.SplitPaths(expand)
.Where(path => path.Split('.').Length > ExpandPlan.MaxDepth)
.ToArray();
private static string[] Unsupported(string? expand, IReadOnlyList<string> allowed) => ExpandPlan.SplitPaths(expand)
.Except(allowed)
.ToArray();
}
In this validator:
CascadeMode.Stopreports only the first failing rule per parameter, so a path that is too deep gets the depth error and not a second “unsupported” error.- The rules for
expandandrelatedLimitrun independently, so the client sees every problem in one response. OverridePropertyNamekeeps the error keys equal to the query parameter names (expand, notExpand).InclusiveBetweenskipsnull, so an omittedrelatedLimitfalls back to the default.
Register the validator once per resource, with that resource’s allowlist:
builder.Services.AddSingleton<IValidator<GetMembershipRequest>>(
new ExpandRequestValidator<GetMembershipRequest>(MembershipExpansions.Rules.Allowed));
Once the request passes validation, it becomes an ExpandPlan: the requested paths plus every implied parent, so visits.club also selects visits. This step needs no error handling, because the validator has already accepted the input:
public static ExpandPlan From(ExpandableRequest request)
{
var paths = new HashSet<string>();
foreach (var path in SplitPaths(request.Expand))
{
var segments = path.Split('.');
for (var length = 1; length <= segments.Length; length++)
paths.Add(string.Join('.', segments[..length]));
}
return new ExpandPlan(paths, request.RelatedLimit ?? DefaultRelatedLimit);
}
A missing or blank expand produces an empty plan and the baseline response.
Load the requested relationships with EF Core
A valid plan builds the query. The generic ExpansionRules does this for any entity:
public IQueryable<TEntity> Apply(IQueryable<TEntity> query, ExpandPlan plan)
{
// Fetch one extra related row so the response can report hasMore without a COUNT query.
var take = plan.RelatedLimit + 1;
foreach (var (path, include) in rules)
{
// Skip a parent when a selected nested path's include already loads it.
if (plan.Has(path) && !plan.Paths.Any(other => other.StartsWith(path + '.')))
query = include(query, take);
}
return query;
}
A WithExpansions extension method wraps Apply, so the endpoint uses it like any other LINQ operator:
var membership = await db.Memberships
.AsNoTracking()
.Where(membership => membership.Id == request.Id)
.WithExpansions(MembershipExpansions.Rules, plan)
.SingleOrDefaultAsync(cancellationToken);
What each expansion does to the query:
memberadds a join throughInclude(membership => membership.Member).visitsuses a filtered include:OrderByDescending(...).Take(take)makes PostgreSQL return only the most recent visits. TheIdtie-breaker keeps the preview stable when two check-ins share a timestamp.visits.clubaddsThenInclude(visit => visit.Club), which loads each visit’s club in the same query. There is no extra query per club. Because this include already loads the visits,Applyskips the parent’s own include, and?expand=visits,visits.clubincludes the visits once.
Every combination runs as a single SELECT. If you later add a second collection, such as payments, add AsSplitQuery() so the two collections do not multiply each other’s rows. The EF Core docs explain the trade-off in single vs. split queries.
Adding a new expansion takes one Allow call and a field in the response. The parser, validator, and loading code stay the same.
Build the response and represent unrequested relationships
Finally, the endpoint maps the loaded entity to the response:
public static MembershipResponse ToResponse(this Membership membership, ExpandPlan plan) => new(
membership.Id, membership.Number, membership.Plan, membership.Status,
membership.StartsOn, membership.EndsOn, membership.MonthlyFeeCents, membership.MemberId,
Member: plan.Has("member")
? new MemberResponse(membership.Member.Id, membership.Member.Name, membership.Member.Email)
: null,
Visits: plan.Has("visits")
? Related.From(membership.Visits, plan.RelatedLimit, visit => new VisitResponse(
visit.Id, visit.CheckedInAt, visit.ClubId,
Club: plan.Has("visits.club") ? new ClubResponse(visit.Club.Id, visit.Club.Name, visit.Club.City) : null))
: null);
Collections go through a generic wrapper. The query loaded up to limit + 1 rows; the extra row only signals hasMore:
public sealed record Related<T>(IReadOnlyList<T> Data, int Limit, bool HasMore);
public static class Related
{
public static Related<TResult> From<TSource, TResult>(IReadOnlyCollection<TSource> rows, int limit,
Func<TSource, TResult> map) => new(
Data: rows.Take(limit).Select(map).ToList(),
Limit: limit,
HasMore: rows.Count > limit);
}
In this response contract, a relationship has three states:
| State | JSON |
|---|---|
| Not requested | "visits": null |
| Requested, no items | "visits": { "data": [], "limit": 2, "hasMore": false } |
| Requested, more rows exist | "visits": { "data": [ ... ], "limit": 2, "hasMore": true } |
Handle unsupported expansions and enforce authorization
The handler puts the pieces together. It validates before it touches the database, so an invalid request never runs a query:
private static async Task<IResult> HandleAsync([AsParameters] GetMembershipRequest request,
IValidator<GetMembershipRequest> validator, MembershipsDbContext db, CancellationToken cancellationToken)
{
// 1. Validate the requested expansions against the allowlist.
var validation = validator.Validate(request);
if (!validation.IsValid)
return Results.ValidationProblem(validation.ToDictionary());
var plan = ExpandPlan.From(request);
// 2. Load only the requested relationships.
var membership = await db.Memberships
.AsNoTracking()
.Where(membership => membership.Id == request.Id)
.WithExpansions(MembershipExpansions.Rules, plan)
.SingleOrDefaultAsync(cancellationToken);
// 3. Map to the response.
return membership is null
? Results.Problem(statusCode: 404, title: "Membership not found")
: Results.Ok(membership.ToResponse(plan));
}
An unknown path fails the allowlist rule. validation.ToDictionary() groups the failures by parameter name, and Results.ValidationProblem turns them into a standard Problem Details response (more on that in Problem Details in .NET):
GET /api/memberships/1001?expand=payments
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"expand": [
"Unsupported expansion: payments. Supported: member, visits, visits.club."
]
}
}
The error lists the supported paths, so the client can fix the request without reading the docs.
Authorization. An expansion must not bypass authorization. If GET /api/payments is restricted to billing staff, so is ?expand=payments. In this sample, everything expandable is visible to anyone who can read the membership, so the handler needs no extra check. For a protected relationship, check its policy after validation and before the query. If the check fails, return 403 for the whole request rather than dropping the field, which the client would read as “no data”.
Limit expansion depth and related collection sizes
Without limits, a single request can turn into a deep, unbounded join. The limits are constants on ExpandPlan, shared by every resource:
public const int DefaultRelatedLimit = 2;
public const int MaxRelatedLimit = 50;
// There is no universal depth limit. Choose the smallest depth your clients need.
public const int MaxDepth = 2;
For reference, ASP.NET Core OData’s MaxExpansionDepth defaults to 2, and Stripe caps expansions at four levels.
The validator enforces them with the depth rule on expand and the range rule on relatedLimit:
| Control | Value | Why |
|---|---|---|
| Allowlist | 3 paths | Only relationships you chose to expose |
| Max depth | 2 segments | visits.club is allowed; visits.club.address gets a clear error |
Default relatedLimit | 2 | Small by default |
Max relatedLimit | 50 | Caps the rows loaded and serialized |
| Fetch size | limit + 1 | Tells the client whether more rows exist, without a COUNT |
The allowlist already rejects paths deeper than visits.club. The explicit depth check gives a clearer error, and it still protects the API if someone adds a deeper path later:
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"expand": [ "Expansion depth cannot exceed 2: visits.club.address." ]
}
}
An out-of-range value such as 51 fails validation with "relatedLimit must be between 1 and 50.". A non-integer such as abc never reaches the validator: RelatedLimit is an int?, so binding rejects it with 400.
These limits give bounded previews. When a client needs the full visit history, give it a paginated endpoint such as GET /api/memberships/{id}/visits?cursor=....
The complete implementation in action
Membership 1001 has three visits; 1002 has none. Each request returns:
| Request | Result |
|---|---|
/api/memberships/1001 | Membership fields only; member and visits are null |
/api/memberships/1001?expand=member | Member object; visits is null |
/api/memberships/1001?expand=visits | Two most recent visits, club is null, hasMore: true |
/api/memberships/1001?expand=member,visits.club&relatedLimit=3 | Member and all three visits with clubs |
/api/memberships/1001?expand=visits&relatedLimit=1 | One visit, hasMore: true |
/api/memberships/1002?expand=visits | "data": [], hasMore: false |
/api/memberships/1001?expand=payments | 400, lists supported paths |
/api/memberships/1001?expand=visits.club.address | 400, depth exceeded |
/api/memberships/1001?expand=visits&relatedLimit=51 | 400, limit out of range |
/api/memberships/9999 | 404 Problem Details |
For example, expanding only visits returns the two most recent check-ins without their clubs:
GET /api/memberships/1001?expand=visits
{
"id": 1001,
"number": "MEM-1001",
"plan": "Premium",
"status": "active",
"startsOn": "2026-01-01",
"endsOn": "2026-12-31",
"monthlyFeeCents": 4900,
"memberId": 1,
"member": null,
"visits": {
"data": [
{ "id": 3, "checkedInAt": "2026-09-27T06:45:00+00:00", "clubId": 1, "club": null },
{ "id": 2, "checkedInAt": "2026-09-24T18:15:00+00:00", "clubId": 2, "club": null }
],
"limit": 2,
"hasMore": true
}
}
Performance: expanded requests vs. separate requests
Expansion reduces HTTP calls. To see whether it also cuts latency, I benchmarked two ways of fetching that return the same data: one membership, its member, and the latest N visits with the club of each visit.
Separate requests:
GET /api/memberships/1
GET /api/members/{memberId}
GET /api/memberships/1/visits?limit=N
GET /api/clubs/{clubId} (once per distinct club)
Expansion:
GET /api/memberships/1?expand=member,visits.club&relatedLimit=N
The separate-request client ran in two variants. SeparateParallel fetches the membership, then the member and visits in parallel, then all clubs in parallel, which makes three dependent round trips. SeparateSequential awaits each request before sending the next, as a simple client would. An integration test verifies that both paths return the same data.
Test setup
The tests ran with BenchmarkDotNet 0.15.8 on my laptop: 13th Gen Intel Core i7-13620H (10 cores, 16 logical), Windows 11, .NET SDK 10.0.103.
The API (ASP.NET Core 10, EF Core with Npgsql 10.0.0) and PostgreSQL 17 run in Docker Desktop on the same laptop, with no container resource limits. Toxiproxy sits in front of both to simulate network latency:
- API to database: 1 ms per response, like a database in the same data center.
- Client to API: 0 ms (loopback) or 20 ms (a nearby region).
The benchmark client runs on the host and uses one shared HttpClient with HTTP/1.1 keep-alive and no response compression. Neither approach uses caching. The dataset has one membership, one member, 20 clubs, and 1,000 visits assigned to clubs round-robin, so a preview of 1, 10, or 50 visits references 1, 10, or 20 distinct clubs.
What is measured?
For separate requests, I measured the time from sending the first request until the client had received and parsed every response body. For expansion, I measured the time until the client had received and parsed the expanded response. Before collecting results, BenchmarkDotNet ran 3 warm-up iterations, followed by 15 measured iterations per scenario. The benchmark also warmed up all clients for 20 seconds before the first case, so JIT compilation and connection setup do not skew the numbers.
The comparison covers:
- Mean and P95 latency: the average end-to-end time, and the time within which 95% of operations complete.
- Response size: response body bytes per operation, summed across separate requests.
- Database query count: SQL
SELECTstatements executed per operation, read frompg_stat_statements.
Results
With 20 ms of client latency:
Visits (N) | Approach | Requests | SQL queries | Mean | P95 | Response bytes |
|---|---|---|---|---|---|---|
| 1 | Expanded | 1 | 1 | 34.5 ms | 41.9 ms | 372 |
| 1 | SeparateParallel | 4 | 4 | 73.9 ms | 82.2 ms | 384 |
| 1 | SeparateSequential | 4 | 4 | 103.8 ms | 124.4 ms | 384 |
| 10 | Expanded | 1 | 1 | 24.2 ms | 24.5 ms | 1,417 |
| 10 | SeparateParallel | 13 | 13 | 76.7 ms | 81.1 ms | 1,465 |
| 10 | SeparateSequential | 13 | 13 | 306.0 ms | 314.7 ms | 1,465 |
| 50 | Expanded | 1 | 1 | 24.8 ms | 25.4 ms | 6,003 |
| 50 | SeparateParallel | 23 | 23 | 81.9 ms | 91.2 ms | 4,941 |
| 50 | SeparateSequential | 23 | 23 | 556.1 ms | 587.3 ms | 4,941 |
Latency. An expanded request costs one round trip, about 24 ms. SeparateParallel costs three (74 to 82 ms, 3x slower) and SeparateSequential one per request (104 to 556 ms, up to 22x slower). Without added latency the gaps shrink but remain: 3.4 to 3.8 ms for Expanded, against 10 to 16 ms for SeparateParallel and 12 to 61 ms for SeparateSequential. The 1-visit expanded result (34.5 ms) is an outlier: its first iterations took 24 ms like the others, then a noisy stretch on the shared laptop pushed some to 31 to 42 ms, and BenchmarkDotNet flagged the run as possibly multimodal.
Database. The expanded request always ran one SQL query, thanks to the filtered include. The separate clients ran one query per request, parallel or not. Running requests in parallel shortens the wait, but the database does the same work.
Size. For 1 and 10 visits, the responses are about the same size. At 50 visits, the expanded response is 21% larger: the 50 visits share 20 clubs, and every visit embeds its club, while the separate client downloads each club once.
Verdict. Expansion is convenient, but whether it is faster depends on latency, the queries behind it, and how much data it returns. In this benchmark, the number of round trips, each paying the network latency, outweighed query count and response size.
Expansion can also add overhead:
- Duplicated nested objects, as the 50-visit case shows. A de-duplicated
includedsection, as in JSON:API compound documents, avoids it. - Wider SQL rows. Joins repeat the parent’s columns on every child row. Harmless here, but costly with wide parents or several joined collections.
- Over-fetching. A client that already caches the clubs re-downloads them with every expanded response.
- HTTP caching.
/api/clubs/{id}is a stable, cacheable resource. An expanded response mixes stable and volatile data, so caches must invalidate it whenever any part changes.
When should you use resource expansion?
Use it when clients need related data together on most requests, especially over a slow network. When they cache that data or need full, paginated lists, separate endpoints serve them better. The trade-offs:
| Advantages | Disadvantages |
|---|---|
| Fewer HTTP round trips to retrieve related resources | Expanded responses can become large and expensive to generate |
| Clients choose which supported relationships to include | Each expansion combination needs validation and testing |
| Less client-side work combining separate responses | Database queries grow more complex as you add relationships |
| One endpoint serves different data needs | More response variations to document and maintain |
Bound every expansion with an allowlist, a depth limit, and a collection limit. Then benchmark it with your own latency and data before you assume one request beats several.