Group Scoping Pattern¶
All data entities are scoped to groups for multi-tenant isolation. Scoping is enforced by a named EF Core global query filter — handlers never write GroupId == predicates.
Overview¶
flowchart TB
subgraph Request["Request Pipeline"]
A[Request] --> B[CurrentGroupIdBehavior]
B --> C[Handler]
end
subgraph Query["Query Building"]
C --> D["DbSet<T>"]
D --> E["GroupScope filter<br/>(GroupId == current group)"]
E --> F[Database]
C -.->|"AcrossAllGroups()"| F
end IGroupScoped Interface¶
Entities with group isolation implement IGroupScoped:
Named Global Query Filter¶
DataContext.OnModelCreating applies a named query filter (QueryFilters.GroupScope) to every group-scoped entity. The filter compares GroupId against the authenticated user's group from ICurrentUserAccessor:
private void ApplyGroupScopeFilter<TEntity>(ModelBuilder modelBuilder) where TEntity : class, IGroupScoped
=> modelBuilder.Entity<TEntity>().HasQueryFilter(QueryFilters.GroupScope, e => e.GroupId == CurrentGroupId);
Entities with the GroupScope filter:
| Entity | Notes |
|---|---|
Marker | + SoftDelete filter |
Note | + SoftDelete filter |
Transaction | + SoftDelete filter |
Category | + SoftDelete filter, IOrderable |
BudgetAccount | + SoftDelete filter |
RecurringTransaction | + SoftDelete filter |
ActionHistory | Change-tracking log |
Entities WITHOUT the GroupScope filter:
| Entity | Reason |
|---|---|
GroupModule | Admin endpoints read module configuration across groups |
User | Nullable GroupId (Guid?) |
StoredFile | Nullable GroupId (profile pics have no group) |
Group | Is the group itself |
NoteItem | Scoped via parent Note relationship |
Query Patterns¶
Standard Query¶
No group predicate needed — the filter applies automatically:
return await _context.Transactions
.AsNoTracking()
.Where(t => t.Date >= startDate && t.Date <= endDate)
.ToListAsync(cancellationToken);
Never add manual group predicates
Do not write Where(x => x.GroupId == ...) for filtered entities. The filter already applies; a manual predicate is dead code that suggests scoping is opt-in.
Opting Out: AcrossAllGroups¶
Background jobs and admin code that must span groups opt out explicitly:
public static IQueryable<T> AcrossAllGroups<T>(this IQueryable<T> query) where T : class, IGroupScoped
=> query.IgnoreQueryFilters([QueryFilters.GroupScope]);
var dueTransactions = await _context.RecurringTransactions
.AcrossAllGroups()
.Where(rt => rt.IsEnabled && rt.NextOccurrence <= now)
.ToListAsync(cancellationToken);
Because the filter is named, AcrossAllGroups() removes only group scoping — the SoftDelete filter stays active. Use IncludeSoftDeleted() (see Soft Delete) to remove that one independently.
Combined with Other Filters¶
return await _context.Notes
.AsNoTracking()
.AccessibleTo(request.CurrentUserId)
.OrderByDescending(n => n.IsPinned)
.ToListAsync(cancellationToken);
Write-Side Stamping¶
Reads are implicit; writes stay explicit. CurrentGroupIdBehavior injects CurrentGroupId into requests implementing IRequestWithCurrentGroupId, and command handlers stamp it onto new entities:
public sealed class CreateCategoryCommand : IRequest<Guid>, IRequestWithCurrentGroupId
{
public Guid CurrentGroupId { get; set; } // Auto-injected by behavior
}
See Pipeline Behaviors for details.
Benefits¶
| Benefit | Description |
|---|---|
| Fail-safe | A forgotten predicate cannot leak cross-group data; a forgotten opt-out yields a loud empty result |
| Zero per-handler cost | Handlers contain only feature logic; scoping is defined once in DataContext |
| Auditable opt-outs | AcrossAllGroups() call sites are the complete list of cross-group reads |
| Composable | Named filters can be removed independently (GroupScope vs SoftDelete) |
Related¶
- Pipeline Behaviors — CurrentGroupIdBehavior auto-injection
- Soft Delete — SoftDelete named filter
- Authorization — Module-based access control