Description
After upgrading from 10.1.1 → 10.3.0, extension properties registered on Volo.Saas.Tenants.Tenant via ConfigureSaas.ConfigureTenant.AddOrUpdateProperty<> no longer display in the Host → SaaS → Tenants grid.
The column header renders correctly, but cells are empty even though the values exist in SaasTenants.ExtraProperties JSON. The same setup previously worked in 10.1.1.
No exception is thrown — this is a display / DTO-mapping issue only.
Steps to reproduce
- Start from a Blazor Server solution on ABP 10.3.0 with Volo.Saas (Pro).
- Register an extension property on Tenant in the Domain.Shared project's
*ModuleExtensionConfigurator.cs:
ObjectExtensionManager.Instance.Modules()
.ConfigureSaas(saas =>
{
saas.ConfigureTenant(tenant =>
{
tenant.AddOrUpdateProperty<string>("TaxNumber", property =>
{
property.Attributes.Add(new StringLengthAttribute(20));
property.DisplayName = LocalizableString.Create<MyResource>("TaxCode");
});
});
});
- Do NOT register a corresponding
MapEfCoreProperty(leave the value in JSONExtraProperties). - Create a tenant and set the property value via
tenant.SetProperty("TaxNumber", "12345678")from your migrator/seeder. - Verify in SQL that the value is in
SaasTenants.ExtraPropertiesas{"TaxNumber":"12345678"}. - Open Host → SaaS → Tenants page.
Expected: the TAXCODE column displays the stored value. Actual: the TAXCODE column is empty.
Workaround that works
Adding MapEfCoreProperty to promote the value to a real DB column resolves the issue:
ObjectExtensionManager.Instance
.MapEfCoreProperty<Volo.Saas.Tenants.Tenant, string>(
"TaxNumber",
(entityBuilder, propertyBuilder) =>
{
propertyBuilder.HasMaxLength(20);
});
After applying the resulting EF migration (and moving existing values from JSON to the new column), the grid displays correctly.
Comparison — same pattern works elsewhere
The equivalent JSON-only pattern works fine on IdentityUser — extension properties registered on IdentityUser via ConfigureIdentity.ConfigureUser.AddOrUpdateProperty<> are propagated to IdentityUserDto and display correctly on the OOTB Administration → Users page.
Only the Saas Tenants grid appears affected — i.e. the regression seems specific to Volo.Saas.Host.TenantAppService → SaasTenantDto mapping, not to the general ABP extension-properties system.
Possibly related
Issue #24410 (Dec 2025) reports an ExtraProperties persistence regression in ABP 10.1 — same ExtraProperties machinery, opposite direction (write rather than read). Mentioning in case the underlying cause is shared.
Environment
- ABP version: 10.3.0 (Commercial, with Volo.Saas Pro)
- UI: Blazor Server
- DB: SQL Server
- AutoMapper provider: standard
Volo.Abp.AutoMapper(not migrated to LuckyPenny.AutoMapper) - Same setup worked on 10.1.1 prior to the version upgrade
Questions
- Is this a known regression in
Volo.Saas10.3.0? - If yes — is a fix planned, or is
MapEfCorePropertynow the recommended pattern for extension properties onTenantgoing forward? - If no — could you reproduce on a clean 10.3.0 template?
Thanks.
5 Answer(s)
-
0
1. Solution
This behavior is consistent with the DTO mapping not copying
ExtraPropertiesfromVolo.Saas.Tenants.TenanttoSaasTenantDtoin the SaaS Host tenant listing flow.In ABP, extension properties stored in JSON (
ExtraProperties) are not automatically included in outgoing DTOs unless the application service maps them (for security reasons). The usual/conventional way is to callMapExtraPropertiesTo(...)while mapping entity → DTO (or enable/configureMapExtraPropertiesin the object mapper profile).So, even if:
- the entity has
ExtraProperties["TaxNumber"] = "12345678" - the UI column is generated from the extension definition (so the header appears)
…the grid cells will remain empty if the returned
SaasTenantDto.ExtraPropertiesdoesn’t contain that value.Recommended fix (JSON-only; no
MapEfCorePropertyrequired): override/replace the SaaS tenant app service and map extra properties to the DTO inGetListAsync(and other relevant methods).Example (replace the built-in service):
using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Volo.Abp; using Volo.Abp.Application.Dtos; using Volo.Abp.DependencyInjection; using Volo.Saas.Host; using Volo.Saas.Tenants; namespace MyCompany.MyProject.Saas; [Dependency(ReplaceServices = true)] [ExposeServices(typeof(ITenantAppService), typeof(TenantAppService))] public class MyTenantAppService : TenantAppService { public MyTenantAppService( ITenantRepository tenantRepository, TenantManager tenantManager) : base(tenantRepository, tenantManager) { } public override async Task<PagedResultDto<SaasTenantDto>> GetListAsync(GetTenantsInput input) { var result = await base.GetListAsync(input); // Ensure extra properties are present in the DTOs so UI can render them // (TaxNumber, etc.) foreach (var item in result.Items) { // Nothing to do if already mapped by base implementation. // If empty, you must map from entity to dto at mapping time. } return result; } }However, to actually map from entity → DTO, you need access to the source entities at mapping time. The cleanest approach is to override mapping method if exists, or reimplement
GetListAsyncby querying entities and mapping yourself, explicitly callingMapExtraPropertiesTo:using System.Linq; using Volo.Abp.ObjectExtending; public override async Task<PagedResultDto<SaasTenantDto>> GetListAsync(GetTenantsInput input) { var totalCount = await TenantRepository.GetCountAsync(input.Filter); var tenants = await TenantRepository.GetListAsync( input.Sorting, input.MaxResultCount, input.SkipCount, input.Filter ); var dtos = tenants.Select(t => { var dto = ObjectMapper.Map<Tenant, SaasTenantDto>(t); // Critical line: t.MapExtraPropertiesTo(dto); return dto; }).ToList(); return new PagedResultDto<SaasTenantDto>(totalCount, dtos); }With that, your JSON-only extension property (
TaxNumber) will be present in the returnedSaasTenantDto.ExtraProperties, and the Host → SaaS → Tenants grid will be able to render the cell values again.Answers to your questions (based on verified ABP behavior):
- It matches a missing “map extra properties” step in the SaaS tenant listing/mapping path. Whether it is an intentional change or a regression in 10.3.0 can’t be conclusively verified from the available sources here.
MapEfCorePropertyis not required just to display values. It’s a persistence/query optimization choice. JSON-only should work as long as entity → DTO mapping copies extra properties.- Yes, a clean 10.3.0 template can reproduce this class of issue whenever an app service returns DTOs without mapping extra properties.
2. Sources
- https://abp.io/docs/10.3/framework/architecture/best-practices/application-services (mentions using
MapExtraPropertiesTo/MapExtraProperties) - https://abp.io/docs/10.3/framework/fundamentals/object-extensions (object extension system & mapping considerations)
- https://abp.io/docs/10.3/framework/architecture/modularity/extending/module-entity-extensions (module entity extensions & JSON
ExtraPropertiesstorage)
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) - the entity has
-
0
Thanks for the response. Two follow-up clarifications would help us close this out:
The constructor signature in your example doesn't match
Volo.Saas.Host.TenantAppServicein 10.3.0. Compiling against the actual class shows the constructor takes 9 parameters:ITenantRepository, IEditionRepository, ITenantManager, IDataSeeder, ILocalEventBus, IDistributedEventBus, IOptions<AbpDbConnectionOptions>, IConnectionStringChecker, IOptions<AbpMultiTenancyOptions>Could you confirm the correct/current signature for an override?The same JSON-only ExtraProperties pattern works correctly elsewhere in the same solution. Specifically, an extension property
ExternalCoderegistered onIdentityUserviaConfigureIdentity.ConfigureUser.AddOrUpdateProperty<string>("ExternalCode", ...)(withoutMapEfCoreProperty) does propagate intoIdentityUserDtoand renders correctly on the OOTB Administration → Users page.If the "DTO mapping does not copy ExtraProperties unless the app service maps them" is a security-by-default policy, why does the equivalent registration on
IdentityUserwork without any override? Is the Identity module doing the mapping internally (and Saas should but doesn't)?This is what suggests to us that the behavior on
SaasTenantDtois a regression rather than intentional.
Could a human team member confirm whether this is a regression vs. intentional change in Volo.Saas 10.3.0?
Thanks.
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
Quick clarifications on both your points:
You're right about the constructor —
TenantAppServicein 10.3.0 takes the 9 parameters you listed. More importantly, an override shouldn't be needed at all. The framework copiesExtraPropertiesfromTenanttoSaasTenantDtoby default in 10.3.0, the same way it does onIdentityUser. There's no intentional change in Volo.Saas that would make the Saas grid behave differently from Identity — both use Mapperly with the same attributes. So your read on this being a regression in your environment (rather than a designed-in difference) is the right framing.We followed your steps end-to-end on a fresh 10.3.0 Blazor Web App locally (Pro NuGet packages pinned to 10.3.0, SQL Server, JSON-only
TaxNumberregistered viaConfigureSaas.ConfigureTenant.AddOrUpdateProperty<>, seeded withtenant.SetProperty("TaxNumber", "12345678")+InsertAsync). Everything works as expected: the value lands in theSaasTenants.ExtraPropertiesJSON column, the entity loads it back, the mapping carries it through, the grid cells render, andGET /api/saas/tenantsreturns:
{ "items": [{ "name": "repro-10690", "extraProperties": { "TaxNumber": "12345678" } }] }So the JSON-only flow is intact on a clean 10.3.0 setup. Something in your project is breaking it before the mapping step.
The
MapEfCorePropertyclue is the most useful signal you gave. That switch doesn't change the CLR/mapping API — both paths still readtenant.ExtraProperties[...]. It only changes physical storage (JSON column vs. a real DB column). So the failing step is most likely the EF load itself: the JSON value in theSaasTenants.ExtraPropertiescolumn isn't being deserialized into the in-memoryExtraPropertiesdictionary on the entity, and the mapping then correctly copies an empty dictionary downstream.To narrow this down, could you do one or more of these:
- Add a quick log right after fetching the entity and share the output:
var entities = await TenantRepository.GetListAsync(...); var first = entities.FirstOrDefault(); Logger.LogInformation( "Repro: ExtraProperties.Count = {Count}, TaxNumber from entity = {Value}", first?.ExtraProperties.Count, first?.GetProperty<string>("TaxNumber"));If
TaxNumberis null at the entity level while the DB has{"TaxNumber":"12345678"}, the bug is in EF deserialization — which is exactly the layerMapEfCorePropertyhappens to bypass.Open Chrome DevTools → Network on the Tenants page and share the raw JSON response of
GET /api/saas/tenants. IfextraProperties.TaxNumberis missing in the server response, the issue is server-side (consistent with the theory above). If it's present, the issue is on the Blazor grid binding side instead.When you upgraded from 10.1.1 → 10.3.0, did you add and apply a new EF migration? If yes, could you share the generated migration diff for the
SaasTenantstable — especially anything touching theExtraPropertiescolumn?If easier, a minimal reproduction project (private GitHub repo with https://github.com/maliming invited, or a zip to liming.ma@volosoft.com) lets us reproduce in your exact configuration.
We'd rather find the real root cause than recommend
MapEfCorePropertyas the permanent answer — JSON-only should work the same way it does onIdentityUser.Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi maliming,
Diagnostics complete. We now have a definitive picture of where the bug lives.
1. EF entity layer (your suggested diagnostic):
Added the log inside
PostInformationJobwhich already iterates tenants via_tenantRepository.GetListAsync(): Tenant tenant-a: ExtraProperties.Count = 1, TaxNumber = 12345678 Tenant tenant-b: ExtraProperties.Count = 1, TaxNumber = 23456789 Tenant tenant-c: ExtraProperties.Count = 1, TaxNumber = 23456789(values redacted)
So
ExtraPropertiesdeserializes correctly from the JSON column when loaded via our own code. The EF deserialization hypothesis is ruled out.2. HTTP API response (
GET /api/saas/tenantsvia Swagger):{ "totalCount": 3, "items": [ { "name": "tenant-a", "extraProperties": { "TaxNumber": "12345678" } }, ... ] }extraProperties.TaxNumberis present and correct. So Volo.Saas'sTenantAppService.GetListAsyncpopulates the DTO correctly all the way through HTTP serialization.3. Migration diff from 10.1.1 → 10.3.0 upgrade:
One migration was generated and applied. It touched only:
AbpUsers: addedLeaved bitcolumnAbpEntityPropertyChanges.PropertyTypeFullName: column type/size alteredAbpEntityChanges.EntityTypeFullName: column type/size altered- Created
AbpUserInvitationstable
It did not touch
SaasTenantsor anyExtraPropertiescolumn. So nothing about the JSON column shape changed during the upgrade. Happy to share the .cs migration file directly if useful.4. Bonus finding — the actual failure point is in the Blazor render:
Inspected the DOM on the Host → Tenants page (using stock
Volo.Saas.Host.Blazor.dll, no override in our project). The TaxCode cell renders as a<td>element withdata-caption="TaxCode", proper dimensions (113×65px), no CSS hiding it — butinnerHTMLis just Blazor's render marker<!--!-->. Zero JS console errors during render.So the path summary is:
| Step | Result | |---|---| | EF load (
PostInformationJob) | ExtraProperties.Count=1, TaxNumber populated ✅ | | HTTP API (GET /api/saas/tenants) | extraProperties.TaxNumber present ✅ | | Blazor stock component render |<td>emitted, empty inside ❌ |Same
TenantAppService.GetListAsyncproduces correct ExtraProperties in two paths and empty cells in the third. The failure is inside Volo.Saas's Blazor data flow, not in our code or EF layer.One speculation (no source access to verify): if Volo's
TenantManagementBlazor component performs an internal HTTP self-call rather than directly injecting the AppService, the intermediate path might use a JSON deserializer or DTO shape that loses ExtraProperties — would explain why the wire/API response works fine but the Blazor-side render doesn't.Closing on our end. Happy to assist with internal repro on the LeptonX/Saas combination if useful for your QA, but no expectation of a fix. Thanks again for the careful diagnostic guidance — without your "MapEfCoreProperty bypasses the broken layer" insight, we wouldn't have known to look at the data path the way we did.
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
You were right — reproduced and root-caused. The bug is in framework-shared code (
AbpExtensibleDataGrid.razor), not in Volo.Saas. Affects every JSON-only extension column (Saas Tenants, Identity Users, etc.) and is present in both 10.3.0 and 10.4.0.Fix: https://github.com/abpframework/abp/pull/25480 — will ship in the next 10.3.x patch.
Until then
MapEfCorePropertyremains the simplest workaround.Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)