Foundgine

Foundgine Supply Chain Starter — Every Concept, Explained

This is a companion to SupplyChain-Starter-Tutorial.md. It exists because that tutorial shows you each file, but doesn’t always stop to explain why the concept exists or what you need installed/configured before it will work. Read this alongside the tutorial, in the same order (sections match the tutorial’s numbered steps).


0. The mental model

Foundgine sits between “an AI agent calling a tool” and “SQL running against your database.” Its whole point is that no layer above the SQL compiler is allowed to know column names, table names, or write raw SQL — everything is expressed as semantic operations (read this entity, filtered by this field, traversing this relationship) that only get turned into SQL at the very last step, by a compiler that knows your schema.

Why does that matter in practice?


1. Prerequisites — what each one is for

Requirement Why you need it
.NET 9 SDK Foundgine’s generator is a Roslyn source generator, which only runs inside a .NET/Roslyn compilation. You cannot use an older SDK — Roslyn incremental generators need a modern SDK/compiler.
Docker Desktop The sample stores data in real PostgreSQL, not an in-memory fake, so the SQL compiler output is exercised against a real engine (real types, real constraints, real query plans).
Git clone with project refs Because Foundgine.Core/Runtime/Providers are still evolving alongside the sample, the tutorial deliberately uses <ProjectReference> instead of published NuGet versions, so you always build against the exact source in the repo.

Verify with dotnet --version / docker --version before doing anything else — nearly every “weird build error” in this kind of project traces back to an SDK version mismatch or Docker not running.


2. The 4 packages — what problem each one solves

Why a Roslyn analyzer instead of a normal NuGet dependency for the generator? Because a source generator has to run during your build, as an Analyzer item, not as a regular assembly reference — that’s what the OutputItemType="Analyzer" / ReferenceOutputAssembly="false" incantation in the .csproj is doing. It tells MSBuild “run this project’s code as a compiler plugin against my source, but don’t link its assembly into my app.”


3–5. Domain models, storage entities, and mappings — why three files, not one

This is the part people most often try to collapse into one file, and it’s worth understanding why the tutorial keeps them separate:

  1. Domain/Models.cs ([FoundgineModel]) is the vocabulary your application and your AI agent talk in — “Customer”, “SalesOrder”. This layer should be stable even if you migrate databases entirely.
  2. Domain/StorageModels.cs ([FoundgineEntity] / [FoundgineField] / [FoundgineRelationship]) is the actual schema — real table names, real column names, real foreign keys. This layer changes whenever your DBA changes something.
  3. Domain/Mappings.cs ([FoundgineModelEntityMap]) is a firewall between the two. It’s intentionally the only file allowed to using both the Models and Storage namespaces. If you ever find yourself importing Domain.Storage from your application layer, that’s a sign the boundary is leaking.

IDs matter, and here’s the actual rule, precisely:

If you get an id collision, the AOT generator (or ValidateRelationships, which runs before code emission) will fail the build with a diagnostic rather than silently producing wrong metadata — this is deliberate: wrong metadata here means wrong SQL later, so the generator fails loudly and early.

Checkpoint discipline: the tutorial tells you to dotnet build right after step 5, before writing any application code. Do this. If your attributes are malformed, you want the compiler to tell you now, not three files later when a query mysteriously returns nothing.


6. Why there’s no semantic-model file to write — and why the mapping still is

The short version

Earlier revisions of this sample had you hand-write Semantics/SupplyChainSemanticModel.cs — a “front door” file so nothing in Infrastructure/Application imported Foundgine.Generated directly or juggled raw numeric ids. That file is now gone entirely. Application code (SupplyChainQueryRepository, SupplyChainMutationRepository, Program.cs) references Foundgine.Generated.GeneratedMetadata and Foundgine.Generated.GeneratedSemanticModel directly. There’s nothing left for the wrapper to do.

Why it became unnecessary

The wrapper originally earned its place two ways:

  1. EntityId passthrough properties (Customer, SalesOrder, …) — low risk, but boilerplate.
  2. Relationship lookups — the file used to run a LINQ query against the whole MetadataRegistry at static-constructor time, matching on string names:

    private static RelationshipId Relationship(string entityName, string relationshipName) =>
        Registry.Relationships
            .Single(x => x.Name == relationshipName &&
                         Registry.GetEntity(x.Source).Name == entityName)
            .Id;
    
    public static readonly RelationshipId CustomerOrders = Relationship("CustomerERP", "Orders");
    

    This didn’t scale: every new relationship needed a new hand-typed call, the entity/relationship names were plain strings with no compiler check, and a typo failed at runtime via Single() throwing.

We extended the AOT generator itself (src/csharp/Foundgine.Providers/Foundgine.Providers.Aot.Generator/FoundgineMetadataGenerator.cs, in EmitSemanticModel) so it emits a Relationships nested class directly under each model’s generated class — one strongly-typed constant per [FoundgineRelationship] property found on that model’s mapped storage entity:

// auto-generated, in Foundgine.Generated.GeneratedSemanticModel
public static class Customer
{
    public static readonly EntityId Entity = new(101);
    // ...fields...

    public static class Relationships
    {
        public static readonly RelationshipId Orders = new(1);
    }
}

Once that existed, the wrapper’s entire remaining content was one-line aliases with zero logic in them — SupplyChainSemanticModel.Customer => GeneratedSemanticModel.Customer.Entity, SupplyChainSemanticModel.Metadata => GeneratedMetadata.Registry, and so on. A file that only renames things one-to-one isn’t a boundary, it’s indirection — so we deleted it and updated the three call sites that used it (Program.cs’s two DI registrations, and the two relationship references inside SupplyChainQueryRepository) to name the generated members directly:

// Program.cs — GeneratedMetadata.Registry already implements IMetadataProvider
builder.Services.AddSingleton<IMetadataProvider>(GeneratedMetadata.Registry);
builder.Services.AddSingleton(GeneratedMetadata.Registry);
// SupplyChainQueryRepository.cs
GeneratedSemanticModel.SalesOrder.Relationships.Lines
GeneratedSemanticModel.Shipment.Relationships.Order

What this buys you as you onboard new entities: add a [FoundgineRelationship] property, rebuild, and the accessor (GeneratedSemanticModel.<Model>.Relationships.<Name>) just exists — no wrapper file to touch, no lookup call to write, and a typo is a compile error (unknown member) instead of a Single() throw at app startup.

The mapping is still required — this is the important part

None of the above means Domain/Mappings.cs became optional. It’s the opposite: Domain/Mappings.cs is now the only place that decides whether a GeneratedSemanticModel.<Model> class gets emitted at all. The generator registers every [FoundgineEntity] into GeneratedMetadata.Registry unconditionally, but it only emits the GeneratedSemanticModel.<Model> class — the one with .Entity, field constants, and .Relationships — for models that appear in a [FoundgineModelEntityMap]:

// FoundgineMetadataGenerator.cs, EmitSemanticModel
if (!modelEntityMap.TryGetValue(model.ToDisplayString(), out var entity) || ...)
    continue; // this model gets no GeneratedSemanticModel class at all

Skip the mapping for a model and you still get raw metadata for its entity (plannable by name), but you lose every compile-time-checked accessor for it. So removing the hand-written wrapper made the mapping more load-bearing, not less: with no wrapper standing between application code and the generator’s output, Domain/Mappings.cs is the one file that determines what application code is even allowed to reference by name.

Where this lives

Build the solution

cd src/csharp/samples/Foundgine.SupplyChain
dotnet build

Then inspect the generated file (path will be under obj/Debug/net9.0/generated/.../FoundgineMetadataGenerator/..., or just right-click → “Go to Definition” on GeneratedSemanticModel in your IDE) and confirm you see a Relationships nested class with the expected members. Also run:

dotnet test src/csharp/tests/Foundgine.Aot.Tests

7. Application layer — the concepts worth understanding


8. Infrastructure — planner, compiler, fingerprint


9–11. Wiring, Postgres, running it — setup concepts


12. Where the starter stops, on purpose

The tutorial is explicit that it’s not implementing:

All of those exist in the repo already, in src/csharp/samples/Foundgine.SupplyChain.Advanced, if/when you’re ready to go past the starter.


Quick checklist for onboarding someone new to this project

  1. Install .NET 9 SDK + Docker; verify both.
  2. Clone, dotnet build the whole solution once to prime NuGet/Roslyn caches.
  3. Read Domain/Models.csDomain/StorageModels.csDomain/Mappings.cs, in that order — that’s the dependency direction of understanding, not just of code.
  4. dotnet build again — confirm GeneratedSemanticModel and (with the change above) its new Relationships nested classes appear.
  5. docker compose up -d postgres, run seed.sql.
  6. dotnet run, hit /health/ready.
  7. Call describe_capabilities for each of alice/bob/carol/dave/admin first, before anything else — it’s the fastest way to see the authorization model in action without touching the database at all.