Draft
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a QueryResult<T> result pattern (similar in spirit to the existing command result pattern) and adds default query validation (paging, sorting, filter-type validation) so queries can return ValidationProblem (400) or NotFound (404) consistently from API endpoints.
Changes:
- Add
QueryResult<T>union types and refactor query base classes to returnQueryResult<...>(including paged and by-id queries). - Add FluentValidation validators + a MediatR pipeline behavior intended to short-circuit queries on validation failures.
- Split mediator HTTP mapping helpers into command/query-specific extensions and update endpoints + application tests to the new query result pattern.
Reviewed changes
Copilot reviewed 35 out of 35 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Content/Backend/Solution/Monaco.Template.Backend.Common.Infrastructure/Context/Extensions/FilterExtensions.cs | Exposes helper methods used by new query validators for filter datatype checks. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryPagedBaseValidator.cs | Adds paging + sort-field validation rules for paged queries. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Validators/QueryBaseValidator.cs | Adds sort-field validation and filter value type validation based on mapped fields. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryResult.cs | Introduces QueryResult<T> (Success / NotFound / ValidationFailure). |
| src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryPagedBase.cs | Refactors paged queries to return QueryResult<Page<T>> and centralizes paging parsing via IPagedQuery. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryByIdBase.cs | Refactors by-id query base to return a non-null IRequest<T> (now typically QueryResult<T>). |
| src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/QueryBase.cs | Refactors base query typing and adds mapped-field hooks for filtering/sorting in entity-backed queries. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Extensions/QueryExtensions.cs | Updates EF query execution helpers to return QueryResult<T> and support the new query base types. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Extensions/QueryBehaviorExtensions.cs | Adds DI registration helper intended to wire query validation pipeline + default validators. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Contracts/IPagedQuery.cs | Adds shared parsing helpers and max limit for paged queries. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Common.Application/Queries/Behaviors/QueryValidationBehavior.cs | Adds MediatR pipeline behavior that aggregates FluentValidation errors into QueryResult.ValidationFailure. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorQueryExtensions.cs | Adds query-to-HTTP mapping extensions for QueryResult<T> including ValidationProblem in some overloads. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorExtensions.cs | Removes the old combined command/query mediator helpers (superseded by new split extensions). |
| src/Content/Backend/Solution/Monaco.Template.Backend.Common.Api.Application/MediatorCommandExtensions.cs | Adds command-only mediator helpers (extracted from the removed combined file). |
| src/Content/Backend/Solution/Monaco.Template.Backend.ArchitectureTests/ApplicationTests.cs | Narrows validator discovery to the application assembly for architecture constraints. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/GetProductPage.cs | Updates product page query to entity-backed paged base + mapped fields + QueryResult<Page<ProductDto>>. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/GetProductById.cs | Updates product by-id query to return QueryResult<ProductDto> and expands includes. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/Extensions/ProductExtensions.cs | Makes mapping extensions non-nullable and removes mapped-field helper (now on query types). |
| src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Product/DownloadProductPicture.cs | Updates download query to return QueryResult<FileDownloadDto> and map NotFound explicitly. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Country/GetCountryList.cs | Updates list query to entity-backed QueryBase<,> + mapped fields + QueryResult<List<CountryDto>>. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Country/GetCountryById.cs | Updates by-id query to return QueryResult<CountryDto>. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Country/Extensions/CountryExtensions.cs | Makes mapping extensions non-nullable and removes mapped-field helper (now on query types). |
| src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/GetCompanyPage.cs | Updates company page query to entity-backed paged base + mapped fields + QueryResult<Page<CompanyDto>>. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/GetCompanyById.cs | Updates company by-id query to return QueryResult<CompanyDto>. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Application/Features/Company/Extensions/CompanyExtensions.cs | Makes mapping extensions non-nullable; keeps mapped-field helper for companies. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Product/GetProductPageTests.cs | Updates tests to assert QueryResult.Success<Page<ProductDto>>. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Product/GetProductByIdTests.cs | Updates tests to assert Success<ProductDto> / NotFound<ProductDto>. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Product/DownloadProductPictureTests.cs | Updates tests to assert Success<FileDownloadDto> / NotFound<FileDownloadDto>. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Country/GetCountryListTests.cs | Updates tests to assert Success<List<CountryDto>>. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Country/GetCountryByIdTests.cs | Updates tests to assert Success<CountryDto> / NotFound<CountryDto>. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Company/GetCompanyPageTests.cs | Updates tests to assert Success<Page<CompanyDto>>. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Application.Tests/Features/Company/GetCompanyByIdTests.cs | Updates tests to assert Success<CompanyDto> / NotFound<CompanyDto>. |
| src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Products.cs | Updates endpoints to new query result-to-HTTP mapping (including ValidationProblem for list). |
| src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Countries.cs | Updates endpoints to new query result-to-HTTP mapping (including ValidationProblem for list). |
| src/Content/Backend/Solution/Monaco.Template.Backend.Api/Endpoints/Companies.cs | Updates endpoints to new query result-to-HTTP mapping (including ValidationProblem for page). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+50
to
+71
| private static void RegisterDefaultValidators(IServiceCollection services, Type queryType) | ||
| { | ||
| var validatorInterface = typeof(IValidator<>).MakeGenericType(queryType); | ||
| var baseType = queryType.BaseType; | ||
|
|
||
| while (baseType is not null && baseType != typeof(object)) | ||
| { | ||
| if (baseType.IsGenericType) | ||
| { | ||
| var found = true; | ||
| switch (baseType.GetGenericTypeDefinition()) | ||
| { | ||
| case var t when t == typeof(QueryPagedBase<,>): | ||
| services.AddScoped(validatorInterface, typeof(QueryPagedBaseValidator<,>).MakeGenericType(baseType.GenericTypeArguments)); | ||
| services.AddScoped(validatorInterface, typeof(QueryBaseValidator<,>).MakeGenericType(baseType.GenericTypeArguments)); | ||
| break; | ||
| case var t when t == typeof(QueryPagedBase<>): | ||
| services.AddScoped(validatorInterface, typeof(QueryPagedBaseValidator<>).MakeGenericType(baseType.GenericTypeArguments)); | ||
| break; | ||
| case var t when t == typeof(QueryBase<,>): | ||
| services.AddScoped(validatorInterface, typeof(QueryBaseValidator<,>).MakeGenericType(baseType.GenericTypeArguments)); | ||
| break; |
Comment on lines
+10
to
+47
| public static class QueryBehaviorExtensions | ||
| { | ||
| extension(IServiceCollection services) | ||
| { | ||
| /// <summary> | ||
| /// Scans the assembly for query types returning <see cref="QueryResult{T}"/>, registers | ||
| /// <see cref="QueryValidationBehavior{TQuery,TResult}"/> for each, and automatically registers | ||
| /// the appropriate default base validators (<see cref="QueryPagedBaseValidator{T}"/> or | ||
| /// <see cref="QueryPagedBaseValidator{T,TEntity}"/>). | ||
| /// Specific validators already registered in DI will also be picked up by the behavior. | ||
| /// </summary> | ||
| public IServiceCollection RegisterQueryValidationBehaviors(Assembly assembly) | ||
| { | ||
| var queryTypes = assembly.GetTypes() | ||
| .Where(t => !t.IsAbstract && | ||
| t.GetInterfaces().Any(i => i.IsGenericType && | ||
| i.GetGenericTypeDefinition() == typeof(IRequest<>) && | ||
| i.GenericTypeArguments[0].IsGenericType && | ||
| i.GenericTypeArguments[0].GetGenericTypeDefinition() == typeof(QueryResult<>))); | ||
|
|
||
| foreach (var queryType in queryTypes) | ||
| { | ||
| var resultType = queryType.GetInterfaces() | ||
| .First(i => i.IsGenericType && | ||
| i.GetGenericTypeDefinition() == typeof(IRequest<>)) | ||
| .GenericTypeArguments[0] // QueryResult<T> | ||
| .GenericTypeArguments[0]; // T | ||
|
|
||
| // Register the behavior | ||
| services.AddScoped(typeof(IPipelineBehavior<,>).MakeGenericType(queryType, typeof(QueryResult<>).MakeGenericType(resultType)), | ||
| typeof(QueryValidationBehavior<,>).MakeGenericType(queryType, resultType)); | ||
|
|
||
| // Register default base validators | ||
| RegisterDefaultValidators(services, queryType); | ||
| } | ||
|
|
||
| return services; | ||
| } |
Comment on lines
+21
to
+30
| RuleFor(x => x.QueryParams) | ||
| .Must(p => !TryGetInt(p, nameof(Pager.Offset), out var v) || | ||
| v >= 0) | ||
| .WithMessage($"{nameof(Pager.Offset)} must be greater than or equal to 0."); | ||
|
|
||
| RuleFor(x => x.QueryParams) | ||
| .Must(p => !TryGetInt(p, nameof(Pager.Limit), out var v) || | ||
| v is > 0 and <= IPagedQuery.MaxLimit) | ||
| .WithMessage($"{nameof(Pager.Limit)} must be between 1 and {IPagedQuery.MaxLimit}."); | ||
| } |
Comment on lines
+111
to
+120
| private static Results<Ok<TResult>, NotFound> OkOrNotFound<TResult>(QueryResult<TResult> result) => | ||
| ResponseOrNotFound(result, TypedResults.Ok); | ||
|
|
||
| private static Results<TResponse, NotFound> ResponseOrNotFound<TResult, TResponse>(QueryResult<TResult> result, Func<TResult, TResponse> func) where TResponse : IResult => | ||
| result switch | ||
| { | ||
| Success<TResult> success => func(success.Result), | ||
| Common.Application.Queries.NotFound<TResult> => TypedResults.NotFound(), | ||
| _ => throw new InvalidOperationException($"Unexpected query result type '{result.GetType().FullName ?? "null"}' returned.") | ||
| }; |
| .SingleOrDefaultAsync(x => x.Id == request.Id, cancellationToken); | ||
| return item.Map(true); | ||
|
|
||
| return QueryResult<CompanyDto>.SuccessOrNotFound(item?.Map()); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Incorporate query validations and result pattern to better handle their outcome.
Motivation and Context
Just like with the Commands, it's been more evident that the handling of the queries sometimes requires some validations, such as:
For all this, the inclusion of some default validators and a refactor of the current base Query classes to better integrate some mechanisms (like MappedFields for filtering and sorting) and the way to handle their results is needed. Hence.
How Has This Been Tested?
The same test suite has been used to ensure that nothing has been broken and also the same tests have been improved and adapted for the new results being returned
Types of changes
Checklist: