Skip to content

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:

public interface IGroupScoped
{
    Guid GroupId { get; }
}

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)