Skip to main content

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 raw IQueryable
  • 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));
}
}
// 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 IProjection interface
  • Properties are init only - 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 tenant
  • MaxTakeSize - Maximum results allowed (prevents accidental huge queries)
  • AllowedIncludes - Whitelist of navigation properties that can be included
  • Lazy<>() - 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