Entitlement Checking
Sub-millisecond access control with Redis caching for feature gates and usage limits.
Entitlement Checking determines whether a customer has access to a feature or has exceeded usage limits. It's optimized for sub-millisecond response times using aggressive Redis caching and read-optimized data structures.
Structure
type Check struct {
SubscriptionID id.SubscriptionID
CustomerID id.CustomerID
Feature string
Allowed bool
Reason string
UsageCurrent int64
UsageLimit int64
CachedAt time.Time
}Checking entitlements
The primary API is entitlement.Check:
import "github.com/xraph/ledger/entitlement"
result, err := entitlement.Check(ctx, &entitlement.Request{
CustomerID: customer.ID,
Feature: "api-calls",
})
if !result.Allowed {
return fmt.Errorf("access denied: %s", result.Reason)
}
// Feature is allowed - proceed
processAPICall(ctx)Check reasons
When Allowed is false, Reason explains why:
| Reason | Description |
|---|---|
no_subscription | Customer has no active subscription |
feature_not_included | Feature not in current plan |
limit_exceeded | Usage limit reached |
subscription_inactive | Subscription is canceled/past_due |
trial_expired | Trial ended without payment method |
Caching strategy
Entitlement checks are cached in Redis with a 60-second TTL:
// First check: queries database and caches result
check1, _ := entitlement.Check(ctx, req) // ~10ms
// Second check: returns from cache
check2, _ := entitlement.Check(ctx, req) // <1msCache keys are structured as:
entitlement:{customer_id}:{feature}The cache is invalidated when:
- Subscription changes (upgrade, downgrade, cancel)
- Usage is recorded for the feature
- Plan is modified
Feature types
Boolean features
Simple on/off access:
check, _ := entitlement.Check(ctx, &entitlement.Request{
CustomerID: customer.ID,
Feature: "priority-support",
})
if check.Allowed {
routeToSupport("priority-queue")
}Metered features
Features with usage limits:
check, _ := entitlement.Check(ctx, &entitlement.Request{
CustomerID: customer.ID,
Feature: "api-calls",
})
if !check.Allowed {
return fmt.Errorf("quota exceeded: %d/%d calls used",
check.UsageCurrent, check.UsageLimit)
}
fmt.Printf("Remaining: %d calls\n", check.UsageLimit - check.UsageCurrent)Licensed features
Seat-based features:
check, _ := entitlement.Check(ctx, &entitlement.Request{
CustomerID: customer.ID,
Feature: "team-members",
Quantity: 1, // Adding 1 member
})
if !check.Allowed {
return fmt.Errorf("team full: %d/%d seats used",
check.UsageCurrent, check.UsageLimit)
}Batch checking
Check multiple features at once:
results, err := entitlement.CheckBatch(ctx, &entitlement.BatchRequest{
CustomerID: customer.ID,
Features: []string{"api-calls", "team-members", "priority-support"},
})
for feature, check := range results {
if !check.Allowed {
log.Printf("%s: denied - %s", feature, check.Reason)
}
}Batch checks are performed in parallel and return a map.
Soft limits vs hard limits
Ledger supports both enforcement modes:
Hard limit (default)
Access is denied when limit is reached:
check, _ := entitlement.Check(ctx, req)
if !check.Allowed {
return ErrQuotaExceeded
}Soft limit (monitor only)
Access is allowed but overage is tracked:
check, _ := entitlement.Check(ctx, &entitlement.Request{
CustomerID: customer.ID,
Feature: "api-calls",
Enforce: false, // Soft limit
})
// Always allowed, but check.UsageCurrent may exceed check.UsageLimit
if check.UsageCurrent > check.UsageLimit {
// Log overage event
events.Emit(ctx, &events.OverageEvent{
CustomerID: customer.ID,
Feature: "api-calls",
Overage: check.UsageCurrent - check.UsageLimit,
})
}Real-time usage updates
When usage is metered, entitlements are recalculated automatically:
// Record usage
meter.Record(ctx, &meter.Event{
SubscriptionID: subscription.ID,
Meter: "api-calls",
Value: 1,
})
// Cache invalidated, next check queries fresh usage
check, _ := entitlement.Check(ctx, &entitlement.Request{
CustomerID: customer.ID,
Feature: "api-calls",
})Middleware integration
Use entitlement checks in HTTP middleware:
func EntitlementMiddleware(feature string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
customerID := getCustomerID(r.Context())
check, err := entitlement.Check(r.Context(), &entitlement.Request{
CustomerID: customerID,
Feature: feature,
})
if err != nil || !check.Allowed {
http.Error(w, check.Reason, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// Usage:
router.Handle("/api/v1/analytics", EntitlementMiddleware("analytics-access")(analyticsHandler))Pre-warming the cache
For critical features, pre-warm the cache on subscription creation:
func onSubscriptionCreated(ctx context.Context, sub *subscription.Subscription) {
plan, _ := store.GetPlan(ctx, sub.PlanID)
for _, feature := range plan.Features {
// Prime the cache
entitlement.Check(ctx, &entitlement.Request{
CustomerID: sub.CustomerID,
Feature: feature.Name,
})
}
}Performance targets
Ledger's entitlement system is optimized for:
| Metric | Target | Actual |
|---|---|---|
| Cache hit latency | <1ms | ~0.3ms |
| Cache miss latency | <10ms | ~8ms |
| Cache hit rate | >95% | ~98% |
| Concurrent checks | 10K/sec | 15K/sec |
Store interface
type Store interface {
CheckEntitlement(ctx context.Context, req *Request) (*Check, error)
CheckBatch(ctx context.Context, req *BatchRequest) (map[string]*Check, error)
InvalidateCache(ctx context.Context, customerID id.CustomerID, feature string) error
GetEntitlements(ctx context.Context, customerID id.CustomerID) ([]*Check, error)
}API routes
| Method | Path | Description |
|---|---|---|
POST | /ledger/entitlements/check | Check single entitlement |
POST | /ledger/entitlements/check-batch | Check multiple entitlements |
GET | /ledger/entitlements/{customerId} | List all entitlements |
POST | /ledger/entitlements/{customerId}/invalidate | Invalidate cache |