Read Model Queryable Pattern
Read models provide a safe, controlled way to query data from your PearDrop application without exposing database implementation details or creating security vulnerabilities.
The Problem Read Models Solve
When you allow direct access to IQueryable or database contexts:
❌ Security Issues
- Developers might accidentally query tenant data they shouldn't access
- Complex authorization rules scattered across query code
- Hard to audit what data is being accessed
❌ Performance Issues
- No way to prevent expensive queries (N+1 problems, huge result sets)
- Developers might accidentally load entire datasets
❌ Maintenance Issues
- Changes to database schema break all consuming code
- Authorization logic duplicated everywhere
Read Models solve this by providing a safe, audited queryable interface that automatically handles tenant isolation, authorization, and performance safeguards.
How Read Models Work
Architecture
Your Query Handler
↓
IMyReadModels (Interface - what you inject)
↓
IReadModelQueryable<T> (Safe queryable wrapper)
↓
DbContext (Internal - never exposed)
Key Points:
- You inject the interface (
IMyReadModels), not the database context - You call LINQ methods on
IReadModelQueryable<T>- not rawIQueryable - Everything happens automatically: tenant filtering, authorization, performance limits
- Database implementation is completely hidden
Built-In Safety Features
✅ Automatic Tenant Isolation - Current tenant filters applied to every query
✅ Expression Validation - Only approved properties can be queried
✅ Include Whitelisting - Only pre-approved related data can be loaded
✅ Performance Limits - Max result sizes prevent accidental large queries
Using Read Models
Basic Query
public class GetUsersQueryHandler(
IAuthReadModels authReadModels) : IQueryProcessor<GetUsersQuery, GetUsersQueryResult>
{
public async Task<QueryResult<GetUsersQueryResult>> Handle(
GetUsersQuery request,
CancellationToken cancellationToken = default)
{
// Query returns only current tenant's users - done automatically
var users = await authReadModels.Users
.Where(u => !u.IsDisabled)
.OrderBy(u => u.ContactEmailAddress)
.ToListAsync(cancellationToken);
return QueryResult<GetUsersQueryResult>.Succeeded(
new GetUsersQueryResult(users));
}
}
Query with Related Data
// Load related data using pre-approved includes
var usersWithRoles = await authReadModels.Users
.Include(u => u.Roles)
.Include(u => u.Profile)
.Where(u => u.WhenCreated >= startDate)
.OrderBy(u => u.ContactEmailAddress)
.ToListAsync(cancellationToken);
Only includes in the metadata whitelist work - trying to include unapproved data raises an exception.
Projections (Select)
// Transform to DTOs for API responses
var userSummaries = await authReadModels.Users
.Where(u => !u.IsDisabled)
.OrderBy(u => u.ContactEmailAddress)
.Select(u => new UserSummaryDto
{
Id = u.Id,
Email = u.ContactEmailAddress,
FullName = $"{u.Profile.FirstName} {u.Profile.LastName}",
IsLocked = u.WhenLocked.HasValue
})
.ToListAsync(cancellationToken);
Pagination
// Common pagination pattern
var pageSize = 50;
var pageNumber = request.Page;
var skip = (pageNumber - 1) * pageSize;
var users = await authReadModels.Users
.Where(u => !u.IsDisabled)
.OrderBy(u => u.ContactEmailAddress)
.Skip(skip)
.Take(pageSize)
.ToListAsync(cancellationToken);
var totalCount = await authReadModels.Users
.Where(u => !u.IsDisabled)
.CountAsync(cancellationToken);
return new PaginatedResult<UserSummaryDto>(
items: users,
totalCount: totalCount,
pageNumber: pageNumber,
pageSize: pageSize);
Large Result Sets
For queries that might return many rows:
// Stream results instead of loading all into memory
var allUsers = new List<UserSummaryDto>();
await foreach (var user in authReadModels.Users
.Where(u => u.WhenCreated >= startDate)
.OrderBy(u => u.Id)
.AsAsyncEnumerable()
.WithCancellation(cancellationToken))
{
allUsers.Add(new UserSummaryDto
{
Id = user.Id,
Email = user.ContactEmailAddress,
FullName = $"{user.Profile.FirstName} {user.Profile.LastName}"
});
}
Creating Read Models for Your Module
Step 1: Define the Queryable Entity
// Location: Domain/{BoundedContext}/ or Contracts/
// File: QueryableTask.cs
namespace MyApp.Domain.Tasks.Contracts;
public sealed class QueryableTask : IProjection
{
public required Guid Id { get; init; }
public required string Title { get; init; }
public required string Status { get; init; }
public required DateTime CreatedAt { get; init; }
public DateTime? CompletedAt { get; init; }
// Navigation properties
public Guid UserId { get; init; }
public QueryableUser User { get; init; } = null!;
}
Key Points:
- Implement
IProjectioninterface - Properties are
initonly - read-only after creation - No domain methods - just properties
- Navigation properties are optional
Step 2: Create Read Models Interface
// Location: Contracts/
// File: ITasksReadModels.cs
namespace MyApp.Contracts;
public interface ITasksReadModels : IModuleReadModels
{
IReadModelQueryable<QueryableTask> Tasks { get; }
IReadModelQueryable<QueryableUser> Users { get; }
}
Step 3: Implement the Service
// Location: Server/ReadModels/
// File: TasksReadModels.cs
namespace MyApp.Server.ReadModels;
public sealed class TasksReadModels : ModuleReadModelsBase<TasksReadDbContext>,
ITasksReadModels
{
private readonly Lazy<IReadModelQueryable<QueryableTask>> tasks;
private readonly Lazy<IReadModelQueryable<QueryableUser>> users;
public TasksReadModels(
IDbContextFactory<TasksReadDbContext> dbContextFactory,
IMultiTenantContextAccessor tenantContextAccessor,
IReadModelExpressionValidator expressionValidator)
: base(dbContextFactory, tenantContextAccessor, expressionValidator)
{
// Lazy initialization - DB context only created when first queried
this.tasks = new Lazy<IReadModelQueryable<QueryableTask>>(() =>
this.CreateQueryable<QueryableTask>(new ReadModelMetadata
{
EntityName = nameof(QueryableTask),
RequiresTenantIsolation = true, // Automatic tenant filtering
MaxTakeSize = 1000, // Prevent massive queries
AllowedIncludes = new HashSet<string>
{
nameof(QueryableTask.User), // Only these can be included
}
}));
this.users = new Lazy<IReadModelQueryable<QueryableUser>>(() =>
this.CreateQueryable<QueryableUser>(new ReadModelMetadata
{
EntityName = nameof(QueryableUser),
RequiresTenantIsolation = true,
MaxTakeSize = 5000,
AllowedIncludes = new HashSet<string>() // No includes allowed
}));
}
public override string ModuleName => "MyModule";
// Properties expose the lazy-initialized queryables
public IReadModelQueryable<QueryableTask> Tasks => this.tasks.Value;
public IReadModelQueryable<QueryableUser> Users => this.users.Value;
}
Configuration Details:
RequiresTenantIsolation: true- Automatically filters by current tenantMaxTakeSize- Maximum results allowed (prevents accidental huge queries)AllowedIncludes- Whitelist of navigation properties that can be includedLazy<>()- DB context only created when first query runs
Step 4: Register in DI
// In your ServiceCollectionExtensions.cs
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddMyModule(
this IServiceCollection services,
IConfiguration configuration)
{
// Register the read models interface
services.AddScoped<ITasksReadModels, TasksReadModels>();
// Register DbContext for read models
services.AddPearDropSqlServerReadDbContextFactory<TasksReadDbContext>(
"MyApp.Tasks.ReadModel");
return services;
}
}
Single-Tenant Apps
For single-tenant applications, set RequiresTenantIsolation: false:
this.tasks = new Lazy<IReadModelQueryable<QueryableTask>>(() =>
this.CreateQueryable<QueryableTask>(new ReadModelMetadata
{
EntityName = nameof(QueryableTask),
RequiresTenantIsolation = false, // No tenant filtering
MaxTakeSize = 1000,
AllowedIncludes = new HashSet<string> { nameof(QueryableTask.User) }
}));
Best Practices
1. Single Responsibility
Each IReadModels service should focus on one bounded context:
// ✅ Good - Clear responsibility
public interface ITasksReadModels : IModuleReadModels
{
IReadModelQueryable<QueryableTask> Tasks { get; }
IReadModelQueryable<QueryableProject> Projects { get; }
}
// ❌ Wrong - Mixing concerns
public interface IReadModels : IModuleReadModels
{
IReadModelQueryable<QueryableTask> Tasks { get; }
IReadModelQueryable<QueryableUser> Users { get; }
IReadModelQueryable<QueryableEquipment> Equipment { get; }
// ... 20 more entities
}
2. Lean Queryable Entities
Keep queryable entities simple - just properties:
// ✅ Good - Simple, query-optimized
public sealed class QueryableTask : IProjection
{
public required Guid Id { get; init; }
public required string Title { get; init; }
public required string Status { get; init; }
public DateTime? CompletedAt { get; init; }
}
// ❌ Wrong - Domain logic in queryable
public sealed class QueryableTask : IProjection
{
public required Guid Id { get; init; }
public required string Title { get; init; }
public TaskStatus Status { get; init; }
public bool IsOverdue => DateTime.UtcNow > DueAt; // ❌ Computed
public void Complete() { } // ❌ Methods
}
3. Whitelist Includes Carefully
Only approve includes that are commonly needed:
AllowedIncludes = new HashSet<string>
{
nameof(QueryableTask.User), // ✅ Yes - commonly needed
nameof(QueryableTask.Project), // ✅ Yes - commonly needed
// DON'T include large collections like Tasks, History, etc.
}
4. Set Realistic MaxTakeSize
// Calculate based on expected usage:
// - List all users: 5000 should be enough
// - Search results: 1000 per page
// - Admin reports: Maybe 10000
MaxTakeSize = 5000, // Reasonable default
Comparison: Before vs After
Before (Direct DbContext Access)
// ❌ DbContext exposed - authorization scattered everywhere
public class TaskQueryHandler(TaskDbContext context)
{
public async Task<List<TaskDto>> GetTasksAsync(Guid userId)
{
// Authorization logic here
if (!await HasPermissionAsync(userId)) return null!;
// Query without tenant filtering - oops!
var tasks = await context.Tasks
.Where(t => t.UserId == userId)
.ToListAsync(); // Could load 1M rows!
// Easier to accidentally join unvetted tables
return tasks.Select(...).ToList();
}
}
After (Read Models)
// ✅ Interface - automatic security, performance, tenant isolation
public class TaskQueryHandler(ITasksReadModels readModels)
{
public async Task<List<TaskDto>> GetTasksAsync(Guid userId)
{
// Authorization in parent handler layer
// Tenant filtering automatic
// MaxTakeSize prevents huge queries
var tasks = await readModels.Tasks
.Where(t => t.UserId == userId)
.ToListAsync();
return tasks.Select(...).ToList();
}
}
Benefits:
- ✅ No DbContext leakage
- ✅ Consistent authorization rules
- ✅ Performance safeguards built in
- ✅ Tenant isolation guaranteed
- ✅ Easy to audit data access