End-to-end recipe for wiring a C# .NET 8 backend (entities + EF Core +
ASP.NET Minimal API, all metadata-driven via MetaObjects.Codegen) to an
Angular 18 frontend (services + reactive forms + grid, generated by
@metaobjectsdev/codegen-ts-angular + the @metaobjectsdev/angular
runtime). The two halves are bound only by the cross-port REST contract at
docs/features/api-contract.md — no
runtime coupling to MetaObjects in either process.
| Layer | Choice |
|---|---|
| Backend language / runtime | C# 12 on .NET 8 |
| Backend framework | ASP.NET Core Minimal API |
| ORM | EF Core 8 (Postgres via Npgsql.EntityFrameworkCore.PostgreSQL, or SQLite) |
| Backend codegen | MetaObjects.Codegen (entities + AppDbContext + RoutesGenerator) |
| Backend CLI | dotnet meta (in MetaObjects.Cli) |
| Frontend framework | Angular 18 — standalone components, signals, no NgModules |
| Frontend runtime | @metaobjectsdev/angular (peer-deps @angular/core, @angular/common, @angular/forms, @tanstack/angular-table) |
| Frontend codegen | @metaobjectsdev/codegen-ts-angular (services + forms + grids) |
| Frontend HTTP | HttpClient via provideHttpClient() |
| Dev-server ports | Angular 4200, ASP.NET 5000 (HTTP) / 5001 (HTTPS) |
The Angular runtime and codegen packages live in the TypeScript workspace
(client/web/packages/angular/ + server/typescript/packages/codegen-ts-angular/)
because Angular IS TypeScript. They are completely backend-agnostic — the
same packages serve any backend that conforms to the API contract.
using Acme.Generated; // namespace produced by dotnet meta gen
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// 2.1 — DbContext: the generated AppDbContext is plain EF Core; wire your dialect.
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
// 2.2 — CORS: open the dev origin so the Angular dev-server (4200) can call us.
// The generated routes do NOT enforce CORS themselves — it's a consumer concern.
const string AngularDev = "AngularDev";
builder.Services.AddCors(o => o.AddPolicy(AngularDev, p => p
.WithOrigins("http://localhost:4200")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials())); // .AllowCredentials() if you set credentials: "include" on the fetcher
// 2.3 — JSON: camelCase property names + ISO 8601 dates are System.Text.Json's
// defaults. The contract expects integer minor-units for currency on the wire;
// the generated entity uses `long`, so STJ emits it as a JSON number — correct.
builder.Services.ConfigureHttpJsonOptions(opt =>
{
opt.SerializerOptions.PropertyNamingPolicy =
System.Text.Json.JsonNamingPolicy.CamelCase;
// STJ DateTime defaults to ISO 8601 with timezone — no extra config needed.
});
var app = builder.Build();
app.UseCors(AngularDev);
// 2.4 — Mount the generated routes under /api (matches metaobjects.config.ts apiPrefix).
// One Map<Entity>Routes extension per entity (RoutesGenerator emits these).
app.MapAuthorRoutes("/api");
// app.MapPostRoutes("/api"); // ... one call per entity
app.Run();Connection string in appsettings.json:
For a SQLite dev setup, swap UseNpgsql for UseSqlite("Data Source=app.db").
The Angular 18 application config wires three things: Angular's HttpClient,
the EntityFetcher (over that HttpClient + the API base URL), and the router.
// src/app/app.config.ts
import { ApplicationConfig, inject, provideZoneChangeDetection } from "@angular/core";
import { provideHttpClient, HttpClient } from "@angular/common/http";
import { provideRouter } from "@angular/router";
import { provideEntityFetcher } from "@metaobjectsdev/angular";
import { firstValueFrom } from "rxjs";
import { environment } from "../environments/environment";
import { routes } from "./app.routes";
// 3.1 — Build the fetcher inside an Angular DI factory so it can inject HttpClient.
// provideEntityFetcher accepts either a function OR a factory; this is the factory form.
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideHttpClient(),
provideRouter(routes),
provideEntityFetcher(() => {
const http = inject(HttpClient);
return async <T,>(path: string, init?: RequestInit): Promise<T> => {
// path always starts with apiPrefix (e.g. "/api/authors?limit=25").
// Prepend the environment-resolved base URL.
const url = environment.apiBase + path;
const method = (init?.method ?? "GET").toUpperCase();
const body = init?.body
? typeof init.body === "string" ? JSON.parse(init.body) : init.body
: undefined;
const obs = http.request<T>(method, url, {
body,
withCredentials: true, // matches the backend AllowCredentials()
headers: { "Content-Type": "application/json", ...(init?.headers as Record<string,string> ?? {}) },
observe: "body",
responseType: "json",
});
return firstValueFrom(obs);
};
}),
],
};Per-env base URL in src/environments/environment.ts (dev) and
environment.prod.ts (prod):
// environment.ts
export const environment = { production: false, apiBase: "http://localhost:5000" };
// environment.prod.ts
export const environment = { production: true, apiBase: "https://api.example.com" };main.ts bootstrap stays vanilla Angular:
import { bootstrapApplication } from "@angular/platform-browser";
import { AppComponent } from "./app/app.component";
import { appConfig } from "./app/app.config";
bootstrapApplication(AppComponent, appConfig);{ "metadata.root": {
"package": "acme::blog",
"children": [
{ "object.entity": {
"name": "Author",
"children": [
{ "source.rdb": { "@table": "authors" } },
{ "field.long": { "name": "id" } },
{ "field.string": { "name": "name", "@required": true, "@maxLength": 200 } },
{ "field.string": { "name": "bio", "@maxLength": 2000 } },
{ "field.boolean":{ "name": "active" } },
{ "identity.primary": { "@fields": "id", "@generation": "increment" } },
{ "layout.dataGrid": {
"name": "default",
"@columns": ["name", "bio", "active"],
"@defaultSortField": "name",
"@defaultSortOrder": "asc",
"@pageSize": 25
}}
]
}}
]
}}The dotnet meta CLI (MetaObjects.Cli) emits the C# half; the Node meta
(@metaobjectsdev/cli) emits the Angular half. Most repos run them as two
adjacent commands in the build pipeline.
C# half — driven by MetaObjects.Cli's config (appsettings.codegen.json
or CLI flags). Emits:
Author.g.cs— POCO with[Table("authors")],[Key],[Column], etc.AppDbContext.g.cs—DbSet<Author> Authors { get; set; }AuthorRoutes.g.cs—MapAuthorRoutes(this IEndpointRouteBuilder, string prefix)
TypeScript / Angular half — driven by metaobjects.config.ts. Emits:
Author.ts— entity-constants module (paths, types, allowlists)Author.service.ts—@Injectable({ providedIn: 'root' })withlist/get/create/update/deleteAuthor.form.component.ts— standalone reactive formAuthor.grid.component.ts— standalone grid pre-wired to the service
- Navigate to
/authorsin the Angular app. AuthorGridComponentcallsAuthorService.list({ limit: 25, sort: "name:asc", withCount: 1 }).- The service issues
GET http://localhost:5000/api/authors?limit=25&sort=name:asc&withCount=1. - The generated
MapAuthorRouteshandler honours all three qs params and returns{ rows: [...], total: 137 }. - The grid renders the page; the pagination footer shows "1–25 of 137".
- Click the
+toolbar button;AuthorFormComponentopens. - Submit →
POST http://localhost:5000/api/authorswith{ name, bio, active }. - Backend returns
201 Createdwith the created row (id populated). - Grid invalidates + refetches.
AllowCredentials()+WithOrigins(...)must agree: if your Angular fetcher sendscredentials: "include"(cookies / session), the backend CORS policy cannot useAllowAnyOrigin()— you must list explicit origins. The example above does this correctly.- Preflight
OPTIONSis handled automatically by ASP.NET's CORS middleware when you callapp.UseCors(...)before route mapping. The generated routes do not need to handleOPTIONSthemselves. - Multiple environments: keep
apiBaseinenvironment.ts/environment.prod.tsso the prod build hitshttps://api.example.comwhile the dev build hitshttp://localhost:5000. Never hard-code URLs in the fetcher. - Secure cookies in prod: set
SameSite=None; Secureon auth cookies when the API origin differs from the SPA origin (Cross-site cookies need Secure + None to flow at all). - CSRF: if you rely on cookies for auth, layer CSRF protection in your
ASP.NET pipeline (
AddAntiforgery+ a token header the Angular fetcher reads from a cookie and sends back). Out of scope for codegen; standard ASP.NET work.
# Terminal 1 — backend (port 5000)
cd backend
dotnet run
# Terminal 2 — frontend (port 4200)
cd frontend
npm start # equivalent to: ng serve --port 4200Or chain them in a script (root package.json or a justfile):
// package.json (root)
{
"scripts": {
"dev:backend": "dotnet run --project backend",
"dev:frontend": "ng serve --project frontend --port 4200",
"dev": "concurrently \"npm:dev:backend\" \"npm:dev:frontend\""
}
}For codegen on metadata change:
# C# half (one-shot)
cd backend && dotnet run --project ../tools/MetaObjects.Cli -- gen
# TS / Angular half (one-shot; re-run on metadata change)
cd frontend && npx meta genThe two halves deploy independently:
- Angular —
ng build --configuration productionproduces a staticdist/directory. Drop it on any CDN: Cloudflare Pages, Vercel, Azure Static Web Apps, Netlify, S3 + CloudFront.environment.prod.ts.apiBasemust point at the deployed API origin. - ASP.NET Minimal API —
dotnet publish -c Release -o outproduces a self-contained app. Deploy targets: Azure App Service (native fit), AWS Elastic Beanstalk / ECS, Railway, Render, or any container host. Bind theConnectionStrings:Defaultenv-var to your managed Postgres. - Single-origin — if you serve the SPA from the same origin as the API
(e.g. ASP.NET hosts the Angular
dist/as static files viaapp.UseStaticFiles()), drop the CORS middleware entirely — same-origin requests don't need it.
A common deployment pair: Angular on Cloudflare Pages, ASP.NET on Azure App Service, Postgres on Neon / Supabase / Azure Database for PostgreSQL.
docs/features/api-contract.md— the URL grammar, filter operators, wire format, and HTTP verbs every backend must serve and every client expectsdocs/ports/typescript-client.md— the Angular 18 client section covers the runtime + codegen packages in fulldocs/ports/csharp.md— the C# port reference (entities, EF Core wiring, codegen capability snapshot)server/csharp/MetaObjects.Codegen/Generators/KNOWN_GAPS.md—RoutesGeneratorgaps vs the API contract (filter operators are the notable one)docs/superpowers/specs/2026-05-26-fr-008-universal-react-ui-hookup.md— FR-008 §2.4 (this recipe's parent spec)
{ "ConnectionStrings": { "Default": "Host=localhost;Database=acme_blog;Username=acme;Password=acme" } }