Transactions and tests
Commit several writes atomically with ExecuteTransactionAsync, then unit-test the data layer with no database by mocking the generated context interfaces.
The last endpoint creates a project and its first tasks together. Either all of it lands or none of it does, which is exactly what ExecuteTransactionAsync provides.
One atomic unit of work
ExecuteTransactionAsync opens a transaction, runs the callback, commits when it returns, and rolls back if it throws. Move the multi-write logic into a service:
public sealed class ProjectService(ISocigyDatabaseFactory<IAppDb> db)
{
public Task<Guid> CreateWithTasksAsync(string name, IEnumerable<string> titles) =>
db.ExecuteTransactionAsync(async d =>
{
var project = new Project { Id = Guid.NewGuid(), Name = name };
await d.Projects.InsertAsync(project, includeAutoFields: true);
foreach (var title in titles)
await d.Tasks.InsertAsync(new TaskItem { ProjectId = project.Id, Title = title });
return project.Id; // committed here; any throw above rolls the whole thing back
});
}Generating the Id client-side and passing includeAutoFields: true writes that value, so the foreign key on each task is known before the rows are inserted. Register the service and wire the endpoint:
builder.Services.AddScoped<ProjectService>();
app.MapPost("/projects/seeded", (SeedRequest body, ProjectService svc) =>
svc.CreateWithTasksAsync(body.Name, body.Tasks));
record SeedRequest(string Name, string[] Tasks);ExecuteAsync / ExecuteTransactionAsync calls join the outermost scope, so a service that calls another service still commits once. See Transactions.Test it without a database
Because the context is a set of generated interfaces (ISocigyDatabaseFactory<IAppDb>, IAppDb, IProjectSet, ITaskItemSet), the service is testable with a mocking library and no PostgreSQL. Wire the mock factory to invoke the callback against a mock context:
[Fact]
public async Task CreateWithTasksAsync_inserts_project_and_each_task()
{
var projects = new Mock<IProjectSet>();
var tasks = new Mock<ITaskItemSet>();
var ctx = new Mock<IAppDb>();
ctx.SetupGet(c => c.Projects).Returns(projects.Object);
ctx.SetupGet(c => c.Tasks).Returns(tasks.Object);
var db = new Mock<ISocigyDatabaseFactory<IAppDb>>();
db.Setup(f => f.ExecuteTransactionAsync(It.IsAny<Func<IAppDb, Task<Guid>>>(), It.IsAny<CancellationToken>()))
.Returns((Func<IAppDb, Task<Guid>> work, CancellationToken _) => work(ctx.Object));
var svc = new ProjectService(db.Object);
await svc.CreateWithTasksAsync("Launch", new[] { "Design", "Build", "Ship" });
projects.Verify(p => p.InsertAsync(It.IsAny<Project>(), true), Times.Once);
tasks.Verify(t => t.InsertAsync(It.IsAny<TaskItem>(), false), Times.Exactly(3));
}The mocked factory runs your real CreateWithTasksAsync logic, so the test verifies the unit of work itself: one project insert, one task insert per title. For tests against a real database, point a connection string at a disposable PostgreSQL instance and run migrations in a fixture. See Unit testing.
Where to go next
You have modelled a schema, migrated it, and read, written, and tested a typed data layer. From here:
- Querying for the full standalone query builder, joins, and set operations.
- Dynamic tables for per-tenant and time-partitioned tables.
- Recipes for production patterns like pagination, soft deletes, and multi-tenancy.