Database Development Guide
This guide covers database development patterns and practices in Wallow. The platform uses PostgreSQL as its primary database, with EF Core as the only data-access technology in use.
Overview
| Approach | Technology | Use Case | Modules |
|---|---|---|---|
| Writes | EF Core + PostgreSQL | CRUD, change tracking, domain events | Identity, Storage, Notifications, Announcements, Inquiries, ApiKeys, Branding |
| Reads | EF Core NoTracking via IReadDbContext<T> |
Projections, reporting, replica routing | The same seven modules |
All modules share a single PostgreSQL instance but use separate schemas for isolation.
There is no second data-access stack. Reach for
IReadDbContext<T>first; if you genuinely need raw SQL, use EF Core'sFromSql/ExecuteSqlrather than reintroducing one, and remember that tenant query filters do not apply to raw SQL.Dapperis named in the Domain and Application forbidden-dependency lists inWallow.Architecture.Tests, which is the only place the word still appears inapi/.
EF Core Usage
DbContext Pattern
Each module has its own DbContext with automatic multi-tenancy filtering:
// api/src/Modules/Notifications/Wallow.Notifications.Infrastructure/Persistence/NotificationsDbContext.cs
public sealed class NotificationsDbContext : TenantAwareDbContext<NotificationsDbContext>
{
// Email
public DbSet<EmailMessage> EmailMessages => Set<EmailMessage>();
public DbSet<EmailPreference> EmailPreferences => Set<EmailPreference>();
// SMS
public DbSet<SmsMessage> SmsMessages => Set<SmsMessage>();
public DbSet<SmsPreference> SmsPreferences => Set<SmsPreference>();
// InApp Notifications
public DbSet<Notification> Notifications => Set<Notification>();
// Preferences
public DbSet<ChannelPreference> ChannelPreferences => Set<ChannelPreference>();
// Push
public DbSet<DeviceRegistration> DeviceRegistrations => Set<DeviceRegistration>();
public DbSet<TenantPushConfiguration> TenantPushConfigurations => Set<TenantPushConfiguration>();
public DbSet<PushMessage> PushMessages => Set<PushMessage>();
public NotificationsDbContext(DbContextOptions<NotificationsDbContext> options)
: base(options)
{
ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("notifications");
modelBuilder.ApplyConfigurationsFromAssembly(typeof(NotificationsDbContext).Assembly);
// Inherited from TenantAwareDbContext: automatic tenant filtering for all ITenantScoped entities
ApplyTenantQueryFilters(modelBuilder);
}
}
Entity Configuration with Fluent API
Entity configurations are stored separately in the Persistence/Configurations folder:
// api/src/Modules/Notifications/Wallow.Notifications.Infrastructure/Persistence/Configurations/NotificationConfiguration.cs
public sealed class NotificationConfiguration : IEntityTypeConfiguration<Notification>
{
public void Configure(EntityTypeBuilder<Notification> builder)
{
builder.ToTable("notifications");
// Primary key with strongly-typed ID
builder.HasKey(n => n.Id);
builder.Property(n => n.Id)
.HasConversion(new StronglyTypedIdConverter<NotificationId>())
.HasColumnName("id")
.ValueGeneratedNever();
// Tenant ID (required for multi-tenancy)
builder.Property(n => n.TenantId)
.HasConversion(new StronglyTypedIdConverter<TenantId>())
.HasColumnName("tenant_id")
.IsRequired();
builder.HasIndex(n => n.TenantId);
builder.Property(n => n.UserId)
.HasColumnName("user_id")
.IsRequired();
builder.Property(n => n.Type)
.HasColumnName("type")
.HasConversion<string>()
.HasMaxLength(50)
.IsRequired();
builder.Property(n => n.Title)
.HasColumnName("title")
.HasMaxLength(200)
.IsRequired();
builder.Property(n => n.Message)
.HasColumnName("message")
.HasMaxLength(1000)
.IsRequired();
builder.Property(n => n.IsRead)
.HasColumnName("is_read")
.IsRequired();
builder.Property(n => n.IsArchived)
.HasColumnName("is_archived")
.HasDefaultValue(false)
.IsRequired();
// Ignore domain events (not persisted)
builder.Ignore(n => n.DomainEvents);
// Indexes
builder.HasIndex(n => n.UserId);
builder.HasIndex(n => n.CreatedAt);
}
}
Strongly-Typed IDs
Wallow uses strongly-typed IDs to prevent mixing different entity IDs:
// Define ID type in Domain layer
public readonly record struct NotificationId(Guid Value) : IStronglyTypedId<NotificationId>
{
public static NotificationId New() => new(Guid.NewGuid());
public static NotificationId Create(Guid value) => new(value);
}
// Generic converter for EF Core
public class StronglyTypedIdConverter<TId> : ValueConverter<TId, Guid>
where TId : struct, IStronglyTypedId<TId>
{
public StronglyTypedIdConverter()
: base(
id => id.Value,
guid => TId.Create(guid))
{
}
}
Multi-Tenancy
All tenant-scoped entities implement ITenantScoped:
public interface ITenantScoped
{
TenantId TenantId { get; set; }
}
The DbContext automatically applies query filters based on the current tenant context. This ensures complete data isolation between tenants without explicit filtering in every query.
Repository Pattern
Repositories abstract data access and follow this structure:
Interface (Application Layer):
// api/src/Modules/Notifications/Wallow.Notifications.Application/Channels/InApp/Interfaces/INotificationRepository.cs
public interface INotificationRepository
{
void Add(Notification notification);
Task<Notification?> GetByIdAsync(NotificationId id, CancellationToken cancellationToken = default);
Task<PagedResult<Notification>> GetByUserIdPagedAsync(
Guid userId, int page, int pageSize, CancellationToken cancellationToken = default);
Task<int> GetUnreadCountAsync(Guid userId, CancellationToken cancellationToken = default);
Task MarkAllAsReadAsync(Guid userId, DateTime readAt, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
Implementation (Infrastructure Layer):
// api/src/Modules/Notifications/Wallow.Notifications.Infrastructure/Persistence/Repositories/NotificationRepository.cs
public sealed class NotificationRepository(NotificationsDbContext context) : INotificationRepository
{
public void Add(Notification notification)
{
context.Notifications.Add(notification);
}
public Task<Notification?> GetByIdAsync(NotificationId id, CancellationToken cancellationToken = default)
{
return context.Notifications
.AsTracking()
.FirstOrDefaultAsync(n => n.Id == id, cancellationToken);
}
public async Task<PagedResult<Notification>> GetByUserIdPagedAsync(
Guid userId, int page, int pageSize, CancellationToken cancellationToken = default)
{
DateTime utcNow = DateTime.UtcNow;
IQueryable<Notification> query = context.Notifications
.Where(n => n.UserId == userId && !n.IsArchived && (n.ExpiresAt == null || n.ExpiresAt > utcNow))
.OrderByDescending(n => n.CreatedAt);
int totalCount = await query.CountAsync(cancellationToken);
List<Notification> items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return new PagedResult<Notification>(items, totalCount, page, pageSize);
}
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
await context.SaveChangesAsync(cancellationToken);
}
}
Query Patterns: the write context vs. the read context
Use the tenant-scoped write context for:
- Standard CRUD operations
- Loading entities with related data
- Write operations that need change tracking
Use IReadDbContext<T> for:
- Reporting queries and aggregations
- Read-heavy list endpoints and projections
- Anything that should route to a read replica when one is configured
Read-optimized query via IReadDbContext:
For reporting queries, the codebase uses IReadDbContext<TContext> which provides a read-only DbContext instance (potentially routed to a read replica):
// Example read-optimized query using IReadDbContext
public sealed class NotificationReportService(IReadDbContext<NotificationsDbContext> readDbContext) : INotificationReportService
{
public async Task<IReadOnlyList<NotificationSummaryRow>> GetNotificationsAsync(
DateTime from, DateTime to, CancellationToken ct = default)
{
return await readDbContext.Context.Notifications
.Where(n => n.CreatedAt >= from && n.CreatedAt < to)
.OrderByDescending(n => n.CreatedAt)
.Select(n => new NotificationSummaryRow(n.Id, n.Title, n.Type, n.CreatedAt))
.ToListAsync(ct);
}
}
For an aggregation LINQ cannot express, drop to EF Core's FromSql on the read context rather than adding another data-access library — and filter by tenant_id yourself, because global query filters do not reach raw SQL.
Database Schema Management
Module Schema Separation
Each module uses its own PostgreSQL schema:
| Module | Schema |
|---|---|
| Identity | identity |
| Storage | storage |
| Notifications | notifications |
| Announcements | announcements |
| Inquiries | inquiries |
| ApiKeys | apikeys |
| Branding | branding |
| Auth Audit (Shared) | auth_audit |
The shared auth-audit context (AuthAuditDbContext) belongs to no module, so it is the
only one still registered by hand in api/src/Wallow.MigrationService/Program.cs. The seven module
contexts come from ModuleMigrations.AddModuleDbContexts, which walks WallowModuleRegistry.All and
registers each module's DbContextTypes against its own SchemaName.
A module writes its schema name once, as an internal const string Schema on its IWallowModule
implementation; the DbContext and every MigrationsHistoryTable call refer to that constant:
modelBuilder.HasDefaultSchema(NotificationsModule.Schema);
Connection String Configuration
appsettings.Development.json:
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=wallow;Username=wallow;Password=wallow;SSL Mode=Disable",
"Redis": "localhost:6379,password=WallowValkey123!,abortConnect=false"
}
}
EF Core Migrations
Create migrations per module:
# Create a new migration
dotnet ef migrations add MigrationName \
--project api/src/Modules/Notifications/Wallow.Notifications.Infrastructure \
--startup-project api/src/Wallow.Api \
--context NotificationsDbContext
# Apply migrations
dotnet ef database update \
--project api/src/Modules/Notifications/Wallow.Notifications.Infrastructure \
--startup-project api/src/Wallow.Api \
--context NotificationsDbContext
Migrations do not run when the API starts, and modules have no startup hook to migrate from —
the per-module Initialize{Module}ModuleAsync methods were all no-ops and have been deleted.
Migrations are applied by the separate Wallow.MigrationService worker
(api/src/Wallow.MigrationService/), which migrates every module DbContext and then exits.
Under Aspire (pnpm backend) it runs as the wallow-migrations resource and both the seeder
and the API wait for it to complete; in Docker it is the wallow-migrations service that app
services depend on with condition: service_completed_successfully.
The single exception is the Testing environment, where
InitializeWallowModulesAsync in api/src/Wallow.Api/WallowModules.cs migrates inline
because the test factory spins up a fresh Postgres container and the migration service is not
available:
if (app.Environment.IsEnvironment("Testing"))
{
await RunTestMigrationsAsync(app.Services, enabledModules);
}
enabledModules is the exact set AddWallowModules registered, so the inline path migrates each
enabled module's DbContextTypes plus the two host-owned auditing contexts — a new module needs no
line added here.
See Database Migrations for the full picture.
Local Development Setup
Docker Infrastructure
Start required services:
pnpm backend:infra # = cd docker && docker compose up -d
docker/docker-compose.yml defines eight services. Seven start by default:
- PostgreSQL 18 on port 5432
- Valkey (Redis-compatible) on port 6379
- GarageHQ (S3-compatible) on port 3900, admin API on 3903
- Mailpit on ports 1025 (SMTP) / 8025 (web UI)
- Grafana LGTM on port 3001
- Grafana Alloy (OTLP collector) on 4317 (gRPC) / 4318 (HTTP)
- Docs site (the built DocFX site behind nginx) on port 5004
The eighth, ClamAV on port 3310, sits behind the clamav Compose profile — start it with
cd docker && docker compose --profile clamav up -d.
Database Access
# Connect via psql
docker exec -it wallow-postgres psql -U wallow -d wallow
# List schemas
\dn
# List tables in a schema
\dt notifications.*
Best Practices
When to Use Each Technology
| Scenario | Technology | Reasoning |
|---|---|---|
| Simple CRUD | EF Core write context | Standard patterns, change tracking |
| Complex joins/aggregates | IReadDbContext<T> projection, or FromSql if LINQ cannot express it |
No tracking overhead; replica-routed |
| Audit-critical data | EF Core + Audit interceptor | Interceptor-based audit trail in Shared.Infrastructure.Core |
| High-throughput reads | IReadDbContext<T> + Materialized Views |
Read replica plus a precomputed shape |
Performance Considerations
- Indexes - Always add indexes for foreign keys and frequently queried columns
- JSONB queries - Create GIN indexes for JSONB columns when querying inside JSON
- Eager loading - Use
.Include()to avoid N+1 queries - Projections - Keep projections focused; create specialized projections for different query patterns
- Connection pooling - Let the connection string configure pooling appropriately
Transaction Handling
EF Core:
// Implicit transaction via SaveChangesAsync
context.Notifications.Add(notification);
context.EmailMessages.Add(emailMessage);
await context.SaveChangesAsync(ct); // Single transaction
Cross-DbContext (use with caution):
using IDbContextTransaction transaction = await notificationsContext.Database.BeginTransactionAsync();
try
{
notificationsContext.Notifications.Add(notification);
await notificationsContext.SaveChangesAsync();
// Other operations...
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
Testing Patterns
Integration tests with Testcontainers:
Integration tests use WallowApiFactory which manages PostgreSQL and Valkey containers automatically. See the testing guide for details on WallowApiFactory and container lifecycle.
Quick Reference
File Locations
api/src/Modules/{Module}/
├── Wallow.{Module}.Domain/
│ ├── Entities/ # Domain entities
│ └── Enums/ # Domain enumerations
├── Wallow.{Module}.Application/
│ ├── Interfaces/ # Repository interfaces
│ └── Services/ # Domain services
└── Wallow.{Module}.Infrastructure/
├── Persistence/
│ ├── {Module}DbContext.cs
│ ├── Configurations/ # Entity configurations
│ └── Repositories/ # Repository implementations
├── Migrations/ # EF Core migrations
└── Services/ # Query services (read context projections)
Common Commands
# Run tests
./scripts/run-tests.sh
# Create migration
dotnet ef migrations add MigrationName \
--project api/src/Modules/{Module}/Wallow.{Module}.Infrastructure \
--startup-project api/src/Wallow.Api \
--context {Module}DbContext
# Start database
cd docker && docker compose up -d postgres
# View logs
docker logs -f wallow-postgres