Module Collaboration Deep Dive

How Identity and Entitlements collaborate via Core abstractions, the NoOp safety pattern, SubscriptionChangedEvent lifecycle, and deployment topology impact.

SCRIPE has 5 modules (Identity, Entitlements, Compliance, Plugins, Marketplace). They are hermetically sealed — no cross-imports allowed. Yet they must collaborate to drive permissions, subscription features, and billing. The solution: the Core.Application.Abstractions layer acts as a typed, interface-based bridge. Every cross-module interaction flows through this bridge — never through direct module-to-module imports. This page documents every interface, pattern, and runtime flow that makes this work.

The Core Layer Bridge

Core.Application.Abstractions is the heart of cross-module communication. It defines 32+ interface contracts. Identity.Infrastructure and Entitlements.Infrastructure each implement their respective side of these contracts. The AstraFlow pipeline behaviors and module handlers consume only the interfaces — never the concrete implementations. This means the system compiles and runs identically whether Entitlements is deployed or not.

Core Layer Bridge — Cross-Module Dependency Inversion
Identity.Infrastructure — Implements IPermissionReader, IAdminPermissionCache, ITenantPermissionManager
Entitlements.Infrastructure — Implements IFeatureChecker, ISubscriptionStatusProvider
implements
Core.Application.Abstractions — 32 cross-module interface contracts — the Bridge
injected into
AstraFlow Pipeline Behaviors — Consumes interfaces — never the concrete classes

Complete Cross-Module Interface Catalog

The following table documents every interface that crosses module boundaries. Defined in Core.Application, these interfaces are the only legal way for modules to communicate with each other.

InterfaceDefined InImplemented ByPurpose
IFeatureCheckerCore.ApplicationEntitlements.InfrastructureEdition-based feature/quota gating for tenant commands
IPermissionReaderCore.ApplicationIdentity.InfrastructureEntitlements reads permission IDs by module name
ITenantPermissionManagerCore.ApplicationIdentity.InfrastructureEntitlements syncs permission pool after subscription change
ISubscriptionStatusProviderCore.ApplicationEntitlements.InfrastructureIdentity enriches login response with subscription status
IAdminPermissionCacheCore.ApplicationIdentity.InfrastructureServer-side Redis cache for admin permissions/roles/fields
IOverflowResourceDeactivatorCore.ApplicationIdentity.InfrastructureEntitlements deactivates excess admins/users on quota reduce
ITenantProvisionerCore.ApplicationIdentity.InfrastructureEntitlements triggers tenant provisioning on signup
IPublicEditionProviderCore.ApplicationEntitlements.InfrastructureSignup flow reads available editions without module dependency
IUserFeatureCheckerCore.ApplicationEntitlements.InfrastructureUser-level feature gating (separate from tenant-level)
IQuotaCounterServiceCore.ApplicationIdentity.InfrastructureReal-time quota counting for admins/users per tenant

The NoOp Safety Pattern

Core.Infrastructure registers a NoOp (no-operation) implementation for every cross-module interface. These are registered with TryAddScoped, meaning real module implementations override them when deployed. If a module fails to load, the NoOp silently keeps the system alive. Startup diagnostics detect when critical interfaces are still NoOp and emit LogCritical warnings.

Core.Infrastructure/Features/NoOpFeatureChecker.csC#
/// <summary>
/// No-op feature checker — all features enabled, all limits unlimited.
/// Used when the Entitlements module is not deployed.
/// Entitlements module will register its own implementation to override this.
/// </summary>
public class NoOpFeatureChecker : IFeatureChecker
{
    public Task<bool> IsEnabledAsync(Guid tenantId, string featureName, CancellationToken ct = default)
        => Task.FromResult(true);  // All features enabled when no Entitlements

    public Task<int> GetLimitAsync(Guid tenantId, string featureName, CancellationToken ct = default)
        => Task.FromResult(-1);  // -1 = unlimited

    public Task<int> GetTenantTierLevelAsync(Guid tenantId, CancellationToken ct = default)
        => Task.FromResult(0);  // 0 = free tier
}
InterfaceNoOp ClassNoOp Behavior
IFeatureCheckerNoOpFeatureCheckerAll features enabled, all limits -1 (unlimited)
ISubscriptionStatusProviderNoOpSubscriptionStatusProviderReturns null (no subscription) for all tenants
IPublicEditionProviderNoOpPublicEditionProviderReturns empty edition list (signup won't work)
ITenantPermissionManagerNoOpTenantPermissionManagerSilently does nothing on permission sync
IPermissionReaderNoOpPermissionReaderReturns empty permission lists

NoOp Registration — TryAddScoped vs AddScoped

The entire NoOp override mechanism depends on one critical rule: Core.Infrastructure registers NoOps with TryAddScoped. Real module implementations register with AddScoped. Because TryAddScoped only registers if no service is registered yet, calling AddScoped afterward unconditionally overrides it. The order matters: Core.Infrastructure always loads first (it's a transitive dependency of all module Infrastructure projects), so the NoOp is always registered first, and the module's real implementation always wins.

Core.Infrastructure/DependencyInjection.cs — NoOp RegistrationsC#
// Core.Infrastructure registers ALL NoOp fallbacks using TryAddScoped.
// Real modules override these with AddScoped (not Try) in their own DI.
services.TryAddScoped<IFeatureChecker, NoOpFeatureChecker>();
services.TryAddScoped<ISubscriptionStatusProvider, NoOpSubscriptionStatusProvider>();
services.TryAddScoped<IPublicEditionProvider, NoOpPublicEditionProvider>();
services.TryAddScoped<ITenantPermissionManager, NoOpTenantPermissionManager>();
services.TryAddScoped<IPermissionReader, NoOpPermissionReader>();
services.TryAddScoped<IOverflowResourceDeactivator, NoOpOverflowResourceDeactivator>();
services.TryAddScoped<IQuotaCounterService, NoOpQuotaCounterService>();
services.TryAddScoped<IUserFeatureChecker, NoOpUserFeatureChecker>();

// ⚠️ Real module DI overrides with AddScoped (no Try prefix):
// Entitlements.Infrastructure/DependencyInjection.cs:
services.AddScoped<IFeatureChecker, FeatureChecker>();
services.AddScoped<ISubscriptionStatusProvider, SubscriptionStatusProvider>();

Startup Diagnostics — Detecting NoOp Leakage

PostBuildInitialization.cs runs after the DI container is built and all modules are registered. It checks the resolved type for critical interfaces. If the resolved type is still a NoOp implementation, it logs a LogCritical message. This is the production safety net — it doesn't crash the server, but it produces a visible alert in logs and monitoring dashboards that operators can act on immediately.

Host/API/PostBuildInitialization.cs — NoOp DetectionC#
// PostBuildInitialization.cs checks for NoOp implementations at startup:
var featureChecker = app.Services.GetService<IFeatureChecker>();
if (featureChecker is NoOpFeatureChecker)
{
    startupLogger.LogCritical(
        "ENTITLEMENTS SAFETY: IFeatureChecker is NoOp. " +
        "All features will be enabled and all quotas unlimited. " +
        "Load the Entitlements module to enforce edition-based gating."
    );
}

var permManager = app.Services.GetService<ITenantPermissionManager>();
if (permManager is NoOpTenantPermissionManager)
{
    startupLogger.LogCritical(
        "IDENTITY SAFETY: ITenantPermissionManager is NoOp. " +
        "Subscription changes will NOT sync permissions. " +
        "Load the Identity module in the same process as Entitlements."
    );
}

FeatureCheckBehavior — The Gating Point

FeatureCheckBehavior is an AstraFlow pipeline behavior that intercepts commands implementing IRequireFeature. It resolves the tenant ID from the current user context, calls IFeatureChecker.IsEnabledAsync, and either passes the request through or returns a 403 Forbidden result. Because it uses IFeatureChecker (not a concrete class), it works transparently whether Entitlements is deployed or not. When Entitlements is absent, NoOpFeatureChecker returns true for every check, making the behavior a transparent pass-through with zero overhead.

FeatureCheckBehavior — Request Gate Flow
AstraFlow Request — Any command in the pipeline
Implements IRequireFeature? — Opt-in marker interface
yes + system admin
System Admin Bypass — IsSystemProtectedAdmin skips all checks
Platform SuperAdmin Bypass — TenantId=null + IsSuperAdmin bypasses feature gating
Tenant Context Required — No TenantId → 403 Forbidden
tenant present
IFeatureChecker.IsEnabledAsync() — Delegates to Entitlements or NoOp
true
Feature Enabled → Continue
Feature Disabled → Result.Failure(403) — Short-circuits before handler
Commands/Example — Opt-in to Feature GatingC#
// ✅ This command requires the 'Communication.BulkEmail' feature to be enabled
public record SendBulkEmailCommand(Guid TenantId, List<string> Recipients, string Body)
    : ICommand, IRequireFeature
{
    public string RequiredFeatureName => "Communication.BulkEmail";
}

// Handler runs ONLY if the tenant's edition has BulkEmail enabled
public class SendBulkEmailCommandHandler : IRequestHandler<SendBulkEmailCommand, Result>
{
    public async Task<Result> Handle(SendBulkEmailCommand request, CancellationToken ct)
    {
        // Feature gating already checked by FeatureCheckBehavior
        // If we're here, the tenant IS licensed for BulkEmail
        // ...
    }
}

IRequireFeature — The Opt-In Marker Interface

IRequireFeature is a zero-overhead marker interface. Commands that implement it opt into edition-based feature gating via FeatureCheckBehavior. Commands that don't implement it pass through the behavior with zero overhead. This design means feature gating is explicit and opt-in — existing commands are never accidentally gated, and new commands consciously declare their feature requirements.

Core.Application.Abstractions/IRequireFeature.csC#
/// <summary>
/// Opt-in marker interface for commands that require an edition feature to be active.
/// When a command implements this, FeatureCheckBehavior intercepts the pipeline
/// and checks IFeatureChecker before allowing the handler to execute.
/// </summary>
public interface IRequireFeature
{
    /// <summary>
    /// The feature name to check. Must match an edition feature key.
    /// Convention: "{Module}.{FeatureName}" e.g. "Communication.BulkEmail"
    /// </summary>
    string RequiredFeatureName { get; }
}

SubscriptionChangedEvent — The Permission Sync Backbone

SubscriptionChangedEvent is the most critical cross-module domain event in SCRIPE. Published by Entitlements, handled by Identity. It carries the complete effective feature set, the subscription status, and pre-expanded bundle data. Identity uses this event to rebuild the tenant's entire permission pool — adding permissions for newly enabled modules and removing permissions for disabled ones. This is how a billing change in Entitlements becomes a permission change in Identity without any direct module coupling.

Core.Domain/Events/SubscriptionChangedEvent.csC#
/// <summary>
/// Domain event published when a tenant's subscription plan is created, changed, or revoked.
/// Carries subscription status so handlers can decide whether to grant or revoke permissions.
/// </summary>
public sealed record SubscriptionChangedEvent(
    Guid TenantId,
    Guid? EditionId,
    string? EditionName,
    DateTime? EndDate,
    Dictionary<string, string> EffectiveFeatures,
    string SubscriptionStatus = "Active",
    Guid AssignedByAdminId = default,
    List<BundleExpansionDto>? BundleExpansions = null
) : DomainEvent
{
    // True when event represents revocation — handler removes permissions
    public bool IsRevocation => SubscriptionStatus is "Canceled" or "Expired" or "PendingPayment" or "Suspended";
};

All Commands That Publish SubscriptionChangedEvent

SubscriptionChangedEvent is published by any Entitlements command or service that changes a tenant's subscription state. The following table documents every trigger point in the system. Understanding this list is essential for debugging permission sync issues — if a tenant's permissions are wrong, one of these triggers is the source of the last sync.

TriggerEntitlements Command / ServiceNotes
Edition AssignedAssignEditionCommandHandlerGrants permissions for enabled modules
Edition ChangedChangeEditionCommandHandlerDiffs old vs new — adds/removes permissions
Subscription CanceledCancelSubscriptionCommandHandlerIsRevocation = true — permissions removed
Subscription RevokedRevokeSubscriptionCommandHandlerHard removal of all subscription permissions
Subscription SuspendedSuspendSubscriptionCommandHandlerIsRevocation = true
Subscription ResumedResumeSubscriptionCommandHandlerRe-grants permissions from current edition
Stripe Webhook (invoice.paid)StripeWebhookHelperPayment confirmed → activate subscription
Grace Period ExpiredFallbackDowngradeServiceDowngrades to Fallback edition
Edition Rollout JobEditionRolloutJobScheduled batch rollout to all subscribed tenants
Manual ResyncResyncTenantPermissionsCommandHandlerAdmin triggers permission rebuild
Signup Checkout RecoveryDirectSignupCheckoutRecoveryServiceStripe webhook recovery
Reconciliation SweepSignupReconciliationSweepJobDaily sweep for abandoned signups

Permission Sync Lifecycle — Step by Step

When a tenant's subscription changes, a precise 5-step lifecycle executes to rebuild their permission pool. Understanding this lifecycle is essential for debugging permission issues and for designing new subscription-driven features.

  1. Step 1 — Entitlements Resolves Edition Features

    The Entitlements command handler (e.g., AssignEditionCommandHandler) resolves the full effective feature map for the tenant. This merges the Edition's base features, any TenantFeatureOverrides, and BundleExpansion feature additions. The result is a flat dictionary of feature name to value (e.g., 'Communication.Enabled' → 'true'). From this, it derives which module names are enabled.
    C#
    // AssignEditionCommandHandler resolves the full effective feature map:
    // Edition features → merged with TenantFeatureOverrides → BundleExpansions
    var effectiveFeatures = await _editionFeatureResolver
        .ResolveEffectiveFeaturesAsync(edition, tenantOverrides, bundles, ct);
    
    // Identifies which modules are enabled by checking boolean features:
    // e.g., "Communication.Enabled" == "true" → include Communication module
    var enabledModules = effectiveFeatures
        .Where(kvp => kvp.Key.EndsWith(".Enabled") && kvp.Value == "true")
        .Select(kvp => kvp.Key.Replace(".Enabled", ""))
        .ToList();
  2. Step 2 — Event Published via Outbox

    The SubscriptionChangedEvent is raised on the entity as a domain event. The EF Core OutboxInterceptor captures it before SaveChangesAsync. The event is persisted to the OutboxMessages table in the same database transaction as the subscription change. After commit, the OutboxProcessor dispatches the event to all registered handlers. This guarantees exactly-once delivery even if the process crashes mid-execution.
    C#
    // SubscriptionChangedEvent is raised as a domain event, captured by OutboxInterceptor,
    // persisted in OutboxMessages table, then dispatched after SaveChangesAsync:
    entity.AddDomainEvent(new SubscriptionChangedEvent(
        TenantId: command.TenantId,
        EditionId: edition.Id,
        EditionName: edition.Name,
        EffectiveFeatures: effectiveFeatures,
        SubscriptionStatus: "Active",
        AssignedByAdminId: _currentUser.AdminId!.Value,
        BundleExpansions: bundleExpansions
    ));
    await _unitOfWork.SaveChangesAsync(ct);
    // OutboxProcessor dispatches the event after commit
  3. Step 3 — Identity Handler Processes the Event

    Identity.Application's SubscriptionChangedEventHandler receives the event. If IsRevocation is true (subscription canceled/expired/suspended), it calls SyncPermissionsForModulesAsync with an empty module list — which removes all edition-based permissions. If not a revocation, it syncs permissions for all enabled modules and processes each BundleExpansion (grant and deny codes).
    C#
    // Identity.Application handles the SubscriptionChangedEvent:
    public class SubscriptionChangedEventHandler
        : INotificationHandler<DomainEventNotification<SubscriptionChangedEvent>>
    {
        public async Task Handle(...)
        {
            if (notification.DomainEvent.IsRevocation)
            {
                // Remove all edition-based permissions from tenant pool
                await _tenantPermissionManager.SyncPermissionsForModulesAsync(
                    tenantId: e.TenantId,
                    enabledModules: [],  // Empty = revoke all module permissions
                    effectiveFeatures: [],
                    assignedByAdminId: e.AssignedByAdminId
                );
            }
            else
            {
                // Grant permissions for enabled modules + bundle expansions
                await _tenantPermissionManager.SyncPermissionsForModulesAsync(
                    tenantId: e.TenantId,
                    enabledModules: enabledModules,
                    effectiveFeatures: e.EffectiveFeatures,
                    assignedByAdminId: e.AssignedByAdminId
                );
    
                // Process bundle expansions (grant/deny permission codes)
                foreach (var bundle in e.BundleExpansions ?? [])
                {
                    await _tenantPermissionManager.SyncBundlePermissionsAsync(
                        tenantId: e.TenantId,
                        grantCodes: bundle.GrantPermissionCodes.ToHashSet(),
                        denyCodes: bundle.DenyPermissionCodes.ToHashSet(),
                        assignedByAdminId: e.AssignedByAdminId
                    );
                }
            }
        }
    }
  4. Step 4 — ITenantPermissionManager Syncs the Pool

    Identity.Infrastructure.TenantPermissionManager implements ITenantPermissionManager. It uses IPermissionReader to get all permission IDs for the enabled modules from the Identity database. It filters these by RequiredFeature (permissions gated on specific features are only granted if that feature is enabled). It diffs against the current TenantPermission records, adding missing and removing excess entries atomically.
    C#
    // Identity.Infrastructure.TenantPermissionManager:
    // 1. IPermissionReader.GetPermissionIdsByModulesAsync() — reads from Identity DB
    // 2. Filters by RequiredFeature (only grant if feature is enabled in effectiveFeatures)
    // 3. Gets current TenantPermission records from DB
    // 4. Diffs: adds missing, removes excess
    // 5. Result: (Added, Removed) count returned
    public async Task<(int Added, int Removed)> SyncPermissionsForModulesAsync(
        Guid tenantId,
        IEnumerable<string> enabledModules,
        IReadOnlyDictionary<string, string> effectiveFeatures,
        Guid assignedByAdminId,
        CancellationToken ct = default)
    {
        var permissionIds = await _permissionReader
            .GetPermissionIdsByModulesAsync(enabledModules, ct);
    
        // Filter out permissions whose RequiredFeature is not in effectiveFeatures
        var grantableIds = permissionIds
            .Where(id => IsFeatureEnabled(id, effectiveFeatures))
            .ToHashSet();
    
        var currentIds = await _tenantPermRepo.GetPermissionIdsAsync(tenantId, ct);
        var toAdd = grantableIds.Except(currentIds).ToList();
        var toRemove = currentIds.Except(grantableIds).ToList();
    
        await _tenantPermRepo.AddRangeAsync(toAdd.Select(id => new TenantPermission(tenantId, id)));
        await _tenantPermRepo.RemoveRangeAsync(tenantId, toRemove);
        await _unitOfWork.SaveChangesAsync(ct);
    
        return (toAdd.Count, toRemove.Count);
    }
  5. Step 5 — Admin Permission Cache Invalidated

    After the permission pool is synced, IAdminPermissionCache.InvalidateAll() is called for the tenant. Every admin's cached permission set is cleared from the in-memory + Redis cache. On their next API request, the AuthorizationBehavior will reload their permissions from the database and re-populate the cache. The frontend receives updated permissions on the next token refresh cycle.
    C#
    // After sync, all admin caches for this tenant are invalidated:
    _adminPermissionCache.InvalidateAll();  // Nuclear option after bulk sync
    // Next request for any admin will reload permissions from DB into cache
    // Frontend receives updated permissions on next token refresh

Login Response Enrichment — ISubscriptionStatusProvider

Identity's login handler needs to return the tenant's subscription status so the frontend can display grace period warnings, upgrade prompts, and edition badges. But Identity cannot import Entitlements. The solution: ISubscriptionStatusProvider is injected into the login handler. Entitlements.Infrastructure implements this interface, returning TenantSubscriptionInfo with status, edition name, grace phase, and expiry date.

Identity.Application — Login handler subscription enrichmentC#
// Identity's LoginCommandHandler enriches the JWT response without importing Entitlements:
// ISubscriptionStatusProvider is injected — implemented by Entitlements.Infrastructure
public class LoginCommandHandler : IRequestHandler<LoginCommand, Result<LoginResponse>>
{
    private readonly ISubscriptionStatusProvider _subscriptionStatus;

    public async Task<Result<LoginResponse>> Handle(LoginCommand request, CancellationToken ct)
    {
        // ... auth logic ...

        // Enrich response with subscription status (cross-module via Core interface)
        var subscriptionInfo = await _subscriptionStatus
            .GetTenantSubscriptionInfoAsync(tenant.Id, ct);

        return Result<LoginResponse>.Success(new LoginResponse(
            AccessToken: jwtToken,
            RefreshToken: refreshToken,
            SubscriptionStatus: subscriptionInfo?.Status,
            EditionName: subscriptionInfo?.EditionName,
            GracePhase: subscriptionInfo?.GracePhase,
            GracePeriodEndsAt: subscriptionInfo?.GracePeriodEndsAt
        ));
    }
}

Deployment Topology Impact

The MODULE_NAME environment variable controls which modules are loaded. This fundamentally changes how cross-module communication works. Monolith mode supports all collaboration patterns. Microservice mode has critical limitations you must understand before extracting modules.

  • Monolith Mode (MODULE_NAME="")

    • All 5 modules load in a single process
    • Domain events dispatched in-process (synchronous, zero latency)
    • SubscriptionChangedEvent → Identity handler runs in same request scope
    • Permission sync completes before HTTP response returns
    • Self-service signup fully supported (SignupCheckoutCompletedEvent in-process)
    • NoOp implementations completely absent — overridden by real modules
  • Microservice Mode (MODULE_NAME="Identity")

    • Only the specified module loads — others are absent
    • NoOp implementations active for absent modules
    • Domain events CANNOT cross process boundaries (silent loss)
    • Self-service signup BLOCKED at startup (G15 guard in PostBuildInitialization.cs)
    • Future: outbox + message bus will bridge the gap
    • Gateway mode (MODULE_NAME="Gateway"): YARP proxy only, zero modules

MODULE_NAME Environment Variable Reference

The MODULE_NAME environment variable is set at container startup and determines which modules are loaded into the process. The ModuleRegistration.cs file in Host/API reads this variable and conditionally registers only the specified module's DI registrations and EF Core DbContext. When empty (the default), all modules are registered — this is the monolith mode that supports all cross-module collaboration patterns.

MODULE_NAME ValueModules LoadedUse Case
"" (empty)All 5 modulesMonolith — recommended for most deployments
"Gateway"YARP proxy onlyAPI gateway — routes to individual module services
"Identity"Identity onlyMicroservice — NoOp for Entitlements features
"Entitlements"Entitlements onlyMicroservice — NoOp for Identity permission sync
"Compliance"Compliance onlyStandalone compliance auditing service

Module Co-Dependency Map

This table documents every formal dependency between modules, showing exactly what each module needs from another and how that need is satisfied via Core interfaces. Use this as a reference when planning microservice extraction or when debugging cross-module data flow issues.

ModuleNeeds FromWhat It GetsVia Interface
IdentityEntitlementsSubscription status for login responseISubscriptionStatusProvider
EntitlementsIdentityPermission IDs by module nameIPermissionReader
EntitlementsIdentityPermission pool sync after edition changeITenantPermissionManager
EntitlementsIdentityDeactivate excess admins/users on quota reduceIOverflowResourceDeactivator
EntitlementsIdentityQuota counting for enforcementIQuotaCounterService
All ModulesCoreFeature gating (edition-based)IFeatureChecker
Signup FlowEntitlementsAvailable public editionsIPublicEditionProvider
ComplianceIdentityCurrent user data for audit trailsICurrentUser
All ModulesCoreAdmin permission enforcementIAdminPermissionCache (via AuthBehavior)

The Self-Service Signup Cross-Module Saga

The self-service B2B2C tenant signup is the most complex cross-module saga in SCRIPE. It spans Identity (tenant provisioning), Entitlements (subscription linking and billing), and Stripe (payment processing). Understanding this flow is critical for supporting customers and for maintaining the signup infrastructure.

  1. Phase 1 — Identity Provisions Tenant

    RegisterTenantSelfServiceCommand (in Identity module) executes in a single atomic database transaction. It creates the Tenant entity, sets up the default subdomain and branding, provisions default security roles (Admin, User), creates the tenant owner Admin account, and verifies the email verification ticket. On success, it publishes SignupPhase1CompletedEvent.
    C#
    // RegisterTenantSelfServiceCommand (Identity module):
    // - Creates Tenant + Admin in Identity DB
    // - Configures default roles + permissions
    // - Publishes SignupPhase1CompletedEvent
    // All in a single DB transaction (atomic)
  2. Phase 2 — Entitlements Links Subscription

    The SignupPhase1CompletedEventHandler (in Entitlements module) handles SignupPhase1CompletedEvent. It creates a TenantSubscription record for the chosen edition. If the edition is free, it immediately activates and publishes SubscriptionChangedEvent to grant permissions. If the edition is paid, it creates a Stripe Checkout session and returns the payment URL.
    C#
    // SignupPhase1CompletedEventHandler (Entitlements module):
    // - Creates TenantSubscription for chosen edition
    // - If free edition → activates immediately + publishes SubscriptionChangedEvent
    // - If paid edition → creates Stripe checkout session
    // - Returns checkout URL to frontend for payment redirect
  3. Phase 3 — Stripe Confirms, Entitlements Activates

    When the user completes Stripe checkout, Stripe sends a checkout.session.completed webhook. StripeWebhookHelper (in Entitlements) processes this event, finds the matching SignupSession, activates the subscription, and publishes SubscriptionChangedEvent. Identity's handler grants all edition permissions to the new tenant's permission pool.
    C#
    // StripeWebhookHelper processes checkout.session.completed:
    // - Finds matching SignupSession
    // - Activates subscription → publishes SubscriptionChangedEvent
    // - Identity handler grants all edition permissions to new tenant
  4. Compensation — If Checkout Abandoned

    If the user abandons the Stripe checkout (or Stripe fails to initialize), CompensatePhase1Async is called. This deletes the provisioned tenant and the tenant admin account, preventing orphaned accounts with no active subscription. A daily SignupReconciliationSweepJob also sweeps for stale incomplete signups and cleans them up.
    C#
    // CompensatePhase1Async (Identity module) called if Stripe checkout fails:
    // - Deletes the provisioned tenant
    // - Deletes the tenant admin account
    // - Prevents orphaned accounts with no active subscription
    // SignupReconciliationSweepJob runs daily to clean up stale incomplete signups

Signup Event Chain — Cross-Module Event Flow

The signup saga crosses module boundaries via three in-process domain events. SignupPhase1CompletedEvent flows from Identity to Entitlements. SignupCheckoutCompletedEvent flows within Entitlements (Stripe webhook to activation). SubscriptionChangedEvent flows from Entitlements back to Identity. This bidirectional event chain is why signup ONLY works in monolith mode — all three events require both modules in the same process.

Self-Service Signup — Cross-Module Event Chain
RegisterTenantSelfServiceCommand — Identity module — creates tenant + admin
publishes
SignupPhase1CompletedEvent — Domain event via Outbox
handled by
SignupPhase1CompletedEventHandler — Entitlements module — links subscription
free edition
Free Edition Path — Activates immediately → SubscriptionChangedEvent
Paid Edition Path — Creates Stripe checkout → waits for webhook
user pays
Stripe checkout.session.completed — External webhook from Stripe
activates
SubscriptionChangedEvent — Published by Entitlements
grants permissions
SubscriptionChangedEventHandler — Identity module — grants edition permissions

Bundle Expansion — Fine-Grained Permission Grants

Bundle Expansion allows an edition to grant or deny specific permission codes beyond the module-level enablement that SubscriptionChangedEvent carries. When a subscription includes bundles, the SubscriptionChangedEvent carries pre-expanded BundleExpansionDto entries. Identity's event handler processes each bundle separately via ITenantPermissionManager.SyncBundlePermissionsAsync, which diffs grant and deny codes against the current tenant permission set.

Core.Domain/DTOs/BundleExpansionDto.csC#
/// <summary>
/// Represents a permission bundle expansion attached to a subscription event.
/// Bundles allow fine-grained permission grants/denials beyond module-level enablement.
/// For example: a "PowerUser" bundle might grant extra permissions within an enabled module.
/// </summary>
public record BundleExpansionDto(
    Guid BundleId,
    string BundleName,
    IReadOnlyList<string> GrantPermissionCodes,
    IReadOnlyList<string> DenyPermissionCodes
);

IAdminPermissionCache — The Authorization Cache

IAdminPermissionCache is the server-side Redis cache that AuthorizationBehavior uses to check permissions without hitting the database on every request. It stores a denormalized snapshot of each admin's permissions, roles, and field projections. The cache entry is populated lazily on the first request after a cache miss. InvalidateAll() is called after bulk permission sync operations (SubscriptionChangedEvent handling) to force all admins to reload on their next request.

Core.Application.Abstractions/IAdminPermissionCache.csC#
/// <summary>
/// Server-side Redis cache for admin permissions, roles, and field projections.
/// Populated lazily on first request per admin. Invalidated on role/permission changes.
/// Used by AuthorizationBehavior to enforce RBAC without hitting the DB on every request.
/// </summary>
public interface IAdminPermissionCache
{
    Task<AdminPermissionCacheEntry?> GetAsync(Guid adminId, CancellationToken ct = default);
    Task SetAsync(Guid adminId, AdminPermissionCacheEntry entry, CancellationToken ct = default);
    Task InvalidateAsync(Guid adminId, CancellationToken ct = default);
    Task InvalidateAll(CancellationToken ct = default);  // Nuclear — used after bulk sync
}

public record AdminPermissionCacheEntry(
    IReadOnlySet<string> PermissionCodes,
    IReadOnlySet<string> RoleCodes,
    IReadOnlyDictionary<string, IReadOnlySet<string>> FieldProjections,
    DateTime CachedAt
);
Cache OperationTriggerScope
SetAsync(adminId, entry)First request after cache missSingle admin
InvalidateAsync(adminId)Admin role/permission changedSingle admin
InvalidateAll()SubscriptionChangedEvent processedAll admins in tenant
InvalidateAll()Edition changed for tenantAll admins in tenant
TTL expiry (Redis)Automatic after 15 minutesSingle admin (lazy rebuild)

ICurrentUser — The Ubiquitous Cross-Cutting Interface

ICurrentUser is the one interface that every module uses directly — it's not a cross-module bridge like the others, it's a fundamental cross-cutting concern available everywhere. It's populated by Identity's JWT middleware on every authenticated request and provides the current admin/user context to any handler in any module. Every module depends on Core.Application which defines ICurrentUser, so it's always available without any cross-module ceremony.

Core.Application.Abstractions/ICurrentUser.csC#
/// <summary>
/// Provides information about the currently authenticated user.
/// Available in ALL modules via DI — populated from JWT claims by Identity middleware.
/// This is the single cross-cutting interface that every module uses directly.
/// </summary>
public interface ICurrentUser
{
    Guid? AdminId { get; }
    Guid? UserId { get; }
    Guid? TenantId { get; }
    string? Email { get; }
    bool IsAuthenticated { get; }
    bool IsSuperAdmin { get; }
    bool IsSystemProtectedAdmin { get; }
    IReadOnlySet<string> Permissions { get; }
    IReadOnlySet<string> Roles { get; }
    string? Language { get; }
}

Feature Value Resolution Chain

When IFeatureChecker.IsEnabledAsync() is called for a tenant and feature, Entitlements resolves the value through a priority chain. TenantFeatureOverride (per-tenant manual override) always wins. If no override exists, the EditionFeature value is used. If the edition doesn't define the feature, the Feature.DefaultValue is used. For numeric features with multiple active subscriptions (trial + base plan), the MAX value wins. For boolean features, true wins. For string features, the Base subscription wins.

Feature Value Resolution — Priority Chain
TenantFeatureOverride — Highest priority — per-tenant manual override
fallback if no override
EditionFeature — Edition-level default for this feature
fallback if no edition feature
Feature.DefaultValue — System-wide fallback default
fallback if module absent
NoOp (all enabled / unlimited) — When Entitlements module not loaded
ValueTypeMerge Rule (multi-subscription)Example
BooleanOR — true wins across all active subscriptionsBulkEmail: false + true = true
NumericMAX — highest value wins, -1 = unlimitedMaxAdmins: 5 + 20 = 20
StringFirst subscription wins (Base > Trial > AddOn)SupportTier: "Gold" + "Silver" = "Gold"

Developer Checklist — Adding a New Cross-Module Dependency

When you need two modules to share data, follow this exact pattern. Never import one module from another. Always go through Core.Application.Abstractions.

  1. 1. Define the contract in Core.Application.Abstractions

    Create a new interface file in Core.Application/Abstractions/. The interface should be minimal — only what the consuming module actually needs. Add XML documentation explaining which module implements it and which module consumes it.
    C#
    // Core.Application.Abstractions/IMyNewCrossModuleService.cs
    public interface IMyNewCrossModuleService
    {
        Task<SomeResult> DoSomethingAsync(Guid tenantId, CancellationToken ct = default);
    }
  2. 2. Register a NoOp in Core.Infrastructure

    Create a NoOp implementation in Core.Infrastructure/Services/ (or appropriate subfolder). Register it with TryAddScoped in Core.Infrastructure/DependencyInjection.cs. The NoOp should return a safe, neutral value (null, empty, false, -1 for unlimited). Never throw NotImplementedException in a NoOp.
    C#
    // Core.Infrastructure/Services/NoOpMyNewCrossModuleService.cs
    public class NoOpMyNewCrossModuleService : IMyNewCrossModuleService
    {
        public Task<SomeResult> DoSomethingAsync(Guid tenantId, CancellationToken ct = default)
            => Task.FromResult(SomeResult.Empty);  // Safe fallback
    }
    // Register in Core.Infrastructure/DependencyInjection.cs:
    services.TryAddScoped<IMyNewCrossModuleService, NoOpMyNewCrossModuleService>();
  3. 3. Implement in the target module's Infrastructure

    Create the real implementation in {Module}.Infrastructure/CrossModule/ or {Module}.Infrastructure/Services/. Register it with AddScoped (NOT TryAddScoped) in the module's DependencyInjection.cs. Using AddScoped ensures the real implementation OVERRIDES the NoOp that Core registered first.
    C#
    // {Module}.Infrastructure/CrossModule/MyNewCrossModuleService.cs
    public class MyNewCrossModuleService : IMyNewCrossModuleService
    {
        private readonly IMyRepository _repo;
        public MyNewCrossModuleService(IMyRepository repo) => _repo = repo;
    
        public async Task<SomeResult> DoSomethingAsync(Guid tenantId, CancellationToken ct = default)
        {
            // Real implementation using module's own DB access
            return await _repo.GetSomethingAsync(tenantId, ct);
        }
    }
    // Override NoOp in the module's DependencyInjection.cs:
    services.AddScoped<IMyNewCrossModuleService, MyNewCrossModuleService>();
    // Note: AddScoped (not TryAddScoped) to OVERRIDE the NoOp
  4. 4. Add startup diagnostic in PostBuildInitialization.cs

    Add a check in PostBuildInitialization.cs to detect if the interface is still resolved as the NoOp. Log a LogCritical warning if so. This is the safety net that alerts developers to misconfigured deployments in production without crashing the server.
    C#
    // Add a check in PostBuildInitialization.cs:
    var myService = app.Services.GetService<IMyNewCrossModuleService>();
    if (myService is NoOpMyNewCrossModuleService)
    {
        startupLogger.LogCritical(
            "MY SERVICE SAFETY: IMyNewCrossModuleService is NoOp. " +
            "Load the {Module} module or register a custom implementation."
        );
    }

Module Collaboration — Architecture Rules Summary

These are the binding rules for all cross-module communication in SCRIPE. They are enforced by scripe arch-check (30-rule deep scan), project reference constraints in the .sln file, and code review. Violations of these rules create circular dependencies, deployment coupling, and testing impossibility.

  • ✅ Do This

    • Define all cross-module contracts in Core.Application.Abstractions
    • Register NoOp implementations in Core.Infrastructure using TryAddScoped
    • Override NoOps in real modules using AddScoped (not Try)
    • Add startup diagnostics for every new cross-module interface
    • Use domain events (via Outbox) for cross-module reactions
    • Keep Core.Application free of any module-specific references
    • Test with NoOp implementations in unit tests for isolation
    • Document every new interface in this catalog
  • ❌ Never Do This

    • Never import one module's Infrastructure from another module
    • Never reference Entitlements.Infrastructure from Identity (or vice versa)
    • Never use static calls or service locator pattern across modules
    • Never dispatch domain events synchronously across module boundaries
    • Never put cross-module logic in Core.Domain (domain must stay pure)
    • Never assume a module is loaded — always handle NoOp gracefully
    • Never skip startup diagnostics for critical cross-module services
    • Never hardcode module-specific types in Core layer

Security Boundary Enforcement

The module isolation rules are not just an architectural preference — they are security boundaries. Cross-module isolation ensures that a bug or compromise in one module cannot directly access another module's data store. These rules are enforced at multiple levels: project reference constraints, architecture lint rules, and code review checklists.

Boundary RuleWhyEnforced By
Identity NEVER references Entitlements directlyPrevents circular dependencyArchitecture lint rule (scripe arch-check)
Entitlements NEVER writes to Identity tables directlySingle source of truth per module's DBNo shared DbContext between modules
Tenant entity has NO EditionId fieldTenantSubscription owns the mappingDomain model — entity has no edition reference
Feature checks run as pipeline behavior, not in handlersConsistent enforcement, handlers stay pureAstraFlow pipeline registration
Core.Domain has zero module dependenciesCore must be deployable standaloneProject reference graph enforced by .sln