In enterprise SaaS systems (especially in high-stakes legal, financial, and compliance domains), over 80% of high-frequency requests consist of online list paging and multi-dimensional filtering. Traditional architectures frequently suffer from two severe anti-patterns:
- The “Wide-Row In-Memory Deserialization” Anti-Pattern: Query handlers inject repositories to materialize full aggregate roots (containing heavy text columns, rich text, and JSON audit snapshots) into memory before mapping to DTOs via AutoMapper, resulting in massive GC pressure and database network I/O waste.
- The “Hardcoded String Table Alias” Fragile Anti-Pattern: In multi-table join queries, upper-layer DTO annotations hardcode physical table aliases (such as
t.CaseNo,t4.OppositeName). Whenever the underlying repository join order changes or a new join table is inserted, the entire query pipeline instantly crashes at runtime due to table alias misalignment.
BitzOrcas.Modern establishes a declarative query paradigm via ADR 0303 (Standard List Query Kernel and QueryShape): declarative metadata drives compile-time query plans, combining 100% type safety, AOT zero reflection, complete decoupling of physical table aliases, safe custom business condition extension, and optimal dual-ORM (SqlSugar / EF Core) projection pushdown.
1. Concept: What is QueryShape?
1.1 Why Forbid Injecting IRepository<T> in Query Handlers?
This legacy pattern suffers from three critical flaws:
- Broken Projection Pushdown: Hand-written repository calls easily pull unprojected entities, loading wide columns (e.g., full contract content, commentary history) directly into memory.
- Dual-ORM Behavioral Drift: Subtle differences in LINQ-to-SQL translation between SqlSugar and EF Core (such as null propagation or string methods) introduce runtime bugs and portability failures.
- Leaked Write Semantics:
IRepository.FindAsyncis a write-side aggregate restoration entry point (carrying change tracking or concurrency tokens) and must never be used for stateless read queries.
1.2 QueryShape Execution Lifecycle
QueryShape is a compile-time metadata contract declaring:
- Source / Root / Joins: The entity types for the root aggregate and joined relation tables;
- Target Row (ReadModel): The flat scalar projection model constructed directly in the database query;
- QueryFields: Whitelisted queryable fields, supported comparison operators, and entity member paths;
- Sorts: Public sortable columns and tie-breaker sorting rules.
2. Compile-Time Metadata Generation
Inside src/Framework/BitzOrcas.QueryShape.Generator, the Roslyn incremental source generator analyzes classes decorated with [ReadModelQuery]:
// 1. Declare root aggregate and projection row, locking unique QueryShape identifier[ReadModelQuery<CaseMatter, CaseMatterListRow>("Case.MatterList")]// 2. Declare multi-level deterministic sorting rules[ReadModelSort(nameof(SubmittedOn), Descending = true, Priority = 0)][ReadModelSort(nameof(MatterId), Descending = true, Priority = 1)]public sealed partial class CaseMatterListInput : IDeclareQueryShape{ // 3. Declare fuzzy search field generating SQL Contains [QueryField(SourcePath = nameof(CaseMatter.Title), DefaultOperator = FilterOperator.Contains)] public string? SearchText { get; init; }
// 4. Declare exact match status filter [QueryField(SourcePath = nameof(CaseMatter.Status), DefaultOperator = FilterOperator.Equal)] public MatterStatus? Status { get; init; }
// 5. Standard pagination parameters public int PageIndex { get; init; } public int PageSize { get; init; }}2.1 Four Core Compile-Time Guarantees
- Automatic
IDeclareQueryShapeImplementation: EmitsCaseMatterListInput.QueryShape.g.cswith staticQueryDescriptor, translating property mappings into pure Lambda trees without runtime reflection. - Single Parameter Normalization:
When combining
Wherefilters, the engine rewrites sub-expressions to share a single rootParameterExpressionviaParameterReplacementVisitor, producing cleanAndAlso/OrElsenodes and eliminating untranslatableExpression.Invokenodes. - Automated Tenancy and Soft Delete Pushdown:
For entities implementing
ITenantEntity, ORM global filters (SqlSugarAddTableFilterand EF CoreHasQueryFilter) automatically inject tenancy and soft-delete filters. Developers must never manually append!item.IsDeletedoritem.TenantId == tenantId. - Compile-Time Security Boundary:
Properties not explicitly decorated with
[QueryField]are rejected from the whitelist, preventing malicious query injection.
3. Multi-Table Join & Complete Table Alias Decoupling
3.1 Why DTO Annotations Never Need Physical Table Prefixes
In legacy query frameworks relying on string condition splicing (such as SqlSugar’s string ConditionalModel), DTO properties had to hardcode physical table aliases:
// DANGEROUS: tightly couples to Join order and alias t4[AdvancedWhere(FieldNames = new[] { "t4", nameof(CaseMatterRelatedParty.OppositeName) })]public string? OppositeName { get; set; }This pattern is fundamentally fragile: If requirements shift and developers insert another join (such as Office), the party table shifts from table 4 to table 5. If developers miss updating the t4 annotation, production queries break silently or return corrupted data.
3.2 The Decoupling Architecture: From Entity Paths to Adaptive AST
In BitzOrcas.Modern, DTO metadata references domain entities exclusively; prefixes like t., t1., or t4. are completely forbidden:
// Directly map to root aggregate or joined entity properties[QueryField(SourcePath = nameof(CaseMatter.ClientName))]public string? ClientName { get; init; }
[QueryField(SourcePath = nameof(CaseMatterRelatedParty.OppositeName))]public string? OppositeName { get; init; }Under the Hood Mechanism:
- Compile Time: The source generator emits typed single-source accessors bound to the entity type:
// Bound solely to entity type, completely independent of query join ordernew QueryFieldAccessor("matter", "ClientName", (Expression<Func<CaseMatter, string>>)(m => m.ClientName))
- Execution Time (
QueryShapeExpressionBuilder): When building a two-source query, the builder allocates parameter expressionsroot = Expression.Parameter(typeof(TRoot))andjoin = Expression.Parameter(typeof(TJoin)), weaving them into a strongly-typed two-parameter Lambda:Expression<Func<TRoot, TJoin, bool>>. - ORM Adaptive Resolution:
In SqlSugar,
SqlSugarJoinQueryShapeExecutorhands this Lambda directly toISugarQueryable<TRoot, TJoin>.Where(...). SqlSugar’s expression parser maps tables according to parameter types and positional indices, automatically emitting the correct SQL aliases ([t],[t1]). - Total Decoupling: Modifying the repository join order or adding join tables requires zero changes to DTOs, completely eliminating alias mismatch bugs.
4. Custom Conditions (basePredicate) and Out-of-Band Business Logic
While QueryShape automates 90% of standardized list queries, enterprise applications often encounter specialized boundaries:
- User Data Isolation (e.g., scoping matters assigned to the authenticated lead attorney);
- Adversary Conflict Checks (e.g., filtering out restricted counter-parties);
- Dynamic State Machine Logic (e.g., omitting deleted drafts or archived records).
BitzOrcas.Modern natively supports injecting strongly-typed basePredicate expressions into the QueryShape pipeline.
4.1 Single-Source and Two-Source basePredicate Examples
public async Task<Result<PagedResult<CaseMatterSummaryDto>>> SearchMattersAsync( CaseMatterListFields fields, PaginationParams paging, string currentLawyerId, bool includeRestrictedArchive, CancellationToken cancellationToken = default){ var input = CaseMatterListInput.From(fields, paging);
// Single-source query: inject strongly-typed single-entity predicate return await input.ExecutePageAsync( executorFactory, static row => new CaseMatterSummaryDto(row.MatterId, row.Title, row.ClientName, row.Status), basePredicate: matter => // 1. User scope isolation matter.LeadLawyerId == currentLawyerId // 2. Dynamic state machine logic && (includeRestrictedArchive || matter.Status != MatterStatus.Archived), cancellationToken: cancellationToken).ConfigureAwait(false);}// Two-source join: leverage two-parameter typed Lambda with 100% type safetyreturn await joinInput.ExecutePageAsync<CaseMatter, CaseMatterRelatedParty, CaseMatterSummaryDto>( joinExecutorFactory, static (matter, party) => new CaseMatterSummaryDto(matter.Id, matter.Title, party.OppositeName), // Custom cross-entity business conditions: zero string aliases needed basePredicate: (matter, party) => matter.OfficeId == currentOfficeId && (party.IsActive || party.RiskLevel < RiskLevel.High), cancellationToken: cancellationToken).ConfigureAwait(false);4.2 Why Custom basePredicate Remains 100% Robust
- Refactoring Type Safety: Written as C# Lambda expressions, entity property renames or type changes are validated by the compiler at build time.
- Strict Dual-ORM Parity:
- SqlSugar: Executed directly via
ISugarQueryable<TRoot, TJoin>.Where(basePredicate). - EF Core: Rebound to
row.Root.xxxandrow.Joined.xxxviaJoinRowParameterVisitor.
- SqlSugar: Executed directly via
- Seamless Merge with DTO Filters: The executor merges DTO filters and
basePredicateinto a single, unified SQLWHEREclause usingExpression.AndAlso.
5. Enterprise Golden Sample: Law Firm Matter Intake Search
Below is a complete, production-grade slice covering HTTP envelopes, narrow read store ports, QueryShape declarations, custom predicates, and scalar projections:
5.1 Application Layer: Query Envelope & Validation Rule
using BitzOrcas.Application.Abstractions;using BitzOrcas.Application.Authorization;using BitzOrcas.Case.Contracts;using BitzOrcas.Domain.Abstractions.Queries;using BitzOrcas.Domain.Results;using BitzOrcas.Endpoint.Attributes;using Mediator;
namespace BitzOrcas.Case.Application.Queries;
/// <summary>/// Paginated case matter search query command/// </summary>[GenerateEndpoint( HttpRoute.Query, "/api/cases/matters", Tag = "CaseMatter", QueryShapeName = "Case.MatterList")]public sealed record ListCaseMattersQuery( CaseMatterListFields Fields, PaginationParams Paging, SortRequest? Sort = null) : IQuery<Result<PagedResult<CaseMatterSummaryDto>>>, IAuthorizedRequest, IPaginatedRequest{ // Authorization metadata binding public ResourceDescriptor Resource { get; } = new(CasePermissions.Module, CasePermissions.MatterResource); public AuthorizationAction Action { get; } = AuthorizationAction.Read;}
/// <summary>/// Query input validation rule (Tier 1 Base Invariant)/// </summary>public sealed class ListCaseMattersQueryRule : IRequestRule<ListCaseMattersQuery>{ public ValueTask<Result> ValidateAsync(ListCaseMattersQuery request, CancellationToken cancellationToken) { // Prevent slow queries caused by excessively long search text if (request.Fields.SearchText is { Length: > CaseMatterListFields.SearchTextMaxLength }) { return ValueTask.FromResult(Result.Failure(CaseErrors.SearchTextTooLong)); }
// Validate page index if (request.Paging.PageIndex < 1) { return ValueTask.FromResult(Result.Failure(CaseErrors.InvalidPageIndex)); }
return ValueTask.FromResult(Result.Success()); }}
/// <summary>/// Query handler delegating typed envelope to narrow read store/// </summary>public sealed class ListCaseMattersQueryHandler(ICaseMatterReadModelStore store) : IQueryHandler<ListCaseMattersQuery, Result<PagedResult<CaseMatterSummaryDto>>>{ public async ValueTask<Result<PagedResult<CaseMatterSummaryDto>>> Handle( ListCaseMattersQuery request, CancellationToken cancellationToken) { // Delegate to narrow read store without exposing ORM details return await store.SearchAsync( request.Fields, request.Paging, request.Sort, cancellationToken).ConfigureAwait(false); }}5.2 Contracts Layer: Narrow Read Port & Business DTOs
using BitzOrcas.Domain.Abstractions.Queries;using BitzOrcas.Domain.Results;
namespace BitzOrcas.Case.Contracts;
/// <summary>/// Narrow read-only store port (forbids exposing broad repositories or IQueryable)/// </summary>public interface ICaseMatterReadModelStore{ Task<Result<PagedResult<CaseMatterSummaryDto>>> SearchAsync( CaseMatterListFields fields, PaginationParams paging, SortRequest? sort, CancellationToken cancellationToken = default);}
/// <summary>/// Business filtering fields package/// </summary>public sealed record CaseMatterListFields( string? SearchText = null, string? ClientName = null, MatterStatus? Status = null, DateRange? SubmittedRange = null){ public const int SearchTextMaxLength = 200;}
/// <summary>/// Projected read model DTO/// </summary>public sealed record CaseMatterSummaryDto( string MatterId, string SerialId, string Title, string ClientName, MatterStatus Status, DateTimeOffset SubmittedOn);5.3 Infrastructure Layer: QueryShape & ReadModelStore Implementation
using BitzOrcas.Application.Abstractions.Tenancy;using BitzOrcas.Case.Contracts;using BitzOrcas.Case.Domain;using BitzOrcas.DI.Attributes;using BitzOrcas.Domain.Abstractions.Queries;using BitzOrcas.Domain.Results;using BitzOrcas.Infrastructure.Queries;
namespace BitzOrcas.Case.Infrastructure;
[RegisterPersistenceAdapter<ICaseMatterReadModelStore>]public sealed class CaseMatterReadModelStore( IQueryShapeExecutorFactory executorFactory, ICurrentTenant currentTenant) : ICaseMatterReadModelStore{ public async Task<Result<PagedResult<CaseMatterSummaryDto>>> SearchAsync( CaseMatterListFields fields, PaginationParams paging, SortRequest? sort, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(fields); ArgumentNullException.ThrowIfNull(paging);
// Verify ambient tenant context if (!currentTenant.Tenant.IsAvailable) { return Result.Failure<PagedResult<CaseMatterSummaryDto>>(CaseErrors.TenantRequired); }
// 1. Perform single-point pagination normalization in From mapper var input = CaseMatterListInput.From(fields, paging);
// 2. Execute QueryShape pushdown query with custom predicate return await input.ExecutePageAsync( executorFactory, static row => new CaseMatterSummaryDto( row.MatterId, row.SerialId, row.Title, row.ClientName, row.Status, row.SubmittedOn), basePredicate: matter => matter.Status != MatterStatus.DeletedDraft, sorts: sort.WithTieBreaker(nameof(CaseMatterListInput.MatterId)), cancellationToken: cancellationToken).ConfigureAwait(false); }}
/// <summary>/// Case matter QueryShape declaration/// </summary>[ReadModelQuery<CaseMatter, CaseMatterListRow>("Case.MatterList")][ReadModelSort(nameof(SubmittedOn), Descending = true, Priority = 0)][ReadModelSort(nameof(MatterId), Descending = true, Priority = 1)]public sealed partial class CaseMatterListInput : IDeclareQueryShape{ public static CaseMatterListInput From(CaseMatterListFields fields, PaginationParams paging) { // Safe single-point normalization preventing HTTP 400 errors var normalized = paging.Normalize(); return new CaseMatterListInput { SearchText = string.IsNullOrWhiteSpace(fields.SearchText) ? null : fields.SearchText.Trim(), ClientName = string.IsNullOrWhiteSpace(fields.ClientName) ? null : fields.ClientName.Trim(), Status = fields.Status, SubmittedFrom = fields.SubmittedRange?.From, SubmittedTo = fields.SubmittedRange?.To, PageIndex = normalized.PageIndex, PageSize = normalized.PageSize, }; }
[QueryField(SourcePath = nameof(CaseMatter.Title), Default = true, DefaultOperator = FilterOperator.Contains)] public string? SearchText { get; init; }
[QueryField(SourcePath = nameof(CaseMatter.ClientName), DefaultOperator = FilterOperator.Contains)] public string? ClientName { get; init; }
[QueryField(SourcePath = nameof(CaseMatter.Status), DefaultOperator = FilterOperator.Equal)] public MatterStatus? Status { get; init; }
[QueryField(SourcePath = nameof(CaseMatter.SubmittedOn), DefaultOperator = FilterOperator.GreaterThanOrEqual)] public DateTimeOffset? SubmittedFrom { get; init; }
[QueryField(SourcePath = nameof(CaseMatter.SubmittedOn), DefaultOperator = FilterOperator.LessThanOrEqual)] public DateTimeOffset? SubmittedTo { get; init; }
[QueryField(Filterable = false, Visible = false)] public DateTimeOffset SubmittedOn { get; init; }
[QueryField(SourcePath = nameof(CaseMatter.Id), Filterable = false, Visible = false)] public string? MatterId { get; init; }
public int PageIndex { get; init; } public int PageSize { get; init; }}
/// <summary>/// Pure scalar projection row model/// </summary>internal sealed record CaseMatterListRow{ [ReadModelProjection(nameof(CaseMatter.Id))] public string MatterId { get; init; } = string.Empty;
public string SerialId { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public string ClientName { get; init; } = string.Empty;
public MatterStatus Status { get; init; }
public DateTimeOffset SubmittedOn { get; init; }}6. Unit Testing & HTTP Payloads
6.1 Unit Test Golden Sample: Validating QueryShape and Custom Predicates
using System.Linq.Expressions;using BitzOrcas.Case.Contracts;using BitzOrcas.Case.Domain;using BitzOrcas.Case.Infrastructure;using BitzOrcas.Domain.Abstractions.Queries;using BitzOrcas.Infrastructure.Queries;using Shouldly;using Xunit;
namespace BitzOrcas.Unit.Tests.Case;
public sealed class CaseMatterQueryShapeTests{ [Fact] public void CaseMatterListInput_Should_Declare_Accurate_QueryDescriptor_Without_Reflection() { // 1. Validate compile-time generated IDeclareQueryShape descriptor var descriptor = ((IDeclareQueryShape)new CaseMatterListInput()).QueryDescriptor; descriptor.ShouldNotBeNull(); descriptor.Shape.Name.ShouldBe("Case.MatterList");
// 2. Validate field whitelist and operator generation var titleField = descriptor.Shape.Fields.FirstOrDefault(f => f.InputField == nameof(CaseMatterListInput.SearchText)); titleField.ShouldNotBeNull(); titleField.Allows(FilterOperator.Contains).ShouldBeTrue();
// 3. Validate typed accessor availability var accessor = descriptor.ResolveAccessor("item", nameof(CaseMatter.Title)); accessor.IsSuccess.ShouldBeTrue(); }
[Fact] public void BasePredicate_Should_Correctly_Filter_Deleted_Draft_Matters() { // Validate custom basePredicate expression logic Expression<Func<CaseMatter, bool>> basePredicate = matter => matter.Status != MatterStatus.DeletedDraft; var compiled = basePredicate.Compile();
var activeMatter = new CaseMatter { Status = MatterStatus.Active }; var draftMatter = new CaseMatter { Status = MatterStatus.DeletedDraft };
// Assert business filtering behavior compiled(activeMatter).ShouldBeTrue(); compiled(draftMatter).ShouldBeFalse(); }}6.2 HTTP Request and Response JSON Payloads
{ "fields": { "searchText": "Intellectual Property", "status": "Active", "submittedRange": { "from": "2026-01-01T00:00:00Z", "to": "2026-12-31T23:59:59Z" } }, "paging": { "pageIndex": 1, "pageSize": 20 }, "sort": { "field": "submittedOn", "descending": true }}{ "isSuccess": true, "data": { "items": [ { "matterId": "matter-01912a3b-4c5d-7e8f-9a0b-1c2d3e4f5a6b", "serialId": "CASE-2026-0889", "title": "Cross-Border Patent Infringement First Instance", "clientName": "Apex Tech Solutions Inc.", "status": "Active", "submittedOn": "2026-08-20T14:30:00Z" } ], "totalCount": 1, "pageIndex": 1, "pageSize": 20, "totalPages": 1, "hasPreviousPage": false, "hasNextPage": false }}7. Pagination Normalization and Limits (Defense in Depth)
In production environments, frontends may occasionally send invalid parameters (e.g., pageIndex = 0 or pageSize = 5000). BitzOrcas.Modern enforces a Defense in Depth pagination normalization contract:
public sealed record PaginationParams( int PageIndex = 1, int PageSize = PagingLimits.DefaultPageSize){ // Execute safe boundary normalization public PaginationParams Normalize() => new( PagingLimits.NormalizePageIndex(PageIndex), PagingLimits.NormalizePageSize(PageSize));}7.1 PagingLimits Core Rules
| Scenario | Input | Normalized Output | Design Rationale |
|---|---|---|---|
| Non-positive page index | pageIndex <= 0 | 1 | Paging starts at 1, preventing negative SQL offset |
| Non-positive page size | pageSize <= 0 | 20 (DefaultPageSize) | Fall back to platform default |
| Valid page size | 1 <= pageSize <= 1000 | Unchanged | Normal operational range |
| Exceeding page size | pageSize > 1000 | 1000 (MaxPageSize) | Safely truncate to 1000 without returning 400 error! |
8. Summary & Best Practices Checklist
- Never hardcode physical table prefixes in DTOs: Avoid
t.ort1.in[QueryField]; declare pure aggregate root entity member paths. - Enforce Read/Write Segregation: Inject
I*ReadModelStorein query handlers; never expose broad repositories orIQueryable. - Accurate Scalar Projections: Use
ReadModelProjectionrow records to select exact columns, eliminating wide-row materialization. - Typed Custom Conditions: Inject out-of-band and scope logic through
basePredicateLambdas to maintain 100% compile-time type safety across both SqlSugar and EF Core.