Appearance
Navigable Entity Design
Overview
The Navigable Entity pattern provides previous/next record navigation for any entity view screen. When a user opens a record (invoice, customer, sales order, etc.), the API returns the Code and Id of the adjacent records so the frontend can render Previous / Next navigation buttons without an extra round trip.
Interfaces
INavigableEntity<TId> — Microtec.Domain.Interfaces.Navigation
csharp
public interface INavigableEntity<TId>
{
TId Id { get; }
string Code { get; }
DateTime CreatedOn { get; }
}| Member | Source | Purpose |
|---|---|---|
TId Id | Defined here | The primary key — generic to support both Guid and int entities |
string Code | The sequential code used to order and find adjacent records | |
DateTime CreatedOn | Defined here | Tie-breaker when two records share the same code |
Why generic? Sales entities use either
Guid(e.g.SalesInvoiceHeader) orint(e.g.Customer,PricePolicy) as their primary key. The genericTIdparameter allows the same navigation infrastructure to serve both without separate interfaces.
INavigationResolver — Microtec.Domain.Interfaces.Navigation
csharp
public interface INavigationResolver
{
Task<NavigationProperties> ResolveAsync<TEntity, TId>(
IQueryable<TEntity> filteredQuery,
TId currentId,
CancellationToken cancellationToken)
where TEntity : class, INavigableEntity<TId>;
}filteredQuery— the full scoped queryable for the entity (e.g. filtered by branch/tenant, but NOT filtered by the specific record Id).currentId— the Id of the currently open record; C# infersTIdautomatically from the call site.
NavigationProperties — Microtec.Domain.ValueObjects
csharp
public sealed record NavigationProperties(
string? PreviousId,
string? PreviousCode,
string? NextId,
string? NextCode);Ids are stored as string? so the same DTO can carry both Guid and int identifiers. The consumer parses the string back to the appropriate type.
How the Resolver Works (NavigationResolver)
Current record → find by Id → read Code + CreatedOn
Previous → last record where Code < current.Code
(or same Code and CreatedOn < current.CreatedOn)
Next → first record where Code > current.Code
(or same Code and CreatedOn > current.CreatedOn)The Id filter is built with an Expression tree so EF Core can translate it to SQL for any TId type:
csharp
private static Expression<Func<TEntity, bool>> BuildIdFilter<TEntity, TId>(TId id)
{
var parameter = Expression.Parameter(typeof(TEntity), "x");
var property = Expression.Property(parameter, "Id");
var constant = Expression.Constant(id, typeof(TId));
var equal = Expression.Equal(property, constant);
return Expression.Lambda<Func<TEntity, bool>>(equal, parameter);
}Domain Entity Setup
Any entity that needs navigation must:
- Implement
INavigableEntity<TId>with the correct key type. - Have a
string Code { get; }property (fromISequencialEntity). - Have a
DateTime CreatedOn { get; }property (from the base audit class).
Guid-keyed entities (use INavigableEntity<Guid>)
csharp
public class SalesInvoiceHeader : MultiTenantEntity<Guid>, INavigableEntity<Guid> { ... }Int-keyed entities (use INavigableEntity<int>)
csharp
public class Customer : MultiTenantEntity<int>, INavigableEntity<int> { ... }DTO Setup
Every GetById / View DTO must implement IHasNavigation:
csharp
public class GetByIdSalesInvoiceDto : IHasNavigation
{
// ... other properties ...
public NavigationProperties? Navigation { get; set; }
}IHasNavigation is the marker interface that enforces the Navigation property on all view DTOs.
Handler Pattern
Critical Rule — pass the UNFILTERED query to ResolveAsync
The filteredQuery argument must cover all records the user is allowed to see (tenant + branch scope), but must not be narrowed to the current record's Id. If the Id filter is applied before calling ResolveAsync, the resolver searches a single-record set and always returns null for previous/next.
csharp
// CORRECT
var scopedQuery = _repo.GetQueryable().AsNoTracking();
var result = await scopedQuery
.Where(x => x.Id == request.Id) // Id filter only for fetching
.MapProjectTo<TEntity, TDto>(mapper)
.FirstOrDefaultAsync(cancellationToken);
result.Navigation = await navigationResolver.ResolveAsync(
scopedQuery, // full scoped query — NO Id filter
request.Id,
cancellationToken);csharp
// WRONG — resolver searches inside a 1-record set, returns null
var query = _repo.GetQueryable().AsNoTracking();
query = query.Where(x => x.Id == request.Id); // narrows to 1 record
result.Navigation = await navigationResolver.ResolveAsync(query, request.Id, ct);Full handler example
csharp
internal sealed class GetSalesManByIdQueryHandler(
IAppsUnitOfWork unitOfWork,
INavigationResolver navigationResolver,
IMapper mapper)
: IQueryHandler<GetSalesManByIdQuery, SalesManDto>
{
private readonly IRepository<SalesMan> _repo = unitOfWork.GetRepository<SalesMan>();
public async Task<SalesManDto> Handle(
GetSalesManByIdQuery request,
CancellationToken cancellationToken)
{
var scopedQuery = _repo.GetQueryable().AsNoTracking();
var result = await scopedQuery
.Where(s => s.Id == request.Id)
.MapProjectTo<SalesMan, SalesManDto>(mapper)
.FirstOrDefaultAsync(cancellationToken)
?? throw new NotFoundException();
result.Navigation = await navigationResolver.ResolveAsync(
scopedQuery,
request.Id,
cancellationToken);
return result;
}
}Adding Navigation to a New Entity
- Domain — add
INavigableEntity<TId>to the entity class. - DTO — implement
IHasNavigationand addpublic NavigationProperties? Navigation { get; set; }. - Handler — inject
INavigationResolver, keep ascopedQuerywithout the Id filter, callResolveAsyncafter fetching the record.