Skip to content

Adding Persistence

In Your First API, you created a module with an in-memory list. That works for a demo, but real applications need a database. In this tutorial you will wire up EF Core with PostgreSQL and let Granit handle audit fields, soft delete, and sequential GUIDs automatically.

Add the persistence stack and the PostgreSQL provider:

Terminal window
dotnet add package Granit.Persistence
dotnet add package Granit.Security
dotnet add package Granit.Guids
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL

Granit.Persistence brings in Granit.Core and Granit.Timing transitively, so you do not need to add those manually.

Replace the plain TaskItem class with one that inherits from FullAuditedEntity. This gives you Id, CreatedAt, CreatedBy, ModifiedAt, ModifiedBy, IsDeleted, DeletedAt, and DeletedBy for free.

Models/TaskItem.cs
using Granit.Core.Domain;
public sealed class TaskItem : FullAuditedEntity
{
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public bool IsCompleted { get; set; }
}

Add a Data/TaskDbContext.cs file:

Data/TaskDbContext.cs
using Granit.Core.DataFiltering;
using Granit.Core.MultiTenancy;
using Granit.Persistence.Extensions;
using Microsoft.EntityFrameworkCore;
public sealed class TaskDbContext(
DbContextOptions<TaskDbContext> options,
ICurrentTenant? currentTenant = null,
IDataFilter? dataFilter = null) : DbContext(options)
{
public DbSet<TaskItem> Tasks => Set<TaskItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<TaskItem>(e =>
{
e.ToTable("tasks");
e.Property(x => x.Title).HasMaxLength(200).IsRequired();
e.Property(x => x.Description).HasMaxLength(2000);
});
modelBuilder.ApplyGranitConventions(currentTenant, dataFilter);
}
}

The ApplyGranitConventions call at the end of OnModelCreating registers query filters for soft delete, multi-tenancy, and other cross-cutting concerns. Never add manual HasQueryFilter calls — the conventions handle all standard filters centrally.

Declare the persistence dependency and register the DbContext:

TaskManagementModule.cs
using Granit.Core.Modularity;
using Granit.Persistence;
using Granit.Persistence.Extensions;
using Granit.Timing;
using Microsoft.EntityFrameworkCore;
[DependsOn(typeof(GranitTimingModule))]
[DependsOn(typeof(GranitPersistenceModule))]
public sealed class TaskManagementModule : GranitModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddGranitDbContext<TaskDbContext>(options =>
{
options.UseNpgsql(
context.Configuration.GetConnectionString("Default"));
});
}
}

AddGranitDbContext<T> wires up the four interceptors (AuditedEntityInterceptor, VersioningInterceptor, DomainEventDispatcherInterceptor, SoftDeleteInterceptor) automatically. You do not need to resolve them yourself.

In appsettings.Development.json:

{
"ConnectionStrings": {
"Default": "Host=localhost;Database=task_management;Username=postgres;Password=postgres"
}
}

Define DTOs for the API. Input bodies use the Request suffix, return types use Response. Never expose EF entities directly.

Endpoints/TaskContracts.cs
public sealed record CreateTaskRequest(string Title, string? Description);
public sealed record UpdateTaskRequest(string Title, string? Description, bool IsCompleted);
public sealed record TaskResponse(
Guid Id,
string Title,
string? Description,
bool IsCompleted,
DateTimeOffset CreatedAt,
string CreatedBy);

Add Endpoints/TaskEndpoints.cs with the two-level pattern: a public extension method that creates the route group, and an internal class that contains the handlers.

Endpoints/TaskEndpoints.cs
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
public static class TaskEndpointsExtensions
{
public static IEndpointRouteBuilder MapTaskEndpoints(
this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/tasks")
.WithTags("Tasks");
TaskRoutes.Map(group);
return endpoints;
}
}
internal static class TaskRoutes
{
internal static void Map(RouteGroupBuilder group)
{
group.MapGet("/", ListAsync);
group.MapGet("/{id:guid}", GetByIdAsync);
group.MapPost("/", CreateAsync);
group.MapPut("/{id:guid}", UpdateAsync);
group.MapDelete("/{id:guid}", DeleteAsync);
}
private static async Task<Ok<List<TaskResponse>>> ListAsync(
TaskDbContext db,
CancellationToken cancellationToken)
{
var tasks = await db.Tasks
.Select(t => new TaskResponse(
t.Id, t.Title, t.Description,
t.IsCompleted, t.CreatedAt, t.CreatedBy))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return TypedResults.Ok(tasks);
}
private static async Task<Results<Ok<TaskResponse>, NotFound>> GetByIdAsync(
Guid id,
TaskDbContext db,
CancellationToken cancellationToken)
{
var task = await db.Tasks
.Where(t => t.Id == id)
.Select(t => new TaskResponse(
t.Id, t.Title, t.Description,
t.IsCompleted, t.CreatedAt, t.CreatedBy))
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return task is not null
? TypedResults.Ok(task)
: TypedResults.NotFound();
}
private static async Task<Created<TaskResponse>> CreateAsync(
CreateTaskRequest request,
TaskDbContext db,
CancellationToken cancellationToken)
{
var task = new TaskItem
{
Title = request.Title,
Description = request.Description
};
db.Tasks.Add(task);
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
var response = new TaskResponse(
task.Id, task.Title, task.Description,
task.IsCompleted, task.CreatedAt, task.CreatedBy);
return TypedResults.Created($"/api/tasks/{task.Id}", response);
}
private static async Task<Results<NoContent, NotFound>> UpdateAsync(
Guid id,
UpdateTaskRequest request,
TaskDbContext db,
CancellationToken cancellationToken)
{
var task = await db.Tasks
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken)
.ConfigureAwait(false);
if (task is null)
return TypedResults.NotFound();
task.Title = request.Title;
task.Description = request.Description;
task.IsCompleted = request.IsCompleted;
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return TypedResults.NoContent();
}
private static async Task<Results<NoContent, NotFound>> DeleteAsync(
Guid id,
TaskDbContext db,
CancellationToken cancellationToken)
{
var task = await db.Tasks
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken)
.ConfigureAwait(false);
if (task is null)
return TypedResults.NotFound();
db.Tasks.Remove(task);
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return TypedResults.NoContent();
}
}

In Program.cs, after var app = builder.Build();:

app.MapTaskEndpoints();

Install the EF Core tools if you have not already, then generate the initial migration and apply it:

Terminal window
dotnet tool install --global dotnet-ef
dotnet ef migrations add InitialCreate
dotnet ef database update

Start the application:

Terminal window
dotnet run

Test the API with curl:

Terminal window
curl -s -X POST http://localhost:5000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Write docs", "description": "Finish the persistence tutorial"}'

You never wrote a single line of audit code. The interceptors fire on every SaveChanges call, regardless of which endpoint triggered it.

InterceptorTriggerEffect
AuditedEntityInterceptorEntityState.AddedSets CreatedAt, CreatedBy, assigns a sequential GUID to Id if empty, injects TenantId on IMultiTenant entities
AuditedEntityInterceptorEntityState.ModifiedSets ModifiedAt, ModifiedBy, protects CreatedAt/CreatedBy from accidental overwrite
SoftDeleteInterceptorEntityState.DeletedConverts DELETE to UPDATE: sets IsDeleted = true, DeletedAt, DeletedBy

The API is open to everyone right now. In the next tutorial, you will lock it down with JWT Bearer authentication and Keycloak integration.

Adding Authentication