Open Closed

Tenant extension property values not propagated to SaasTenantDto on Host → SaaS → Tenants grid (10.3.0) #10690


User avatar
0
matic.rek@infotim.com created

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

  1. Start from a Blazor Server solution on ABP 10.3.0 with Volo.Saas (Pro).
  2. 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");
               });
           });
       });
  1. Do NOT register a corresponding MapEfCoreProperty (leave the value in JSON ExtraProperties).
  2. Create a tenant and set the property value via tenant.SetProperty("TaxNumber", "12345678") from your migrator/seeder.
  3. Verify in SQL that the value is in SaasTenants.ExtraProperties as {"TaxNumber":"12345678"}.
  4. 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

  1. Is this a known regression in Volo.Saas 10.3.0?
  2. If yes — is a fix planned, or is MapEfCoreProperty now the recommended pattern for extension properties on Tenant going forward?
  3. If no — could you reproduce on a clean 10.3.0 template?

Thanks.

Markdown supported.
Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)

5 Answer(s)
  • User Avatar
    0
    AI-Bot created
    Support Team ABP Agent

    1. Solution

    This behavior is consistent with the DTO mapping not copying ExtraProperties from Volo.Saas.Tenants.Tenant to SaasTenantDto in 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 call MapExtraPropertiesTo(...) while mapping entity → DTO (or enable/configure MapExtraProperties in 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.ExtraProperties doesn’t contain that value.

    Recommended fix (JSON-only; no MapEfCoreProperty required): override/replace the SaaS tenant app service and map extra properties to the DTO in GetListAsync (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 GetListAsync by querying entities and mapping yourself, explicitly calling MapExtraPropertiesTo:

    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 returned SaasTenantDto.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):

    1. 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.
    2. MapEfCoreProperty is 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.
    3. 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 ExtraProperties storage)

    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)
  • User Avatar
    0
    matic.rek@infotim.com created

    Thanks for the response. Two follow-up clarifications would help us close this out:

    1. The constructor signature in your example doesn't match Volo.Saas.Host.TenantAppService in 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?

    2. The same JSON-only ExtraProperties pattern works correctly elsewhere in the same solution. Specifically, an extension property ExternalCode registered on IdentityUser via ConfigureIdentity.ConfigureUser.AddOrUpdateProperty<string>("ExternalCode", ...) (without MapEfCoreProperty) does propagate into IdentityUserDto and 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 IdentityUser work 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 SaasTenantDto is 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)
  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    Hi,

    Quick clarifications on both your points:

    1. You're right about the constructor — TenantAppService in 10.3.0 takes the 9 parameters you listed. More importantly, an override shouldn't be needed at all. The framework copies ExtraProperties from Tenant to SaasTenantDto by default in 10.3.0, the same way it does on IdentityUser. 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.

    2. 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 TaxNumber registered via ConfigureSaas.ConfigureTenant.AddOrUpdateProperty<>, seeded with tenant.SetProperty("TaxNumber", "12345678") + InsertAsync). Everything works as expected: the value lands in the SaasTenants.ExtraProperties JSON column, the entity loads it back, the mapping carries it through, the grid cells render, and GET /api/saas/tenants returns:

    {
      "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 MapEfCoreProperty clue is the most useful signal you gave. That switch doesn't change the CLR/mapping API — both paths still read tenant.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 the SaasTenants.ExtraProperties column isn't being deserialized into the in-memory ExtraProperties dictionary 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:

    1. 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 TaxNumber is null at the entity level while the DB has {"TaxNumber":"12345678"}, the bug is in EF deserialization — which is exactly the layer MapEfCoreProperty happens to bypass.

    1. Open Chrome DevTools → Network on the Tenants page and share the raw JSON response of GET /api/saas/tenants. If extraProperties.TaxNumber is 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.

    2. 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 SaasTenants table — especially anything touching the ExtraProperties column?

    3. 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 MapEfCoreProperty as the permanent answer — JSON-only should work the same way it does on IdentityUser.

    Thanks

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    0
    matic.rek@infotim.com created

    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 PostInformationJob which 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 ExtraProperties deserializes 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/tenants via Swagger):

    {
      "totalCount": 3,
      "items": [
        {
          "name": "tenant-a",
          "extraProperties": { "TaxNumber": "12345678" }
        },
        ...
      ]
    }
    

    extraProperties.TaxNumber is present and correct. So Volo.Saas's TenantAppService.GetListAsync populates 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: added Leaved bit column
    • AbpEntityPropertyChanges.PropertyTypeFullName: column type/size altered
    • AbpEntityChanges.EntityTypeFullName: column type/size altered
    • Created AbpUserInvitations table

    It did not touch SaasTenants or any ExtraProperties column. 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 with data-caption="TaxCode", proper dimensions (113×65px), no CSS hiding it — but innerHTML is 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.GetListAsync produces 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 TenantManagement Blazor 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)
  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    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 MapEfCoreProperty remains the simplest workaround.

    Thanks

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
Boost Your Development
ABP Live Training
Packages
See Trainings
Mastering ABP Framework Book
The Official Guide
Mastering
ABP Framework
Learn More
Mastering ABP Framework Book
Made with ❤️ on ABP v10.8.0-preview. Updated on September 24, 2026, 12:09
1
ABP Assistant
🔐 You need to be logged in to use the chatbot. Please log in first.