Zero to a production foundation in an afternoon: Aspire, HotChocolate, EF Core & Relay
A step-by-step bootstrap of a modern .NET + React stack — Aspire orchestration, a layered HotChocolate GraphQL API over EF Core/Postgres with GreenDonut.Data, and a YARP-fronted Vite/Relay frontend — that gets you to a paginated, filterable Wheel of Time library which is genuinely production-shaped. Then the payoff: an MCP server and REST API you didn't have to write.
This is my first technical post, and I want to start it the way I’d start a project: with a strong opinion about where to begin.
What follows is the approach I believe is the quickest path to a production-ready architecture available today. Not the flashiest and not the most novel — the quickest path to something I’d actually be comfortable putting real users on. Three things earn it that claim:
- Consistent, repeatable environments from local through production. The same orchestration that spins up your laptop is what describes production. “Works on my machine” stops being a sentence anyone has to say.
- Observability that configures itself. OpenTelemetry traces, metrics, and logs are wired across every service automatically — you get a dashboard for the entire request path before you’ve written a single line of instrumentation.
- A GraphQL-centric approach that makes you think about the domain first. You model your domain once, in a schema, and every client you build from it — web, mobile, an AI agent, a partner’s REST integration — inherits that single, well-considered shape for free.
If I’m bootstrapping a team, this is where I start. Here’s why.
Most of the time it takes to start a new product isn’t spent on the product. It’s spent on the scaffolding nobody demos: wiring a database, standing up an API, deciding how the frontend talks to the backend, getting local orchestration sane, and — increasingly — making the whole thing legible to AI agents and to the inevitable team that wants REST.
Here’s the thing I keep coming back to in 2026: that scaffolding is now nearly free. Not “cheap.” Free. You can stand up a stack that is genuinely production-shaped — layered, observable, paged, filtered, agent-ready — in about the time it takes to drink a coffee. The little library at the end isn’t a toy; it’s a foundation you could grow into almost any product.
Let me show you the shortest path I know.
What we’re building
The gateway is the trick that makes the frontend story painless: YARP gives us a single origin.
The browser loads the React app and calls /graphql from the same host, so Relay needs zero CORS
config and zero “what’s my API URL in this environment” gymnastics. Aspire wires the ports between all
of these for us.
The backend is deliberately layered — Domain, Data, and GraphQL stay separate — because the whole point is that this scales past a toy example.
📦 Companion repo: every snippet below is lifted from a complete, end-to-end-verified project at github.com/VanirTechDev/hellostack — clone it, run
dotnet run --project AppHost, and watch a real browser render the result. If you just want working code, start there.
This walkthrough is verified against .NET 10, Aspire 13.4, HotChocolate 16.3, GreenDonut.Data 16.3, Relay 21, and Vite 8. Exact versions matter here — a few of the APIs below are version-specific (the repo pins them all), and I’ll flag the ones that bite.
Prerequisites
The .NET 10 SDK, Node 20+, and a container runtime (Docker or Podman) for Postgres. That’s it — Aspire pulls the Postgres image for you.
Step 1 — The Aspire AppHost
Aspire is the orchestrator. One project describes every resource — database, API, frontend, proxy — and wires their connection strings and ports together.
dotnet new aspire-apphost -o AppHost
dotnet new aspire-servicedefaults -o ServiceDefaults # OpenTelemetry / health / resilience
dotnet new sln -n HelloStack
dotnet sln add AppHost ServiceDefaults
We’ll fill in AppHost/AppHost.cs once the other projects exist. Hold that thought.
Step 2 — A layered API
Create the API and split it into three concerns. Even at this size, the separation pays for itself the first time the project grows.
dotnet new web -o Api
dotnet sln add Api
cd Api
dotnet add reference ../ServiceDefaults
# Aspire's Postgres + EF Core client integration
dotnet add package Aspire.Npgsql.EntityFrameworkCore.PostgreSQL
# GraphQL + data integrations
dotnet add package HotChocolate.AspNetCore
dotnet add package HotChocolate.AspNetCore.CommandLine # for `dotnet run -- schema export`
dotnet add package HotChocolate.Types.Analyzers # source generator for [QueryType]
dotnet add package HotChocolate.Data.EntityFramework
dotnet add package GreenDonut.Data.EntityFramework
Domain — a single entity. This is the layer that knows nothing about GraphQL or EF. We’ll model a library of The Wheel of Time so there’s real data to page, filter, and sort.
// Domain/Book.cs
namespace Api.Domain;
public sealed class Book
{
public int Id { get; set; }
public required string Title { get; set; }
public required string Author { get; set; }
public int SequenceNumber { get; set; } // 0 for the prequel, New Spring
public int PublicationYear { get; set; }
public int Pages { get; set; }
}
Data — the EF Core context plus a repository that uses GreenDonut.Data for paging, sorting, filtering, and projections in one shot.
// Data/AppDbContext.cs
using Api.Domain;
using Microsoft.EntityFrameworkCore;
namespace Api.Data;
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Book> Books => Set<Book>();
}
// Data/BookRepository.cs
using Api.Domain;
using GreenDonut.Data;
using Microsoft.EntityFrameworkCore;
namespace Api.Data;
public sealed class BookRepository(AppDbContext db)
{
// Paged/filtered/sorted list for the `books` connection.
public async Task<Page<Book>> GetBooksAsync(
PagingArguments pagingArgs,
QueryContext<Book> query,
CancellationToken ct = default)
=> await db.Books
// .With() applies the client's filter, sort, and projection. The default-sort
// configurator guarantees cursor pagination always has a key: fall back to the
// series sequence when the client didn't sort, and always append Id as a unique
// tiebreaker so cursors are stable. (Without a key, ToPageAsync throws.)
.With(query, sort => sort
.IfEmpty(o => o.AddAscending(b => b.SequenceNumber))
.AddAscending(b => b.Id))
.ToPageAsync(pagingArgs, ct);
// Single-book lookup used by the Relay node resolver (below).
public async Task<Book?> GetByIdAsync(int id, CancellationToken ct = default)
=> await db.Books.FirstOrDefaultAsync(b => b.Id == id, ct);
}
That QueryContext<Book> is the quietly brilliant part. HotChocolate hands the repository a bundle
of just the filter, sort, and projection the client requested, the repository applies it to an
IQueryable, and ToPageAsync runs an efficient keyset-paginated query. No over-fetching, no N+1, no
middleware-ordering surprises — and because it’s all DataLoader-backed, it batches cleanly when these
live inside larger graphs.
GraphQL — the resolver layer. It’s thin on purpose: it injects the page request and the query context, delegates to the repository, and returns a Relay connection.
// GraphQL/Query.cs
using Api.Data;
using Api.Domain;
using GreenDonut.Data;
using HotChocolate.Types.Pagination;
namespace Api.GraphQL;
[QueryType]
public static class Query
{
[UsePaging(IncludeTotalCount = true)]
[UseFiltering]
[UseSorting]
public static async Task<Connection<Book>> GetBooks(
PagingArguments pagingArgs,
QueryContext<Book> query,
BookRepository repository,
CancellationToken ct)
{
var page = await repository.GetBooksAsync(pagingArgs, query, ct);
return page.ToConnection();
}
}
That gives you a books field that’s paged (with a totalCount), filterable, and sortable on every
column. But Relay wants more than a list — it wants nodes with globally-unique IDs it can cache and
refetch. HotChocolate gives you that with one attribute and one builder call (next):
// GraphQL/BookNode.cs
using Api.Data;
using Api.Domain;
namespace Api.GraphQL;
// Makes Book a Relay Node: its `id` becomes a globally-unique, opaque ID, and the schema gets
// `node(id:)` / `nodes(ids:)` root fields. The [NodeResolver] decodes that global id back to an
// integer key and refetches the book.
[ObjectType<Book>]
public static partial class BookNode
{
[NodeResolver]
public static async Task<Book?> GetBookByIdAsync(
int id, BookRepository repository, CancellationToken ct)
=> await repository.GetByIdAsync(id, ct);
}
Now wire it all up in Api/Program.cs. Notice how little there is:
using Api.Data;
using Api.Domain;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Aspire service defaults: OpenTelemetry, health checks, resilience, service discovery.
builder.AddServiceDefaults();
// Aspire injects the connection string named "appdb".
builder.AddNpgsqlDbContext<AppDbContext>("appdb");
builder.Services.AddScoped<BookRepository>();
builder.Services
.AddGraphQLServer()
.AddGlobalObjectIdentification() // Relay node interface + node(id:) / nodes(ids:) fields
.AddApiTypes() // source-generated from [QueryType] / [ObjectType<Book>]
.AddPagingArguments() // bind first/after/... into the injected PagingArguments
.AddFiltering()
.AddSorting()
// HotChocolate 16 enables cost analysis with a default max field cost of 1000; a paged
// connection plus filtering and sorting exceeds it, so raise the ceiling.
.ModifyCostOptions(o => o.MaxFieldCost = 5000);
var app = builder.Build();
app.MapDefaultEndpoints();
// Least-steps local DB: create + seed the Wheel of Time series once.
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
if (!await db.Books.AnyAsync())
{
db.Books.AddRange(
new Book { Title = "New Spring", Author = "Robert Jordan", SequenceNumber = 0, PublicationYear = 2004, Pages = 334 },
new Book { Title = "The Eye of the World", Author = "Robert Jordan", SequenceNumber = 1, PublicationYear = 1990, Pages = 814 },
// … books 2–13 (see the companion repo for the full 15-book seed) …
new Book { Title = "A Memory of Light", Author = "Brandon Sanderson", SequenceNumber = 14, PublicationYear = 2013, Pages = 909 });
await db.SaveChangesAsync();
}
}
app.MapGraphQL();
// Enables `dotnet run -- schema export`; serves the app normally otherwise.
await app.RunWithGraphQLCommandsAsync(args);
That’s a complete, layered, paginated GraphQL API. Roughly 40 lines of “your” code.
Two version-specific gotchas worth calling out: the schema types are registered with
AddApiTypes()— HotChocolate’s source generator names that method after your project, so anApiproject getsAddApiTypes(), notAddTypes(). AndAddPagingArguments()is what makes HotChocolate injectPagingArgumentsinto your resolver; without it,pagingArgsleaks out as a required GraphQL argument.
Step 3 — Tell Aspire about Postgres and the API
Back in AppHost/AppHost.cs, add Postgres and reference it from the API:
cd ../AppHost
dotnet add package Aspire.Hosting.PostgreSQL
var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres")
.WithDataVolume(); // data survives restarts
var appdb = postgres.AddDatabase("appdb");
var api = builder.AddProject<Projects.Api>("api")
.WithReference(appdb)
.WaitFor(appdb);
builder.Build().Run();
dotnet run from AppHost right now would already give you a running Postgres, a running API, and the
Aspire dashboard with logs, traces, and metrics for both. We’re not done, but pause to appreciate that
you have full observability and a real database without having touched a YAML file.
Step 4 — A Vite + React + Relay frontend
cd ..
npm create vite@latest web -- --template react-ts
cd web
npm install
npm install react-relay relay-runtime
# Vite 8 ships @vitejs/plugin-react v6, which transforms with oxc and dropped its `babel`
# option — so Relay's tags compile through a dedicated Babel pass, not vite-plugin-relay.
npm install -D relay-compiler babel-plugin-relay @rolldown/plugin-babel @babel/core @babel/preset-typescript graphql
Export the schema from the API so Relay’s compiler can type-check your queries (HotChocolate ships a schema-export command):
cd ../Api
dotnet run -- schema export --output ../web/schema.graphql # works via RunWithGraphQLCommandsAsync
cd ../web
Configure Relay (web/relay.config.json):
{
"src": "./src",
"schema": "./schema.graphql",
"language": "typescript",
"noSourceControl": true
}
Wire the Relay Babel transform into vite.config.ts — and allow the Vite dev server to be reached
through the YARP gateway (Vite blocks unknown hosts by default):
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import babel from "@rolldown/plugin-babel";
export default defineConfig({
plugins: [
// Compiles Relay's graphql`` tags. preset-typescript lets Babel parse .tsx; this runs
// before react() so React/oxc still handles the JSX itself.
babel({ presets: ["@babel/preset-typescript"], plugins: ["relay"] }),
react(),
],
// The gateway forwards an internal host header; allow it so the dev server isn't blocked.
server: { allowedHosts: ["localhost", ".aspire.dev.internal"] },
});
A Relay environment that simply posts to /graphql — same origin, so this is the entire network
layer:
// src/RelayEnvironment.ts
import { Environment, Network, RecordSource, Store, type FetchFunction } from "relay-runtime";
const fetchFn: FetchFunction = async (request, variables) => {
const response = await fetch("/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: request.text, variables }),
});
return await response.json();
};
export const RelayEnvironment = new Environment({
network: Network.create(fetchFn),
store: new Store(new RecordSource()),
});
And the app — this is where the data pays off. A colocated fragment owns the fields each card needs; the list never has to know them:
// src/BookCard.tsx
import { graphql, useFragment } from "react-relay";
import type { BookCard_book$key } from "./__generated__/BookCard_book.graphql";
const BookFragment = graphql`
fragment BookCard_book on Book {
title
author
publicationYear
pages
}
`;
export function BookCard({ book }: { book: BookCard_book$key }) {
const b = useFragment(BookFragment, book);
return (
<li className="card">
<h2>{b.title}</h2>
<p>{b.author} · {b.publicationYear} · {b.pages} pp</p>
</li>
);
}
The list is a @refetchable + @connection fragment driven by usePaginationFragment. One hook gives you
load-more (loadNext) and sort/filter changes (refetch) — and because Book is a Relay node, each
node.id is the stable global id Relay caches on:
// src/App.tsx — trimmed; the full sort/filter/search controls + styling are in the repo
const LibraryQuery = graphql`
query AppLibraryQuery($first: Int!, $order: [BookSortInput!], $where: BookFilterInput) {
...App_books @arguments(first: $first, order: $order, where: $where)
}
`;
const BooksFragment = graphql`
fragment App_books on Query
@refetchable(queryName: "LibraryPaginationQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 6 }
after: { type: "String" }
order: { type: "[BookSortInput!]" }
where: { type: "BookFilterInput" }
) {
books(first: $first, after: $after, order: $order, where: $where)
@connection(key: "App_books") {
totalCount
edges { node { id ...BookCard_book } }
}
}
`;
function Library({ queryRef }) {
const { data, loadNext, hasNext, refetch } = usePaginationFragment(BooksFragment, queryRef);
const books = data.books;
// A sort dropdown / author filter / search box just call:
// refetch({ first: 6, order: [{ pages: "DESC" }], where: { author: { eq: "Brandon Sanderson" } } })
return (
<>
<ol className="grid">
{books.edges.map((e) => <BookCard key={e.node.id} book={e.node} />)}
</ol>
{hasNext && (
<button onClick={() => loadNext(6)}>
Load more — {books.edges.length} of {books.totalCount}
</button>
)}
</>
);
}
Run the Relay compiler to generate the typed artifacts. --noWatchman uses a plain directory scan, so
you don’t need Watchman installed:
npx relay-compiler --noWatchman
Because the server added
AddGlobalObjectIdentification(),Book.idis a globally-unique opaque ID — exactly what Relay wants to key its store on. (Expose a raw integeridinstead and Relay throwsRelayResponseNormalizer: Expected id ... to be strings.)
Step 5 — Put YARP in front
This is the last piece, and it’s three lines. Add YARP to the AppHost and route the single public origin: GraphQL to the API, everything else to the Vite app.
cd ../AppHost
dotnet add package Aspire.Hosting.Yarp
// add the Vite app + the gateway to AppHost.cs
var web = builder.AddViteApp("web", "../web")
.WithReference(api)
.WaitFor(api);
builder.AddYarp("gateway")
.WithConfiguration(yarp =>
{
// GraphQL — and, soon, its free MCP and REST endpoints — go to the API.
yarp.AddRoute("/graphql/{**catch-all}", api);
// Everything else is the React app (Vite dev server now, static files in prod).
yarp.AddRoute("/{**catch-all}", web);
});
Run it
dotnet run --project AppHost
Open the Aspire dashboard, click the gateway endpoint, and there’s the library: 15 Wheel of Time books from Postgres, paged six at a time. Sort by series, year, length, or title; filter by author; search by title; and Load more to page through — each one a real GraphQL round-trip over a single origin, with traces for the whole request path sitting in the dashboard.
Count what you actually wrote: one entity, one DbContext, one repository, one resolver, one node
resolver, a couple of React components, and a handful of orchestration lines. Everything else — connection
strings, ports, the proxy, observability, pagination plumbing — Aspire and HotChocolate handled.
And here’s why I said production-shaped and not toy: that books field is already paginated,
filterable, and sortable on every column, with Relay node IDs. books(first: 20, where: { author: { eq: "Robert Jordan" } }, order: [{ pages: DESC }])
just works — point it at your own table and you have a real API.
The payoff: an MCP server and a REST API you didn’t write
This is the part that genuinely changed how I think about greenfield work. Once you have a GraphQL schema, two of the things teams traditionally build become things you enable.
Unlike everything above, the two adapters in this section weren’t part of the verified build — they follow ChilliCream’s own announcements (linked below). Treat the snippets as the shape and the linked posts as the source of truth for exact registration.
An MCP server, basically for free
The ChilliCream team shipped first-class Model Context Protocol support for HotChocolate and Fusion. Your GraphQL operations become MCP tools: an AI agent picks a tool, fills in the variables, and reads the result. You expose it with a package and two calls.
dotnet add package HotChocolate.Adapters.Mcp
builder.Services
.AddGraphQLServer()
.AddApiTypes()
.AddPagingArguments()
.AddFiltering()
.AddSorting()
.AddMcp(); // <-- here
app.MapGraphQL();
app.MapGraphQLMcp(); // MCP server at /graphql/mcp
Point Claude, ChatGPT, or your VS Code agent at /graphql/mcp and it can query your domain through the
same authorization and telemetry pipeline as everything else. With a Fusion gateway, a single tool can
even fetch across multiple subgraphs in one call. You modeled your domain once, in your schema, and your
agents get a typed, governed interface to it.
REST, when someone insists — without writing controllers
Not every consumer speaks GraphQL. A partner wants GET /api/books/42. Historically that meant a
parallel stack of controllers and DTOs. HotChocolate’s OpenAPI adapter
treats REST endpoints as clients of your own schema — you describe the mapping with an @http
directive instead of writing handlers.
dotnet add package HotChocolate.Adapters.OpenApi
builder.Services.AddOpenApi(o => o.AddGraphQLTransformer());
builder.Services
.AddGraphQLServer()
.AddApiTypes()
.AddOpenApi();
app.MapOpenApi();
app.MapOpenApiEndpoints();
app.MapGraphQL();
Then a plain .graphql operation is your endpoint definition:
query GetBookById($id: ID!)
@http(method: GET, route: "/api/books/{id}") {
node(id: $id) {
... on Book {
id
title
author
}
}
}
A GET /api/books/42 now returns flat JSON — no GraphQL envelope — and flows through the same auth
and telemetry as your GraphQL and MCP traffic. Route params bind to variables via {param}, request
bodies map with @body, and you can share field selections across endpoints with fragments. One schema;
three faces: GraphQL, MCP, and REST.
Why this matters
I’ve spent a lot of my career on the unglamorous middle of the stack — the part where re-platforms live or die. What strikes me about the state of the tooling in 2026 is how much of that middle has collapsed into configuration. The leverage isn’t that you can stand up a demo fast; it’s that the fast version is already the right version: layered, observable, paginated, and ready for both AI agents and REST consumers on day one.
That changes the calculus for starting things. When the foundation is an afternoon instead of a sprint, you prototype the risky idea, you spin up the internal tool, you stop talking yourself out of the greenfield bet because of setup cost. The boring part got cheap. Spend the savings on the problem that actually matters.
If you build something on this skeleton, tell me about it — I’d love to see what you grow it into.
Further reading
Credit where it’s due — much of what makes this approach click is recent work by the ChilliCream and .NET Aspire teams. These are the primary sources behind this post and the best places to go deeper:
- github.com/VanirTechDev/hellostack — the complete,
end-to-end-verified companion repo for this post. Clone it and
dotnet run --project AppHost. - MCP for Hot Chocolate & Fusion — ChilliCream’s announcement of native Model Context Protocol support, the basis for the “MCP server for free” section above.
- Open your GraphQL API for the REST — ChilliCream on generating REST endpoints straight from your schema with the OpenAPI adapter, the basis for the REST section above.
- Hot Chocolate documentation — the canonical reference for the GraphQL server, plus GreenDonut.Data, pagination, filtering, sorting, and projections.
- aspire.dev — the home of .NET Aspire: orchestration, the AppHost model, and integrations for Postgres, YARP, and JavaScript frontends.