Skip to content

Add IQueryable projection support to read-only application services - #26016

Merged
maliming merged 17 commits into
abpframework:devfrom
nazem0:dev
Aug 20, 2026
Merged

Add IQueryable projection support to read-only application services#26016
maliming merged 17 commits into
abpframework:devfrom
nazem0:dev

Conversation

@nazem0

@nazem0 nazem0 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Resolve #26015

Adds an optional IQueryProjector<TEntity, TDto>. When one is registered, GetAsync and GetListAsync project the query to the DTO instead of loading the entities and mapping them in the memory. Nothing changes when no projector is registered, so existing application services keep working as before.

A projection has to return one row per entity, since the total count and the paging are applied to the entity query before it runs. Override CreateGetOutputDtoQueryOrNullAsync or CreateGetListOutputDtoQueryOrNullAsync to build the projection asynchronously, for example to join another aggregate root. Override GetOutputDtoQueryProjector or GetListOutputDtoQueryProjector and return null to opt a specific application service out.

@nazem0

nazem0 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

@maliming
Please consider reading this addition to abp, it small.. but would enhance performance noticeably with small amount of changes IF NEEDED as it is not a breaking change it keeps old behavior as it is if not used

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces an optional query-level DTO projection extension point for read-only application services, enabling ORMs to translate DTO projections to the underlying query (e.g., SQL) to avoid materializing full entities when a projection mapper is available.

Changes:

  • Added IQueryProjectionMapper<TSource, TDestination> (and a base QueryProjectionMapper<,>) as a DI-resolvable abstraction to project IQueryable<TEntity> to IQueryable<TDto>.
  • Updated AbstractKeyReadOnlyAppService to use projection mappers in GetAsync / GetListAsync when present, with a fallback to existing entity materialization + ObjectMapper.
  • Implemented GetEntityByIdQueryAsync in ReadOnlyAppService to provide a default query-by-id for projection scenarios.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs Introduces the projection mapper interface for IQueryable-based DTO projection.
framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/QueryProjectionMapper.cs Adds a convenience base class for implementing query projections.
framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs Implements query-by-id creation used by projection-enabled GetAsync.
framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs Adds optional projection paths for GetAsync / GetListAsync with fallback behavior.
Suppressed comments (1)

framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs:103

  • GetListAsync resolves ListProjectionMapper before checking UseListProjectionMapper, so overriding UseListProjectionMapper to false does not fully disable the projection feature. Resolve the mapper only when the feature is enabled.
            var projectionMapper = ListProjectionMapper;

            if (UseListProjectionMapper && projectionMapper != null)
            {

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

nazem0 and others added 7 commits August 19, 2026 17:00
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
checking Use(Object/List)ProjectionMapper before resolving the mapper
* Fall back to GetEntityByIdAsync when a by-id query can not be created
* Expose IQueryProjectionMapper implementations like IObjectMapper does
* Document query projection and cover EF Core, MongoDB and Mapperly
@maliming
maliming self-requested a review August 20, 2026 02:29
@maliming maliming added this to the 10.8-preview milestone Aug 20, 2026
@maliming maliming changed the title feat: add IQueryable DTO projection support to read-only application services Add IQueryable projection support to read-only application services Aug 20, 2026
* RequiredMappingStrategy.Target avoids an RMG020 warning per unmapped entity property
@maliming

maliming commented Aug 20, 2026

Copy link
Copy Markdown
Member

Hi @nazem0,

I pushed a couple of commits to your branch instead of going back and forth in review comments. Here is what changed and why:

  • GetEntityByIdQueryAsync was only overridden in ReadOnlyAppService, but CrudAppService derives from AbstractKeyCrudAppService, not from it. So every CrudAppService threw NotImplementedException from GetAsync as soon as a mapper was registered. And since CrudAppService<TEntity, TEntityDto, TKey> uses the same DTO for the get and the list, registering a mapper to speed up the list was enough to break the get. It is now CreateEntityQueryAsync, next to the existing CreateFilteredQueryAsync. It returns null by default and the caller falls back to GetEntityByIdAsync. CrudAppService overrides it as well.
  • The mappers were not registered by convention. ExposeServicesAttribute exposes an interface only when the class name ends with the interface name, so GetSectorMapper : IQueryableMapper<Sector, GetSectorsDto> would be registered as itself and nothing would resolve it. AbpObjectMappingModule now exposes them the same way it already does for IObjectMapper<,>.
  • Removed UseObjectProjectionMapper / UseListProjectionMapper. The mapper property alone is enough: override it and return null to opt out, and nothing gets resolved. They are now GetQueryableMapper / GetListQueryableMapper to follow GetPolicyName / GetListPolicyName in the same class, and the interface is IQueryableMapper<TSource, TDestination> so it pairs with the IObjectMapper<TSource, TDestination> next to it.
  • The by-id query uses EntityHelper.CreateEqualityExpressionForId, and the not-found case throws EntityNotFoundException<TEntity> so it matches what Repository.GetAsync throws.
  • Added tests for Ddd.Application, EF Core, MongoDB and Mapperly, plus a Query Projection section in the application services documentation. One of the EF Core tests asserts the generated SQL, it goes down from 30 columns to the 2 the DTO declares while the soft delete and multi tenancy filters stay in the query.

One thing to keep in mind with this design: the mapper is resolved from DI per (TEntity, TDto) pair, so registering one makes every application service using that pair skip its own GetEntityByIdAsync / MapToGetOutputDtoAsync overrides. There is a test and a note in the docs for it now.

Mapperly can generate the projection for you, no need to write the Select by hand. Keep the RequiredMappingStrategy you had in your sample, without it Mapperly reports a warning for every entity property the DTO doesn't have, which is the normal case here:

[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
public partial class BookProjector : IQueryableMapper<Book, BookDto>
{
    public partial IQueryable<BookDto> ProjectTo(IQueryable<Book> source);
}

Thanks for implementing this, it is a nice addition for read heavy endpoints.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 32 out of 32 changed files in this pull request and generated no new comments.

Suppressed comments (6)

framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs:11

  • GetProjectionMapper/GetListProjectionMapper are nullable in the base type, but this override uses a non-nullable return type while returning null, which triggers nullable reference warnings (and can fail builds when warnings are treated as errors).
    protected override IQueryProjectionMapper<Book, BookDto> GetProjectionMapper => null;

    protected override IQueryProjectionMapper<Book, BookDto> GetListProjectionMapper => null;

framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjectionDto.cs:8

  • Name is declared as non-nullable but isn't initialized, which will produce CS8618 warnings under nullable reference types. Initialize it (or make it nullable) to keep the test project warning-free.
    public string Name { get; set; }

framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjectionDto.cs:8

  • Name is declared as non-nullable but isn't initialized, which will produce CS8618 warnings under nullable reference types. Initialize it (or make it nullable) to keep the test project warning-free.
    public string Name { get; set; }

framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjectionDto.cs:7

  • Name is declared as non-nullable but isn't initialized, which will produce CS8618 warnings under nullable reference types. Initialize it (or make it nullable) to keep the test project warning-free.
    public string Name { get; set; }

framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs:22

  • Grammar nit in this newly added comment: "if implements" reads incorrectly; it should be "if implemented".
            //Register types for IQueryProjectionMapper<TSource, TDestination> if implements

docs/en/framework/architecture/domain-driven-design/application-services.md:1

  • The file now starts with a BOM/zero-width character before the opening code fence (visible as an extra character before the first backticks). This can cause noisy diffs and tooling issues; remove the BOM so the first line starts with plain ```json.
```json

@XuJin186

Copy link
Copy Markdown

Can it support asynchrony? What if I need to join another table? GetQueryableAsync is asyn Thanks @maliming

* IQueryableMapper pairs with IObjectMapper, CreateEntityQueryAsync with CreateFilteredQueryAsync
@nazem0

nazem0 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Hi @nazem0,

I pushed a couple of commits to your branch instead of going back and forth in review comments. Here is what changed and why:

  • GetEntityByIdQueryAsync was only overridden in ReadOnlyAppService, but CrudAppService derives from AbstractKeyCrudAppService, not from it. So every CrudAppService threw NotImplementedException from GetAsync as soon as a mapper was registered. And since CrudAppService<TEntity, TEntityDto, TKey> uses the same DTO for the get and the list, registering a mapper to speed up the list was enough to break the get. It is now CreateEntityQueryAsync, next to the existing CreateFilteredQueryAsync. It returns null by default and the caller falls back to GetEntityByIdAsync. CrudAppService overrides it as well.
  • The mappers were not registered by convention. ExposeServicesAttribute exposes an interface only when the class name ends with the interface name, so GetSectorMapper : IQueryableMapper<Sector, GetSectorsDto> would be registered as itself and nothing would resolve it. AbpObjectMappingModule now exposes them the same way it already does for IObjectMapper<,>.
  • Removed UseObjectProjectionMapper / UseListProjectionMapper. The mapper property alone is enough: override it and return null to opt out, and nothing gets resolved. They are now GetQueryableMapper / GetListQueryableMapper to follow GetPolicyName / GetListPolicyName in the same class, and the interface is IQueryableMapper<TSource, TDestination> so it pairs with the IObjectMapper<TSource, TDestination> next to it.
  • The by-id query uses EntityHelper.CreateEqualityExpressionForId, and the not-found case throws EntityNotFoundException<TEntity> so it matches what Repository.GetAsync throws.
  • Added tests for Ddd.Application, EF Core, MongoDB and Mapperly, plus a Query Projection section in the application services documentation. One of the EF Core tests asserts the generated SQL, it goes down from 30 columns to the 2 the DTO declares while the soft delete and multi tenancy filters stay in the query.

One thing to keep in mind with this design: the mapper is resolved from DI per (TEntity, TDto) pair, so registering one makes every application service using that pair skip its own GetEntityByIdAsync / MapToGetOutputDtoAsync overrides. There is a test and a note in the docs for it now.

Mapperly can generate the projection for you, no need to write the Select by hand. Keep the RequiredMappingStrategy you had in your sample, without it Mapperly reports a warning for every entity property the DTO doesn't have, which is the normal case here:

[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
public partial class BookProjector : IQueryableMapper<Book, BookDto>
{
    public partial IQueryable<BookDto> ProjectTo(IQueryable<Book> source);
}

Thanks for implementing this, it is a nice addition for read heavy endpoints.

Thanks @maliming for taking the time to review this and for pushing the fixes directly.

The changes make sense, especially moving the query creation to CreateEntityQueryAsync so it works correctly with both ReadOnlyAppService and CrudAppService, and aligning the naming with IObjectMapper.

I also agree that removing the separate Use*ProjectionMapper properties makes the API cleaner.

Thanks again for the improvements and the additional tests/documentation.

* IQueryProjector replaces IQueryableMapper, the hooks can await other queries to join them
* A projection must return one row per entity, the total count and the paging come before it
* Pass the ambient cancellation token and detect the missing entity for value type DTOs
@maliming

Copy link
Copy Markdown
Member

Hi @XuJin186,

ProjectTo only builds an expression, it doesn't execute anything, so there is nothing to await inside it. Getting the query of another aggregate root is a different story, GetQueryableAsync is async as you say. The application service has two async hooks for that:

public class PersonWithCityAppService : ReadOnlyAppService<Person, PersonWithCityDto, Guid>
{
    private readonly IReadOnlyRepository<City, Guid> _cityRepository;

    //...

    protected override async Task<IQueryable<PersonWithCityDto>?> CreateGetListOutputDtoQueryOrNullAsync(IQueryable<Person> query)
    {
        var cities = await _cityRepository.GetQueryableAsync();

        return from person in query
               join city in cities on person.CityId equals city.Id into personCities
               from personCity in personCities.DefaultIfEmpty()
               select new PersonWithCityDto
               {
                   Id = person.Id,
                   Name = person.Name,
                   CityName = personCity != null ? personCity.Name : null
               };
    }
}

CreateGetOutputDtoQueryOrNullAsync does the same for GetAsync. They replace the projector for that application service, so you don't implement IQueryProjector at all in this case.

Two things to keep in mind. Both queries have to come from the same database context, otherwise they can't be executed as a single query. And the projection has to keep one row per entity: the total count and the paging are applied to the entity query before it runs, so an inner join to an optional relation drops rows from the page while the total count still counts them. That's why the sample uses a left join.

@nazem0 the names changed after the comment you quoted: IQueryableMapper is now IQueryProjector, the properties are GetOutputDtoQueryProjector and GetListOutputDtoQueryProjector, and CreateEntityQueryAsync is CreateEntityQueryOrNullAsync.

Thanks

* The projection only replaces the DTO creation, the filters and the policies still apply
@XuJin186

Copy link
Copy Markdown

I know what you mean,Why I say this is what I think. For example, I have an order class and I have an OrderDto. There is a field in it that is the total price. I can get it through Join Items,My order AppService can definitely rewrite CreateGetOutputDtoQueryOrNullAsync, but if my other AppService wants to reuse the OrderDto, I need to manually query the total price. @maliming

@XuJin186

Copy link
Copy Markdown

I understand that it is not in line with DDD thinking, but I cannot be completely trapped in DDD.

* Only the projected GetAsync passes it, that is the path Repository.GetAsync already covered
* Assert a single data query so a materialize-then-project implementation can not pass
* Opting out of the projection brings the entity based overrides back
@maliming

Copy link
Copy Markdown
Member

Hi @XuJin186,

For the total price you described you probably don't need the async hooks at all. If Items is a collection on Order, a plain projector can calculate it inside the query:

public class OrderProjector : IQueryProjector<Order, OrderDto>
{
    public IQueryable<OrderDto> ProjectTo(IQueryable<Order> source)
    {
        return source.Select(order => new OrderDto
        {
            Id = order.Id,
            Number = order.Number,
            TotalPrice = order.Items.Sum(item => item.UnitPrice * item.Quantity)
        });
    }
}

EF Core translates it to a correlated subquery, so the total is calculated on the database and the other Order columns are not read:

SELECT o.Id, o.Number, (
    SELECT COALESCE(SUM(o0.UnitPrice * o0.Quantity), 0)
    FROM OrderItem AS o0
    WHERE o.Id = o0.OrderId) AS TotalPrice
FROM Orders AS o

The projector is resolved by the (Order, OrderDto) pair, so every application service returning an OrderDto uses it. There is no second query to write anywhere.

The async hooks are only needed when the other side is not reachable from the entity and you have to get its query from another repository. That is per application service, but you don't have to repeat the join in each one. Put it in an application layer service and call it from the hooks:

public interface IOrderDtoQuery : ITransientDependency
{
    Task<IQueryable<OrderDto>> ProjectAsync(IQueryable<Order> orders);
}

public class OrderAppService : ReadOnlyAppService<Order, OrderDto, Guid>
{
    private readonly IOrderDtoQuery _orderDtoQuery;

    //...

    protected override async Task<IQueryable<OrderDto>?> CreateGetListOutputDtoQueryOrNullAsync(IQueryable<Order> query)
    {
        return await _orderDtoQuery.ProjectAsync(query);
    }
}

Any other application service injects the same IOrderDtoQuery.

There is no reusable async projector contract yet, and that is a fair gap to point out. A few things have to be settled before adding one: how it coexists with the synchronous projector when both are registered for the same pair, how an application service opts out of it, and how it keeps one row per entity so the paging still matches the total count. We'll track that as a separate feature request instead of growing this PR.

To be clear, this is not about DDD. The framework can join whatever the query provider can translate, both queries just have to come from the same database context.

Thanks

@maliming
maliming merged commit 7288e5a into abpframework:dev Aug 20, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add IQueryable projection support to ReadOnlyAppService

4 participants