Skip to content

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; }
}
MemberSourcePurpose
TId IdDefined hereThe primary key — generic to support both Guid and int entities
string CodeThe sequential code used to order and find adjacent records
DateTime CreatedOnDefined hereTie-breaker when two records share the same code

Why generic? Sales entities use either Guid (e.g. SalesInvoiceHeader) or int (e.g. Customer, PricePolicy) as their primary key. The generic TId parameter allows the same navigation infrastructure to serve both without separate interfaces.

INavigationResolverMicrotec.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# infers TId automatically from the call site.
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:

  1. Implement INavigableEntity<TId> with the correct key type.
  2. Have a string Code { get; } property (from ISequencialEntity).
  3. 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

  1. Domain — add INavigableEntity<TId> to the entity class.
  2. DTO — implement IHasNavigation and add public NavigationProperties? Navigation { get; set; }.
  3. Handler — inject INavigationResolver, keep a scopedQuery without the Id filter, call ResolveAsync after fetching the record.

Internal Documentation — Microtec