The Quota block enforces per-tenant resource limits. Storage bytes used, number of active users, API calls per month, anything you care to meter. It ships Valkey-backed counters (Valkey is a Redis-compatible, BSD-licensed Redis fork) with an in-memory fallback (for dev/test only), plan-driven limits with per-tenant overrides, an IQuotaGaugeProvider hook for modules that expose live counts (the Identity module exposes user count this way), and a request middleware that returns 429 when a tenant blows past its plan.
What it ships
Extensions
AddHeroQuotas(services, configuration)— registers anIQuotaServicebased onQuotaOptions:Enabledand whether Valkey is configured. Disabled →NoopQuotaService. Enabled + Valkey →RedisQuotaService. Enabled + no Valkey →InMemoryQuotaService. Also registersQuotaPlanResolver+QuotaEnforcementMiddleware.UseHeroQuotas(app)— inserts the enforcement middleware into the pipeline (after authentication, before authorization).
Service interfaces
IQuotaService—CheckAsync,RecordAsync,CheckAndRecordAsync,GetCurrentAsync. The atomic check-and-record is the one you want for any “this counts towards the quota” path.IQuotaGaugeProvider— extensibility hook for modules that expose live usage. Each provider declares whichQuotaResourceit gauges and an async function that returns the current value.
Implementations
RedisQuotaService— hash-per-tenant-per-period counters with a TTL that aligns with the period end.InMemoryQuotaService—ConcurrentDictionarycounters in-process. Single-instance only.NoopQuotaService— drop-in for when quotas are off; every check passes.
Models
QuotaCheckResult—Allowed,LimitAmount,CurrentAmount.QuotaPlanResolver— resolves a tenant’s plan to aDictionary<QuotaResource, long>of limits, merging plan defaults with tenant overrides.
Middleware
QuotaEnforcementMiddleware— runs once per request after auth. Inspects the request’s primary resource (configured per endpoint) and returns429 TooManyRequestsif the limit’s exceeded.
Options
QuotaOptions—Enabled(bool, default true),Redis(connection string),DefaultPlan(string; e.g.free),Plans(Dict —Plans:free:StorageBytes = 1073741824),ExemptRootTenant(bool, default true).
How modules consume Quota
The Storage block wraps IStorageService with QuotaMeteredStorageService to record bytes used on upload:
public async Task<StoredObjectMetadata> UploadAsync<T>(FileUploadRequest req, FileType type, CancellationToken ct){ var size = req.Data.Length; var check = await _quota.CheckAndRecordAsync(_current.GetTenant(), QuotaResource.StorageBytes, size, ct).ConfigureAwait(false); if (!check.Allowed) throw new CustomException($"Storage quota exceeded: {check.CurrentAmount}/{check.LimitAmount} bytes", HttpStatusCode.TooManyRequests); return await _inner.UploadAsync<T>(req, type, ct).ConfigureAwait(false);}The Identity module exposes user count as a gauge:
public sealed class IdentityUserCountGaugeProvider(UserManager<FshUser> users) : IQuotaGaugeProvider{ public QuotaResource Resource => QuotaResource.Users; public async ValueTask<long> ReadAsync(Guid tenantId, CancellationToken ct) => await users.Users.LongCountAsync(u => u.TenantId == tenantId && u.IsActive, ct).ConfigureAwait(false);}The quota service compares the gauge value against the plan limit on every CheckAsync.
Configuration
{ "QuotaOptions": { "Enabled": true, "Redis": "localhost:6379", "DefaultPlan": "free", "ExemptRootTenant": true, "Plans": { "free": { "StorageBytes": 1073741824, // 1 GB "Users": 5, "Requests": 100000 }, "growth": { "StorageBytes": 53687091200, // 50 GB "Users": 50, "Requests": 1000000 } } }}Per-tenant overrides go on AppTenantInfo.QuotaLimits. Anything you set there wins over the plan’s default.
How to extend
Add a new gauge
public sealed class TicketsCountGaugeProvider(ITicketsDbContext db) : IQuotaGaugeProvider{ public QuotaResource Resource => QuotaResource.OpenTickets; public async ValueTask<long> ReadAsync(Guid tenantId, CancellationToken ct) => await db.Tickets.LongCountAsync(t => t.TenantId == tenantId && t.Status != TicketStatus.Closed, ct).ConfigureAwait(false);}
services.AddScoped<IQuotaGaugeProvider, TicketsCountGaugeProvider>();…and add OpenTickets to the QuotaResource enum + Plans dict in config.
Disable quotas for a specific endpoint
The middleware reads endpoint metadata. Add .AllowAnonymousQuota() (custom helper in the kit) — or just don’t call CheckAndRecordAsync from the handler. For request-count quotas, the middleware enforces; tag the endpoint with [QuotaExempt] to bypass.
Use a different period
The default period is the calendar month. To use a sliding window or a custom window, replace QuotaPlanResolver.GetPeriodStart with your own logic and update RedisQuotaService TTL accordingly.
Gotchas
- Period is calendar-month, UTC. Counters reset at midnight UTC on the 1st. Cross-timezone customers see “your usage reset” at 4 PM PT on the 31st. Document this in your dashboard.
- Gauges are read on-demand. Each
CheckAsyncinvokes the gauge providers, which usually do a DB query. Cache the results inside the gauge if the data is expensive to compute and slightly stale is fine. InMemoryQuotaServiceis not cluster-safe. Two instances of the API hold separate counters. Use Valkey (or any distributed cache) in production.- Root tenant exemption is global. With
ExemptRootTenant = true, the root/platform tenant has unlimited everything. Don’t disable this in production unless you have a reason — internal admin actions tend to exceed all the limits you set for paying customers. - Valkey TTL. Period keys carry a TTL aligned to the period end. Clock skew between instances can produce off-by-one errors at the period boundary. Use NTP.
Critical files
src/BuildingBlocks/Quota/Extensions.cssrc/BuildingBlocks/Quota/IQuotaService.cssrc/BuildingBlocks/Quota/RedisQuotaService.cssrc/BuildingBlocks/Quota/QuotaPlanResolver.cssrc/BuildingBlocks/Quota/QuotaEnforcementMiddleware.cs
Related
- Storage — uses the metered decorator for
StorageBytes. - Identity module — exposes the user-count gauge.
- Billing module — plan keys here must match plan keys there.