Persistence¶
The app has one DbContext derived from CoworkeeDbContext. Modules contribute their entities; Coworkee contributes identity, settings, audit and outbox tables.
public sealed class MyAppDbContext(DbContextOptions<MyAppDbContext> options, ICurrentUser currentUser, IEnumerable<IModelContributor> contributors)
: CoworkeeDbContext(options, currentUser, contributors);
context.Services.AddCoworkeeDbContext<MyAppDbContext>((provider, options) =>
options.UseCoworkeeDatabase(provider.GetRequiredService<IConfiguration>(), "myapp"));
UseCoworkeeDatabase picks the EF Core provider from Coworkee:Database:Provider (see Database providers) and keeps the migration history in cw.__EFMigrationsHistory.
Handlers inject CoworkeeDbContext and use db.Set<T>(). There are no repositories; EF Core already is one.
Database providers¶
Coworkee:Database:Provider |
EF Core provider | Aspire app host | Good for |
|---|---|---|---|
Postgres (default) |
Npgsql | AddCoworkeeApp starts a Postgres container |
everything, production |
SqlServer |
Microsoft.EntityFrameworkCore.SqlServer | options.UseSqlServer(), a SQL Server container |
organisations that run SQL Server or Azure SQL |
Sqlite |
Microsoft.EntityFrameworkCore.Sqlite | options.UseSqlite(), the file .data/<name>.db |
demos, prototypes, one machine |
The app host sets the setting on every service, next to the connection string. Elsewhere (tests, your own hosting) set it like any other configuration value. coworkee new --database sqlServer writes the app host call and creates the initial migration for that provider.
The provider-specific parts sit behind small seams in Coworkee.Infrastructure, nothing to do in an app:
| Part | Postgres | SQL Server | SQLite |
|---|---|---|---|
IHasConcurrencyToken.Version |
xmin |
column, counted up on every update | column, counted up on every update |
HasColumnType("jsonb") |
jsonb |
nvarchar(max) |
TEXT |
| Outbox batch | FOR UPDATE SKIP LOCKED |
UPDLOCK, READPAST |
plain query, no row locks |
IDistributedLock |
advisory lock | sp_getapplock |
file lock in the temp folder (one machine) |
| Background jobs | Hangfire.PostgreSql | Hangfire.SqlServer | Hangfire.InMemory, every process runs its own job server |
| Text comparison | case-sensitive | case-sensitive (Latin1_General_100_CS_AS on every text column) |
case-sensitive (=) |
Limits to know:
- Migrations belong to one provider. An app keeps the migrations of the provider it was created with. To switch, delete
Migrationsand runcoworkee migrations add Initialagain. To ship one app for several providers, follow the EF Core guide Migrations with multiple providers with one migrations assembly per provider. - Database backups need Postgres. The template leaves the module out for the other providers.
- SQLite has one writer at a time, keeps background jobs in memory (queued and delayed jobs are lost on restart), stores
DateTimeOffsetas a number anddecimalasdouble, does not enforceHasMaxLengthand knows no schemas (tables lose theircw.prefix). It is meant for development and small single machine installations. - Facets over
boolcolumns ([ProvideFacet]onIsActive) fail on SQL Server and SQLite, which hand the value back as1/0; fixed in the next Nextended release. - MySQL and MariaDB are not offered: Pomelo, the usual provider, has no release for EF Core 10 yet.
Base classes and markers¶
| Type | Gives |
|---|---|
Entity |
Id, a version 7 GUID set on construction |
AuditedEntity / AuditedAggregateRoot |
CreatedAt, CreatedBy, ModifiedAt, ModifiedBy, filled on save |
AggregateRoot |
Raise(IDomainEvent) for the outbox |
IMultiTenant |
query filter on the current tenant, TenantId set on insert |
ISoftDelete |
Remove marks the row deleted; queries hide it |
IHasConcurrencyToken |
optimistic concurrency with a Version column |
IVersioned |
keeps a snapshot per revision for the version history view |
[Realtime(permission)] |
publishes inserts, updates and deletes to SignalR clients with the permission |
[NotAudited], [Sensitive] |
leave a type or property out of the audit trail, or record a property masked |
Audit trail¶
Every change to an audited entity is written field by field with user, time and old and new value. The admin UI shows it per entity (AuditTimeline) and globally (Audit log page).
Tenants and the current user¶
ICurrentUser carries user id, tenant id and permissions of the request. Background jobs and the outbox restore the user who started them. To act as someone else, for example in a seed, open a scope:
using (CurrentUserScope.Begin(new ImpersonatedUser(adminId, tenantId)))
{
// queries and inserts run as this user and tenant
}
Migrations and seeding¶
Migrations live in the infrastructure project and are made for its database provider:
A small host applies them. The template's migration service also seeds:
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddCoworkeeModules<MyAppDatabaseModule>(builder.Configuration);
builder.Services.AddCoworkeeIdentitySeed(DemoSeed.Configure);
builder.Services.AddScoped<ISetupStep, DemoData>();
using var host = builder.Build();
await using var scope = host.Services.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<MyAppDbContext>().Database.MigrateAsync();
await host.Services.SeedCoworkeeIdentityAsync();
AddCoworkeeIdentitySeed replaces the setup wizard: it creates the default tenant, the administrator, roles with their permission grants, further users and system wide settings (seed.Settings["Theme.Mode"] = "dark"), once. ISetupStep implementations run inside the same setup, as the administrator, and are the place for sample data. See Authentication.