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.
Install packages
Section titled “Install packages”Add the persistence stack and the PostgreSQL provider:
dotnet add package Granit.Persistencedotnet add package Granit.Securitydotnet add package Granit.Guidsdotnet add package Npgsql.EntityFrameworkCore.PostgreSQLGranit.Persistence brings in Granit.Core and Granit.Timing transitively, so you
do not need to add those manually.
Update the domain entity
Section titled “Update the domain entity”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.
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; }}Create the DbContext
Section titled “Create the DbContext”Add a Data/TaskDbContext.cs file:
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.
Update the module
Section titled “Update the module”Declare the persistence dependency and register the DbContext:
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.
Add the connection string
Section titled “Add the connection string”In appsettings.Development.json:
{ "ConnectionStrings": { "Default": "Host=localhost;Database=task_management;Username=postgres;Password=postgres" }}Create request and response types
Section titled “Create request and response types”Define DTOs for the API. Input bodies use the Request suffix, return types use
Response. Never expose EF entities directly.
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);Create CRUD endpoints
Section titled “Create CRUD endpoints”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.
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(); }}Wire the endpoints
Section titled “Wire the endpoints”In Program.cs, after var app = builder.Build();:
app.MapTaskEndpoints();Create and apply migrations
Section titled “Create and apply migrations”Install the EF Core tools if you have not already, then generate the initial migration and apply it:
dotnet tool install --global dotnet-efdotnet ef migrations add InitialCreatedotnet ef database updateStart the application:
dotnet runTest the API with curl:
curl -s -X POST http://localhost:5000/api/tasks \ -H "Content-Type: application/json" \ -d '{"title": "Write docs", "description": "Finish the persistence tutorial"}'curl -s http://localhost:5000/api/tasks | jqcurl -s -X DELETE http://localhost:5000/api/tasks/{id}The row remains in the tasks table with is_deleted = true.
What the interceptors do
Section titled “What the interceptors do”You never wrote a single line of audit code. The interceptors fire on every
SaveChanges call, regardless of which endpoint triggered it.
| Interceptor | Trigger | Effect |
|---|---|---|
AuditedEntityInterceptor | EntityState.Added | Sets CreatedAt, CreatedBy, assigns a sequential GUID to Id if empty, injects TenantId on IMultiTenant entities |
AuditedEntityInterceptor | EntityState.Modified | Sets ModifiedAt, ModifiedBy, protects CreatedAt/CreatedBy from accidental overwrite |
SoftDeleteInterceptor | EntityState.Deleted | Converts DELETE to UPDATE: sets IsDeleted = true, DeletedAt, DeletedBy |
Next step
Section titled “Next step”The API is open to everyone right now. In the next tutorial, you will lock it down with JWT Bearer authentication and Keycloak integration.