Skip to content

Backend integration

This page shows how to add the engine to your own ASP.NET Core host. For a complete, runnable example see the Inventory sample; for installing the packages from GitHub Packages (auth, nuget.config) see Consuming the packages.

Reference Genie.Engine, register it through the fluent builder, and add the exception handler and hubs to the pipeline:

using Genie.Engine;
builder.Services.AddGenie<YourContext>(genie => genie
.LoadFromConfiguration(builder.Configuration)); // config-first: datasource + connection strings + options
builder.Services.AddGenieAuth(builder.Configuration); // minimal auth; features are opt-in
var app = builder.Build();
app.UseGenieExceptionHandler(); // first in the pipeline — consistent { success, error, traceId } JSON
// ... your middleware ...
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapGenieHubs(); // SignalR notification hub at /hubs/genie-hub
app.Run();

AddGenie(genie => …) registers the entire engine: view resolution, the unified Object services, identity/RBAC, workflow, wizard, sequences, notifications, and the model-migration hosted service. The <YourContext> type parameter registers the engine against a host-derived context so its EF migrations live in the host assembly (see step 2); the non-generic AddGenie(genie => …) overload stays on the base GenieContext.

All engine configuration flows through the fluent GenieBuilder (the same config-first + in-code-override pattern as GenieAuthBuilder). LoadFromConfiguration(configuration) binds the datasource (AppSettings:Datasource), the connection strings, and every options section from appsettings as the base, then any Use* / Configure* call overrides it in code (later wins):

builder.Services.AddGenie<YourContext>(genie => genie
.LoadFromConfiguration(builder.Configuration)
.UseDatasource(Datasource.PostgreSql) // or set AppSettings:Datasource in config
.UseConnectionString("PostgreSql", secretFromVault)
.ConfigureErrors(e => e.ExposeErrorDetails = false)
.ConfigureStorage(s => { s.Type = StorageType.Local; s.Path = "App_Data/storage"; })
.ConfigureEmail(m => { m.Enabled = true; m.SmtpHost = "smtp.example.com"; }));

The builder also registers IAppIdentity (the datasource) and the IGenieConnectionStrings seam, so the host no longer wires those by hand — the data path resolves connection strings through the seam, not IConfiguration. Available fluent calls include UseDatasource, UseConnectionString, UseAppIdentityType, AddWorkers, and ConfigureErrors / ConfigureStorage / ConfigureEmail / ConfigureSms / ConfigureMeilisearch / ConfigureMigrations / ConfigureSchemaCache / ConfigureUploads (plus ConfigureAuth / ConfigureHangfire, honoured by the umbrella AddGenieApp).

AddGenieApp(genie => …) composes the whole platform from the same builder — the engine + Identity (ConfigureAuth) + Hangfire (ConfigureHangfire) + Assistant + OpenAPI. Because those modules read IConfiguration directly, you must call LoadFromConfiguration(configuration) inside the callback or AddGenieApp throws:

builder.Services.AddGenieApp(genie => genie
.LoadFromConfiguration(builder.Configuration)
.ConfigureAuth(auth => auth.AddImpersonation())
.ConfigureHangfire(h => h.RedisPrefix = "myapp-jobs"));
var app = builder.Build();
app.UseGenieExceptionHandler();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapGenieApp(builder.Configuration); // Razor Pages + both hubs + Hangfire dashboard + OpenAPI
app.Run();

MapGenieApp maps the Genie notification hub, the Assistant chat hub, the IP-gated Hangfire dashboard, and OpenAPI/Swagger. If you compose the engine manually instead (as the Inventory sample does), map the pieces yourself: MapGenieHubs() for the notification hub, and MapAssistantHubs() if you enable the Assistant.

The engine’s MonitoredBackgroundService workers (app-notification delivery, email/SMS drain loops, Meilisearch sync) are config-driven by default — app-notifications always run, the email/SMS workers run when their channel is Enabled + valid, and the Meilisearch sync worker runs when Meilisearch is valid. Call AddWorkers(...) to switch to explicit opt-in — only the workers you list run, so an API host can skip the drain/sync loops while a dedicated Worker host runs them (the Meilisearch read provider stays active either way; only the sync worker is gated):

builder.Services.AddGenie<YourContext>(genie => genie
.LoadFromConfiguration(builder.Configuration)
.AddWorkers(w => w
.AddAppNotificationWorker()
.AddEmailWorker() // still needs a valid email channel
.AddSmsWorker() // still needs a valid SMS channel
.AddMeilisearchSyncWorker()));

See Notifications for the delivery channels and Global search for the Meilisearch provider.

2. Derive your DbContext from GenieContext

Section titled “2. Derive your DbContext from GenieContext”

The host owns the EF context so its migrations live in the host assembly:

namespace YourApp.Persistence;
public partial class YourContext(IConfiguration configuration, IAppIdentity appIdentity)
: GenieContext(configuration, appIdentity)
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder); // full Genie schema + seeds
modelBuilder.ApplyConfigurationsFromAssembly(typeof(YourContext).Assembly); // your configs + generated ones
}
}

Mark it partial so the source generator can merge the low-code entities’ DbSets into it (see step 3). ApplyConfigurationsFromAssembly is what registers the generated IEntityTypeConfiguration<T> classes — no per-entity wiring needed. Because you passed <YourContext> to AddGenie, every engine service that depends on GenieContext resolves your derived context.

3. Wire the source generator (entity.xml → EF tables)

Section titled “3. Wire the source generator (entity.xml → EF tables)”

Genie.Source is a Roslyn generator. Reference it as an analyzer and feed it your entity files:

<!-- Reference the generator as an analyzer (not a compile reference). -->
<ProjectReference Include="..\..\..\src\api\Genie.Source\Genie.Source.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<!-- Tell the generator which class to extend with the generated DbSets (your GenieContext-derived
context). Defaults to "ZedContext" if unset. -->
<PropertyGroup>
<GenieDbContextName>YourContext</GenieDbContextName>
</PropertyGroup>
<ItemGroup>
<CompilerVisibleProperty Include="GenieDbContextName" />
</ItemGroup>
<!-- Feed the entity models to the generator (it filters to *.entity.xml). -->
<ItemGroup>
<AdditionalFiles Include="..\models\entities\*.entity.xml" />
</ItemGroup>

For each *.entity.xml the generator emits, into your project’s namespace:

  • a partial class {EntityName} : EntityTraits<long> (audit/tenant/soft-delete + Id come from the base);
  • an enum per Select field and an FK navigation per Lookup field;
  • an {EntityName}Configurations : EntityBaseConfiguration<{EntityName}, long> (table, schema, indexes, relationships);
  • a partial class {GenieDbContextName} with a DbSet<> per entity (merges into your context).

Then create the physical tables with a normal migration:

Terminal window
dotnet ef migrations add <Name> --project YourHost --startup-project YourHost \
--context YourContext --output-dir Persistence/Migrations

4. Engine SQL objects (deployed at startup)

Section titled “4. Engine SQL objects (deployed at startup)”

The engine’s own database objects — the password-encryption key/procs (sp_encrypt_password / sp_decrypt_password, or the PostgreSQL pgcrypto functions), the sequence-number functions ([Genie].[GetNextSequenceNumber] / [Genie].[GetSequenceNumberPreview]), and the company-scope helpers — are deployed at startup by EngineSqlBootstrapper (an IHostedService registered by AddGenie, ahead of the model-migration service). There is nothing to add to a migration — it’s automatic. Each unit of engine SQL is owned by a per-feature IEngineSqlContributor (PasswordEncryptionSqlContributor, SequenceSqlContributor, CompanyScopeSqlContributor); the bootstrapper collects them, each picks the dialect from the active datasource, substitutes the configured secrets in memory, executes via raw ADO.NET, and records each unit in the Genie.ExecutedScripts table keyed by a content hash:

  • On first boot, each unit runs and is recorded.
  • On later boots, an unchanged unit (same hash) is skipped — the heavy DDL runs only when the SQL (or the unit’s own Version) actually changes. This is the same hash-tracking the model migration uses for *.sql / *.entity.xml files.

Secrets are read from the host’s IConfiguration (Security:MasterKeyPassword, Security:PasswordEncryptionKey, Security:SeedAdminPassword) and are never persisted — the stored ExecutedScripts.Script and its hash use the tokenised template; only the executed batches carry the substituted values.

AddGenieHangfire(configuration) wires Hangfire on a Redis storage backend plus an embedded processing server, and registers the Jobs dashboard service that powers GET/POST /api/v1/hangfire/* (consumed by the React Jobs dashboard). It is included automatically by AddGenieApp; a host that composes the engine manually calls it itself:

services.AddGenieHangfire(configuration); // after AddGenie(...)

The Redis connection is read from ConnectionStrings:Redis (the same instance backing the cache). If no Redis connection is configured, only the dashboard service registers and the Jobs API returns 503 — the rest of the engine still boots. Settings bind from the Genie:Hangfire section (code overrides via ConfigureHangfire win on top):

"Genie": {
"Hangfire": {
"RedisPrefix": "hangfire",
"KnownQueues": [ "default", "pipelines", "transfers", "notifications", "logs" ],
"DefaultPageSize": 25,
"MaxJobsPerStateQuery": 1000
}
}

All /api/v1/hangfire/* endpoints require the System role. The legacy built-in Hangfire dashboard (MapGenieHangfireDashboard/hangfire) remains available behind the IP-allowlist for hosts that want it.

AddGenieAuth(configuration) also calls AddGenieDataProtection(configuration), which persists the ASP.NET Core Data-Protection key ring per the DataProtection config section. The key ring protects auth cookies, antiforgery tokens, and — critically — the encrypted JWT signing key (see Identity → signing keys). It is a no-op when Enabled is false (the default), in which case the framework default applies: a per-user local key ring, or an ephemeral one under service identities without a writable profile — keys that don’t survive a restart.

"DataProtection": {
"Enabled": true,
"Provider": "Redis", // "Redis" or "FileSystem" (default)
"ApplicationName": "YourApp", // pins the DP discriminator — default "Zed"
"RedisKey": "DataProtection:Keys", // Redis provider: the ring's key (default shown)
"KeyPath": "C:\\ProgramData\\YourApp\\dp-keys", // FileSystem provider (default C:\ProgramData\Zed\dp-keys)
"UseDpapi": true // FileSystem on Windows: DPAPI-NG at-rest encryption (default true)
}
  • Provider: "Redis" stores the ring in Redis (connection from ConnectionStrings:Redis) — one ring shared by every node, surviving restarts and redeploys. Required posture for multi-node hosts, and for any host using JwtKeyStorage.CacheStore.
  • Provider: "FileSystem" persists to KeyPath (the app-pool identity needs read/write; the ring is DPAPI-NG-encrypted at rest on Windows unless UseDpapi is false). Per-node — fine for single-node hosts with FileSystem JWT keys.
  • ApplicationName sets the DP application discriminator. Without it the framework default is the content-root path, so moving the deployment folder silently makes old ciphertext undecryptable. Always set it.