ADR-019: User Lookup Dual Mode — Cache vs Local Store
Status
Section titled “Status”Accepted
Context
Section titled “Context”Granit supports two identity provider modes:
-
External provider (Keycloak, Entra ID, Cognito, Google Cloud) — users live in an external system that cannot be queried via SQL. A local
UserCacheEntrytable mirrors user data for search, display names, and audit log resolution. -
Local provider (OpenIddict / ASP.NET Core Identity) — users live in the application database as
GranitUser. They are already queryable via SQL.
With the introduction of Granit.OpenIddict (#383), the UserCacheEntry
table and UserCacheSyncMiddleware become redundant in local mode:
GranitUserIS the source of truth — no cache needed.- The sync middleware performs unnecessary writes on every authenticated request.
- Three user tables (
GranitUser+UserCacheEntry+ appUserProfile) create data duplication and desynchronization risk.
Decision
Section titled “Decision”IUserLookupService is the single abstraction
Section titled “IUserLookupService is the single abstraction”All framework modules (Auditing, Notifications, Timeline, etc.) consume
users exclusively through IUserLookupService. Two implementations exist:
| Mode | Implementation | Data source |
|---|---|---|
| External provider | CachedUserLookupService | UserCacheEntry (SQL cache) |
| Local provider | AspNetIdentityUserLookupService | GranitUser (direct SQL) |
The active implementation is determined by which identity provider package is
registered (Granit.Identity.Federated.Keycloak vs Granit.Identity.Local.AspNetIdentity).
IIdentityProviderCapabilities.IsLocalStore
Section titled “IIdentityProviderCapabilities.IsLocalStore”A new IsLocalStore property on IIdentityProviderCapabilities indicates
whether users are stored locally. This property drives:
UserCacheSyncMiddleware— short-circuits whenIsLocalStore = trueGranit.Identity.Federated.EntityFrameworkCore— not required whenIsLocalStore = true
UserCacheEntry is a caching concern
Section titled “UserCacheEntry is a caching concern”UserCacheEntry is an implementation detail of CachedUserLookupService,
not a first-class domain concept:
- No module outside
Granit.Identity.Federated.EntityFrameworkCorereferences it directly. - It is not created when the identity provider is local (OpenIddict).
- It exists solely to enable SQL queries against external provider user data.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- No redundant database table in OpenIddict mode.
- No wasteful per-request sync middleware in OpenIddict mode.
- Clean abstraction — consumers of
IUserLookupServicework unchanged. IIdentityProviderCapabilities.IsLocalStoreenables future optimizations (e.g., skip background stale refresh when local).
Negative
Section titled “Negative”- Adding
IsLocalStoreto the interface requires updating all existing provider capability implementations (5 files — one-time cost).
Neutral
Section titled “Neutral”- Application-level
UserProfile(preferences, avatar) remains the application’s responsibility in both modes.