A first-class full-stack target on .NET 8. Loader + conformance + EF Core codegen
- render engine + the
metaCLI all ship. Targets EF Core + ASP.NET Core + Postgres + Npgsql.
Note — not yet published to NuGet. Consume from source via a project reference, OR adopt the in-repo build (
dotnet build server/csharp/) and link to the produced DLLs. NuGet coordinates land once the C# port is ready for release:
<!-- YourApp.csproj — planned coordinates once published -->
<ItemGroup>
<PackageReference Include="MetaObjects" Version="0.15.10" />
<PackageReference Include="MetaObjects.Codegen" Version="0.15.10" />
<PackageReference Include="MetaObjects.Render" Version="0.15.10" />
</ItemGroup>The C# CLI ships as a .NET tool invoked as dotnet meta (dotnet tool install --global MetaObjects.Cli, command dotnet-meta) or run directly from the repo
via dotnet run --project server/csharp/MetaObjects.Cli. It is deliberately
not a bare meta executable — that name belongs to the canonical Node meta
CLI, which owns schema + TS codegen (ADR-0015).
Drop metadata under metadata/:
If your app needs a metamodel subtype the core doesn't ship, declare an
IMetaDataTypeProvider and compose it into the registry before loading:
using MetaObjects.Metadata;
using MetaObjects.Loader;
var registry = Provider.ComposeRegistry(new IMetaDataTypeProvider[] {
CoreTypesProvider.Instance,
ForgeTypesProvider.Instance,
yourProvider, // adds your custom subtype/attrs
});
var loader = MetaDataLoader.FromDirectory("./metadata", registry);The provider object has the same four-member contract (Id, Dependencies,
Description, RegisterTypes(registry)) as TS / Python. Composition errors
surface ERR_PROVIDER_DUPLICATE_ID, ERR_PROVIDER_MISSING_DEPENDENCY,
ERR_PROVIDER_DEPENDENCY_CYCLE — codes match the cross-port contract. See
../features/extending-with-providers.md
for the full reference and
../recipes/extending-metaobjects-with-providers.md
for a worked example.
# Generate EF Core entities + AppDbContext + CRUD minimal-API routes
dotnet meta gen ./metadata --out ./Generated --namespace Acme.Blog
# Drift-check templates against payloads (FR-004)
dotnet meta verify ./metadata --templates ./promptsSchema migrations are owned by the Node meta CLI (ADR-0015) — the C# CLI is
gen + verify only.
gen also accepts --template-spec <json> (+ --template-root <dir>, default
templates) — the declarative Mustache template-codegen surface; see
Declarative template-codegen below.
The codegen emits:
Author.cs— record per entity.AppDbContext.cs—DbSet<Author>, projection.ToView(),@storageowned types viaOwnsOne(single) /OwnsMany(...).ToJson(...)(@isArrayarray-of-VO), enum-as-string viaHasConversion<string>().Author.routes.cs— CRUD minimal-API endpoints.
// Program.cs
using Acme.Blog;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(opts =>
opts.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
var app = builder.Build();
app.MapAuthorRoutes(); // generated — GET/POST/PUT/DELETE on /api/author
app.Run();EF Core does the rest. The runtime has no MetaObjects dependency.
// Optional handwritten service over the generated DbContext
public class AuthorService(AppDbContext db)
{
public Task<List<Author>> ListAsync() => db.Authors.ToListAsync();
public async Task<long> CreateAsync(string name, string? bio = null)
{
var author = new Author { Name = name, Bio = bio };
db.Authors.Add(author);
await db.SaveChangesAsync();
return author.Id;
}
}using MetaObjects.Render;
var provider = new FilesystemProvider("./prompts");
var payload = new WelcomePayload(
DisplayName: "Ada",
PostCount: 12,
Posts: new[] { new PostSummary("Hello") });
string output = Renderer.Render(new RenderRequest(
Ref: "lobby/welcome",
Payload: payload,
Provider: provider,
Format: "xml"));Verify in MetaObjects.Render drift-checks every template.* against its
@payloadRef. Wire it into your CI step or invoke dotnet meta verify directly.
OutputParserGenerator (in MetaObjects.Codegen) emits one
<TemplateName>.output.cs file per template.output declaration. The static
<TemplateName>Parser class follows the BCL Parse/TryParse dual-API
convention — Parse throws on bad input, TryParse returns a bool plus an
out-error string.
// generated/NpcResponse.output.cs
public static class NpcResponseParser
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = false,
};
/// <exception cref="JsonException">malformed JSON or schema mismatch.</exception>
public static NpcResponse Parse(string text) =>
JsonSerializer.Deserialize<NpcResponse>(text, Options)
?? throw new JsonException("deserialized to null");
public static bool TryParse(string text,
[NotNullWhen(true)] out NpcResponse? value,
[NotNullWhen(false)] out string? error) { ... }
}The [NotNullWhen] attributes mean nullable-flow analysis lets you use npc
without a null-check after a true return, and error without one after false.
Consumer wiring:
string llmResponse = await myLlmClient.CompleteAsync(promptText);
// Throwing path
var npc = NpcResponseParser.Parse(llmResponse);
// TryParse for explicit error handling
if (NpcResponseParser.TryParse(llmResponse, out var npc, out var error))
return Ok(npc);
else
return BadRequest(new { error });MetaObjects.Render's Verify walks template.output nodes the same way
it walks template.prompt, catching payload-VO ↔ parser drift at build time.
Cross-port design is at ADR-0010;
the feature reference is at
features/templates-and-payloads.md.
Consumer dependency. System.Text.Json ships in the .NET 8 BCL — no
NuGet package to add. The generated parser uses the strict (case-sensitive)
default options.
Beyond the built-in EF Core / routes suite, dotnet meta gen runs declarative
Mustache template generators from a JSON template-spec — the cross-port contract
shared with the Python port (see
docs/features/codegen-concepts.md
and the neutral data dict in
docs/features/codegen-data-shapes.md):
dotnet meta gen ./metadata --out ./Generated \
--template-spec ./template-spec.json --template-root ./templates// template-spec.json — the cross-port shape
{ "generators": [
{ "name": "entity-doc",
"scope": "perEntity", // perEntity | perPackage | perModel
"outputPattern": "{package}/{Name}.md",
"template": "entity-doc", // resolved under --template-root
"format": "markdown" } // optional; a registered escaper format
]}Each spec entry derives the neutral template data dict for its scope
(MetaObjects.Codegen.TemplateCodegen.TemplateData) and names each file via the
outputPattern placeholders ({name}, {Name}, {package}). The named generators
are appended to the default suite and gated byte-identical against the shared
fixtures/template-codegen-conformance/ corpus. A target field is rejected (C# has
no output-target concept); a bad template ref or wrong --template-root surfaces as a
clean error, not a stack trace. For output to be regenerable, the template must emit
the @generated header itself (the write path refuses to overwrite files lacking it).
C# 12 / .NET 8 backends pair cleanly with an Angular 18 client built from
the universal @metaobjectsdev/angular runtime + @metaobjectsdev/codegen-ts-angular
codegen packages. The generated ASP.NET Minimal API routes (from
MetaObjects.Codegen RoutesGenerator) speak the same URL grammar
and wire format the Angular client expects — no special-casing.
End-to-end recipe — CORS wiring, dev-server port conventions, base-URL
configuration, the dotnet meta gen command sequence that emits both halves —
lives at docs/recipes/csharp-angular18.md.
Today's RoutesGenerator honours pagination (?limit/?offset), sort
(?sort=<field>:asc|desc against a static per-entity allowlist), and the
?withCount=1 envelope ({ rows, total }) that the Angular grid hook
always sends. Filter operators (eq / ne / gt / gte / lt / lte
/ in / like / isNull) per api-contract.md
are not yet generated — see
server/csharp/MetaObjects.Codegen/Generators/KNOWN_GAPS.md.
| Feature | Status |
|---|---|
| Entities + fields | Yes |
| Relationships + FK | Yes (EF Core + Postgres FK clause) |
| Source kinds (table / view / storedProc) | table + view fully shipped; storedProc / tableFunction / materializedView partial |
field.currency / field.enum / field.object + @storage |
Yes (incl. EF Core OwnsOne for flattened; OwnsMany(...).ToJson(...) for @isArray array-of-VO jsonb) |
| Templates + render (FR-004) | Yes (MetaObjects.Render) |
| Output parser codegen (FR-006) | Yes (OutputParserGenerator — Parse/TryParse BCL pattern) |
| Payload-VO codegen | Yes (MetaObjects.Codegen) |
| Declarative template-codegen | Yes — dotnet meta gen --template-spec (scope perEntity/perPackage/perModel + outputPattern; the cross-port JSON contract shared with Python) |
| Migrations | Owned by the Node meta CLI (ADR-0015) — no C# migrate surface |
| Drift verify | dotnet meta verify (template drift, FR-004) |
| Runtime metadata | Loader API + render engine; ObjectManager-style runtime tier on the roadmap |
| Corpus | Result |
|---|---|
Metamodel (fixtures/conformance/) |
91 / 91 |
YAML authoring (fixtures/yaml-conformance/) |
12 / 13 (1 ledgered — error-yaml-coerced-hex-in-string, YamlDotNet doesn't coerce 0xFF) |
Render (fixtures/render-conformance/) |
14 / 14 — byte-identical to TS |
Verify (fixtures/verify-conformance/) |
31 / 31 |
Persistence (fixtures/persistence-conformance/) |
12 / 12 (runnable via scripts/integration-test.sh csharp) |
API contract (fixtures/api-contract-conformance/) |
20 / 20 |
server/csharp/README.md— module-level overviewdocs/features/— every feature shows the C# output inlinedocs/superpowers/specs/2026-05-20-csharp-rdb-persistence-design.md