Skip to main content
Version: Latest

18. AppSettings Group-Aware Key Lookup

18.1 Purpose and scope

Closes the gap tracked in Chapter 17 §17.13: GetSettingValueAsync<T>(string key)/SetSettingValueAsync<T>(string key, T value) resolved an AppSetting row by Key alone, with no GroupName filter. This phase makes that lookup group-aware and safe — no package, schema, business behavior, or MultiTenancyApi change was involved; this is a BackEnd-only correctness fix.

18.2 Root cause

AppSettingService/TenantSettingService's key-only overloads queried _repository.Query<AppSetting>().FirstOrDefaultAsync(q => q.Key == key) — no GroupName in the predicate at all. If the same Key string existed in more than one group (a realistic risk: generic names like Enabled/IsActive/DefaultValue are exactly the kind of property name likely to be reused across unrelated settings groups), this silently returned whichever row the query happened to return first — never an error, never a warning, just a possibly-wrong value or a write landing on the wrong group's row. Tenant isolation itself was never at risk (the query is still correctly scoped to the current tenant by ApplicationDbContext's blanket IsMultiTenant() convention) — this was a cross-group, not cross-tenant, ambiguity.

18.3 Fix

  • New group-aware overloads on AppSettingService/TenantSettingService (and, for TenantSettingService, on its real, active ITenantSettingService interface — IAppSettingService's own key-only/group-aware methods are not part of its interface contract, matching how the pre-existing key-only overload already wasn't):

    Task<T> GetSettingValueAsync<T>(string groupName, string key, CancellationToken ct = default, bool returnDefaultOnException = false);
    Task SetSettingValueAsync<T>(string groupName, string key, T value, CancellationToken ct = default);

    Both scope the query/upsert to (GroupName, Key), and Set stamps GroupName on newly-created rows (the legacy key-only Set never did, and still doesn't — unchanged to avoid altering existing behavior).

  • The legacy key-only overloads now fail fast instead of guessing: a new private ResolveUnambiguousMatch(key, matches) helper loads every row matching the key, and throws an InvalidOperationException naming every distinct GroupName found the moment more than one exists — before any value is read or any write is applied. It never silently picks the first row. When the key is genuinely unique for the tenant (the common case), behavior is unchanged.

  • Cache keys stay consistent with Chapter 17's IAppSettingCacheKeyBuilder — the group-aware overload reuses the existing BuildScopedGroupKey(groupName, "key", settingKey) method (already present, no builder change needed) to produce AppSettings:{TenantId}:{GroupName}:key:{Key}; the legacy key-only overload is unchanged, AppSettings:{TenantId}:single:{Key}.

18.4 Callers

No active production caller of either overload existed before this phase (the only two call sites, ProductService.cs and ShoppingCartService.cs, were already commented-out dead code — left untouched, not part of this fix's scope). This is a forward-looking safety fix: any future caller that knows its GroupName should use the new overload; the legacy key-only overload remains available for genuinely tenant-wide-unique ad-hoc keys, now safely.

18.5 Tests

Shumoul.Application.Tests/ServicesTests/AppSettingServiceGroupAwareKeyTests.cs (7 tests):

  • Key exists in exactly one group — legacy key-only lookup still works unchanged.
  • Key exists in two groups — legacy key-only Get throws InvalidOperationException naming both groups.
  • Key exists in two groups — legacy key-only Set throws the same way, and neither row is mutated.
  • Group-aware Get returns the correct group's value even when the key is ambiguous globally.
  • Group-aware Set updates only the intended group's row; the other group's row is untouched.
  • Group-aware Set on a brand-new key creates the row with GroupName stamped.
  • Tenant isolation from Chapter 17 remains intact for the group-aware overload (two tenants, one shared IEasyCachingProvider, no cross-contamination).

18.6 Build and test verification

CommandResult
dotnet build (full solution)0 errors, 0 new warnings
dotnet test (targeted: new suite)7/7 pass
dotnet test (full solution)837 total (830 + 7 new), 35 failed — identical pre-existing baseline, 0 new failures

Shumoul.Framework.MultiTenancy.Api package: unchanged. No MultiTenancyApi or frontend file was touched.

18.7 Compatibility notes

  • Fully backward compatible for the common case (a key unique to one group) — no behavior change.
  • Breaking in the narrow, intended sense: if a genuinely ambiguous key-only call exists anywhere not yet discovered (none found active in this codebase), it will now throw InvalidOperationException instead of silently returning a possibly-wrong value. This is the explicit goal of the fix, not a regression — a caller hitting this exception should switch to the group-aware overload.