BotFramework.Rest 0.9.0-preview.1

This is a prerelease version of BotFramework.Rest.
dotnet add package BotFramework.Rest --version 0.9.0-preview.1
                    
NuGet\Install-Package BotFramework.Rest -Version 0.9.0-preview.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="BotFramework.Rest" Version="0.9.0-preview.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="BotFramework.Rest" Version="0.9.0-preview.1" />
                    
Directory.Packages.props
<PackageReference Include="BotFramework.Rest" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add BotFramework.Rest --version 0.9.0-preview.1
                    
#r "nuget: BotFramework.Rest, 0.9.0-preview.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package BotFramework.Rest@0.9.0-preview.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=BotFramework.Rest&version=0.9.0-preview.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=BotFramework.Rest&version=0.9.0-preview.1&prerelease
                    
Install as a Cake Tool

BotFramework

framework/ is the reusable runtime layer for CasinoShiz modules.

Release notes and the NuGet/GitHub publishing procedure live in docs/releases/0.9.0-preview.1.md and docs/framework-release.md.

The framework is split into transport-neutral contracts, backend/runtime infrastructure, and channel adapter infrastructure. Telegram and Discord are presentation/transport adapters; game contracts, decisions, effects and domain events do not depend on either channel or on the deployment shape. Game modules should depend on the smallest framework surface they need.

0.9-preview public product

The current public integration boundary is tenant-aware and intentionally breaking. Every inbound operation carries a TenantContext:

public sealed record TenantContext(
    TenantId TenantId,
    ScopeId ScopeId,
    PlayerId? PlayerId,
    BotChannel Channel,
    RequestId RequestId,
    RequestId CorrelationId);

TenantId, ScopeId, PlayerId and RequestId are opaque string value types. TenantKey and ScopeKey are internal PostgreSQL numbers and never appear in SDK DTOs, REST paths, gRPC metadata contracts or module signatures. Wallets are scope-local: the same player id in two scopes or tenants is always a different wallet boundary.

The scoped ITenantContextAccessor is the ambient boundary for infrastructure below a transport adapter. Host code pushes it once at ingress and restores it after the operation. Background jobs and outbox consumers restore it from persisted tenant/scope metadata before invoking module code.

Tenant resolution

Channel Tenant Scope
Telegram forum chat chat topic id
Telegram ordinary chat chat main
Telegram private chat private chat container main
Discord guild guild channel or thread
Discord DM private container main
REST trusted JWT tenant_id trusted JWT scope_id

REST uses the JWT sub claim as the opaque PlayerId. The route tenant and scope are checked against the token before a module handler is called. A channel binding is provisioned idempotently in the Host registry for the resolved platform container.

Public packages

The supported package-only surface is:

Package Purpose Target
BotFramework.Contracts Opaque ids, tenant context, transport contracts, errors, pagination and limiter contracts net8.0;net10.0
BotFramework.Sdk Pure module, domain, decision/effect, event and scheduling abstractions net8.0;net10.0
BotFramework.Testing Test doubles and in-memory SDK fixtures net8.0;net10.0
BotFramework.Scheduling.Abstractions Transport-neutral scheduled command contracts net8.0;net10.0
BotFramework.Rest Canonical REST middleware and route-module contract net10.0
BotFramework.Telegram.Abstractions Telegram update and tenant-resolution contracts net10.0
BotFramework.Discord.Abstractions Discord tenant-resolution contracts net10.0
BotFramework.Client Authenticated typed REST transport and generated OpenAPI client net8.0;net10.0

BotFramework.Host, BotFramework.Telegram, BotFramework.Discord and other runtime projects are composition/runtime adapters. They may use PostgreSQL, Redis, CAP, Discord.Net or Telegram.Bot and target net10.0. They are not dependencies of a pure domain or contracts project.

All preview packages use the 0.9.0-preview.N version line and carry this framework README, MIT license metadata, repository metadata, SourceLink, embedded symbols and deterministic package output. BotFramework.Templates is a separate dotnet new consumer artifact.

Canonical REST contract

REST modules map only under:

/api/v1/tenants/{tenantId}/scopes/{scopeId}/{module}/{operation}

The old scope-only /scopes/{scopeId} route is removed. State-changing requests require a printable Idempotency-Key. The framework creates or propagates X-Request-ID and X-Correlation-ID and returns them on the response. Errors are RFC 7807 application/problem+json documents with stable code, correlationId and optional retryAfterSeconds fields.

BotFramework.Client owns token acquisition, tenant/scope context, idempotency and correlation headers, and typed RFC 7807 failures. Its low-level client is generated by NSwag from the checked-in OpenAPI artifact:

dotnet build framework/BotFramework.Client \
  -p:BotFrameworkGenerateClient=true

List endpoints use CursorPageRequest and CursorPage<T>; cursors are opaque and must not be interpreted by clients.

gRPC context propagation

The service-defaults client interceptor propagates request metadata with these lowercase keys:

tenant_id
scope_id
player_id
request_id
correlation_id
channel

Numeric Telegram/Discord update and message ids stay inside their adapters. They are not public module identity or wire-contract fields. Removed protobuf field numbers remain reserved in the owning .proto files.

Distributed rate limiting

REST, Telegram, Discord and the Host command pipeline use the same IRateLimiter contract. Redis evaluates all applicable token buckets in one Lua call:

tenant
tenant + player
tenant + IP        (REST only)
tenant + route
tenant + player + route

Route keys are stable module/command identifiers, never raw URLs or user payloads. Deployment defaults are REST player 120/min, REST IP 300/min, Telegram burst 10 with refill 1/s, Discord 8/10s, and a tenant ceiling of 3000/min. PostgreSQL tenant overrides use this precedence:

tenant + route -> tenant + channel -> deployment route -> deployment channel -> global default

All applicable dimensions are charged. Redis outages switch to bounded local buckets with the same policy values; the decision carries IsFallback, the service reports degraded limiter telemetry and retries Redis with backoff. REST returns 429, Retry-After and rate-limit headers; channel adapters stop before game side effects.

Observability and operations

Framework instrumentation uses BotFramework.* meters and activity sources. Metric labels are limited to service, channel, module, route template, outcome, limiter dimension and fallback state. Tenant, scope, player, request and correlation identifiers belong only in structured logs and traces.

The standard service defaults expose request/error counters and latency, atomic execution, lock wait and transaction duration, outbox lag/depth, limiter decisions/fallback, policy-cache hits/misses and tenant provisioning. Compose includes an OTLP Collector, Tempo, Prometheus, Grafana trace links and Alertmanager's configurable generic webhook. Helm supports an external OTLP endpoint and optional Prometheus Operator ServiceMonitor, PrometheusRule and AlertmanagerConfig resources. The alert rules cover unavailable service/DB/Redis, prolonged local fallback, HTTP/game failures, request/game p95 latency, outbox lag, queue backlog, CPU and memory pressure.

Package consumer and template workflow

The repository validates the framework as an external consumer, not only via project references. eng/package-consumer-smoke.sh packs public packages into .artifacts/local-feed with package validation enabled, restores the external samples/CoinFlip module from that feed and runs its tests. It also installs the template package in an isolated dotnet new hive, generates all channels, builds the atomic scaffold and runs its isolated tests.

Install the scaffold from a feed or NuGet preview source:

dotnet new install BotFramework.Templates::0.9.0-preview.1
dotnet new botframework-game -n CoinFlip --module-id coin-flip

The default scaffold contains pure Domain, Application, Contracts, Infrastructure, REST, Telegram, Discord and isolated tests. Use --channels rest|telegram|discord|all, --persistence atomic|event-sourced|none and --include-tests true|false to select the generated surface.

Compatibility and migration boundary

The repository still contains demonstration games using previous numeric contracts. They are a staging compatibility layer only; they are not the SDK 0.9 module shape and are intentionally excluded from the package-only consumer contract. New modules use opaque ids, TenantContext, tenant wallet effects and tenant-aware state/event/schedule persistence. Game migration is a separate vertical-slice task and does not change the framework package contract above.

The assembly map below includes the existing runtime and demonstration-game surface for repository maintainers. It is not the supported package-only SDK surface described above; legacy numeric game contracts are staging material until the games migration is completed.

Runtime assembly map

Role

Referenced by

BotFramework.Contracts

Transport-neutral service contracts, DTOs, wallet/identity/read ports and portable integration contracts

hosts, services, transport adapters, game contracts

BotFramework.Sdk

Module-facing abstractions: modules, domain/events, repositories, projections, analytics, localization, runtime contracts

backend Games.* modules

BotFramework.Sdk.Testing

xUnit helpers and in-memory test doubles

tests/CasinoShiz.Tests

BotFramework.Host

Backend/runtime infrastructure: composition, persistence, CAP/event bus, economics/wallet adapters, analytics, runtime jobs, admin/security, health

backend/monolith composition roots

BotFramework.Telegram.Abstractions

Telegram update context, routes and handler contracts

Telegram adapters and runtime

BotFramework.Scheduling.Abstractions

Transport-neutral scheduled command contracts

game application layers

BotFramework.Scheduling.Quartz

Persistent Quartz implementation of game scheduling

backend composition roots

BotFramework.Rendering

Bounded TPL render queues, content-addressed artifact cache, MinIO media history and admin read endpoints

backend, Telegram adapters and composition roots

BotFramework.Telegram

Telegram adapter runtime: bot client, polling/webhook ingress, update pipeline, router, route attributes, Telegram update context and delivery helpers

Telegram BFF, monolith compatibility host, Games.*.Telegram adapters

BotFramework.Discord

Discord adapter runtime: gateway ingress, message and interaction routing, slash commands, autocomplete, modals, component tokens, embeds, localization, UX rate limiting/cooldowns, health checks and delivery outbox

Discord BFF and Games.*.Discord adapters

The intended dependency direction is:

Games.* backend modules
  -> BotFramework.Sdk
  -> BotFramework.Contracts

Games.*.Telegram adapters
  -> BotFramework.Telegram
  -> BotFramework.Contracts / game contracts

Games.*.Discord adapters
  -> BotFramework.Discord
  -> BotFramework.Contracts / game contracts

Composition roots
  -> BotFramework.Host
  -> BotFramework.Telegram and/or BotFramework.Discord when channel ingress is enabled

Modules should not reference deployment-specific hosts. Hosts select which modules and transports are active.

Repository boundaries

framework/
  BotFramework.Contracts/   transport-neutral contracts and portable DTOs
  BotFramework.Sdk/         module, domain, persistence and event abstractions
  BotFramework.Sdk.Testing/ test helpers and in-memory doubles
  BotFramework.Host/        backend infrastructure and composition
  BotFramework.Telegram.Abstractions/ Telegram update contracts
  BotFramework.Scheduling.Abstractions/ scheduled command contracts
  BotFramework.Scheduling.Quartz/ persistent Quartz scheduler
  BotFramework.Rendering/ bounded rendering runtime and artifact/history ports
  BotFramework.Telegram/    Telegram ingress, update routing and delivery
  BotFramework.Discord/     Discord ingress, interactions, UX and delivery

games/
  Games.X.Contracts/        logical interfaces and portable DTOs
  Games.X/                  backend application/domain/infrastructure
  Games.X.Telegram/         Telegram presentation adapter
  Games.X.Discord/          Discord presentation adapter
  Games.X.Transport.Grpc/   protobuf and remote adapters when needed

host/
  CasinoShiz.Host/          combined compatibility deployment
  CasinoShiz.Backend/       Telegram-free backend process
  CasinoShiz.TelegramBff/   Telegram client process
  CasinoShiz.DiscordBff/    Discord client process
  CasinoShiz.AdminBff/      browser/admin BFF without direct database access

services/
  CasinoShiz.IdentityService/
  CasinoShiz.WalletService/

tests/
  CasinoShiz.Tests/

Not every context needs every optional project. Simple modules may only have Games.X and Games.X.Telegram. Split-service modules may additionally provide Games.X.Contracts and Games.X.Transport.Grpc.

Framework source tree

The following map describes the checked-in source layout of framework/. Build output (bin/, obj/) is intentionally omitted. The project name and namespace do not always have the same suffix: for example, the source project BotFramework.Sdk.Testing produces the package BotFramework.Testing.

framework/
├── BotFramework.Contracts/             # public, transport-neutral contracts
│   ├── Caching/                         # cache ports and cache metadata
│   ├── Economics/                       # wallet and economic service contracts
│   ├── Games/                           # portable game-facing contracts
│   ├── Identity/                        # player and identity contracts
│   ├── Messaging/                       # channel and request metadata
│   ├── Observability/                   # meters and telemetry contracts
│   ├── Operations/                      # operational/service contracts
│   ├── RateLimiting/                    # limiter decisions and policies
│   ├── ResponsibleGaming/               # protection and player-stat contracts
│   ├── Tenancy/                         # opaque ids and tenant context
│   └── Transport/                       # pagination and wire-level contracts
├── BotFramework.Sdk/                    # pure module and game abstractions
│   ├── Admin/                           # admin commands and effects
│   ├── Commands/                        # command and request contracts
│   ├── Configuration/                   # neutral options/validation contracts
│   ├── Domain/                          # aggregates, rules and state primitives
│   ├── Events/                          # domain events and event contracts
│   ├── Execution/                       # actions, decisions and effects
│   ├── Health/                          # health/readiness abstractions
│   ├── Metrics/                         # SDK-level telemetry abstractions
│   ├── MiniGames/                       # mini-game contracts
│   ├── Modules/                         # module registration contracts
│   ├── Projections/                     # projection/read-model contracts
│   └── Snapshots/                       # snapshot contracts
├── BotFramework.Sdk.Testing/            # test doubles for SDK consumers
│   ├── Fakes/
│   └── Repositories/
├── BotFramework.Rest/                   # REST middleware and route support
│   └── RateLimiting/                    # REST limiter options and adapter
├── BotFramework.Client/                 # typed REST client package
│   ├── Generated/                       # NSwag-generated client; do not edit
│   ├── openapi-v1.json                  # checked-in source OpenAPI contract
│   └── openapi-client.nswag.json        # NSwag generation configuration
├── BotFramework.Telegram.Abstractions/  # transport-facing Telegram contracts
│   ├── MiniGames/
│   ├── Pipeline/
│   ├── Tenancy/
│   └── UpdateHandling/
├── BotFramework.Discord.Abstractions/  # transport-facing Discord contracts
├── BotFramework.Host/                   # backend runtime and infrastructure
│   ├── Admin/                            # admin effects and execution
│   ├── Analytics/                        # analytics/query integrations
│   ├── Caching/                          # cache implementations
│   ├── Commands/                         # command middleware and dispatch
│   ├── Composition/                      # Host builders and migrations
│   ├── Configuration/                    # runtime configuration
│   ├── Contracts/                        # internal Host contracts
│   ├── DiscordOutbox/                    # Discord delivery outbox
│   ├── Economics/                        # wallet/economy implementations
│   ├── Events/                           # event bus and outbox dispatch
│   ├── Execution/                        # atomic game execution pipeline
│   ├── Fairness/                         # fairness and entropy services
│   ├── Games/                            # game runtime composition
│   ├── Health/                           # database/dependency health checks
│   ├── Localization/                     # backend localization services
│   ├── Messaging/                        # request/transport support
│   ├── Persistence/                      # PostgreSQL stores and migrations
│   ├── Random/                           # framework entropy providers
│   ├── RateLimiting/                     # Redis/DB limiter implementations
│   ├── Redis/                            # Redis infrastructure
│   ├── Runtime/                          # hosted services and lifecycle
│   ├── Security/                         # auth and authorization
│   ├── TelegramOutbox/                   # Telegram delivery outbox
│   ├── Tenancy/                          # tenant provisioning and resolution
│   └── Workflows/                         # durable command workflows, steps and replay
├── BotFramework.Telegram/                # Telegram adapter runtime
│   ├── Composition/                      # Telegram builder extensions
│   ├── Hosting/                          # hosted polling/webhook services
│   ├── Outbox/                            # Telegram delivery workers
│   ├── Pipeline/                          # update middleware
│   └── Redis/                             # Telegram adapter state
├── BotFramework.Discord/                 # Discord adapter runtime
│   ├── Commands/                          # slash/message command handling
│   ├── Composition/                      # Discord builder extensions
│   ├── Hosting/                           # gateway and hosted services
│   ├── Interactions/                      # buttons, selects, modals and tokens
│   └── Routing/                           # message and interaction routing
├── BotFramework.Scheduling.Abstractions/ # transport-neutral schedule contracts
├── BotFramework.Scheduling.Quartz/       # persistent Quartz implementation
└── BotFramework.Rendering/               # bounded rendering and media history

Where new code belongs

Need Project/location Boundary rule
Public DTO, opaque id, tenant or pagination contract BotFramework.Contracts No database, transport SDK or Host dependency
Pure game action, domain rule, effect or module contract BotFramework.Sdk Synchronous decision code must remain deterministic and I/O-free
Consumer test fake or in-memory repository BotFramework.Sdk.Testing Keep test-only helpers out of runtime packages
REST middleware, route metadata or typed HTTP client BotFramework.Rest / BotFramework.Client REST details must not leak into game/domain contracts
Telegram/Discord-specific contract BotFramework.*.Abstractions Keep channel types out of the neutral SDK
Ingress, routing, presentation or delivery BotFramework.Telegram / BotFramework.Discord Adapters call logical module contracts; they do not own persistence
PostgreSQL, Redis, outbox, migrations or atomic execution BotFramework.Host Infrastructure is selected by a composition root
Long-running command workflow, durable retry or operator replay BotFramework.Host.Workflows Modules provide commands/handlers; Host owns Wolverine and workflow persistence
Durable scheduling BotFramework.Scheduling.* Quartz is an implementation, not a module-facing contract
GIF/PNG rendering and artifact history BotFramework.Rendering Rendering runs after commit and never participates in the game transaction

Keep public package dependencies pointed inward:

BotFramework.Contracts
        ↑
BotFramework.Sdk ────────┐
        ↑                │
Abstractions ────────────┤
        ↑                │
Host ───┴── Telegram / Discord / Rest / Client adapters

BotFramework.Client/Generated/ is regenerated from openapi-v1.json; change the OpenAPI document or NSwag settings first and run the documented generation command. Do not hand-edit generated files. New framework projects should also be added to CasinoShiz.slnx, package smoke validation, public API manifests and this map when they become part of the supported surface.

Layering

┌────────────────────────────────────────────────────────────────────────────┐
│ L5  Presentation adapters                                                  │
│     games/*/*.Telegram, BotFramework.Telegram                              │
│     games/*/*.Discord, BotFramework.Discord                                │
│     channel parsing, embeds, modals, callbacks and interactions             │
├────────────────────────────────────────────────────────────────────────────┤
│ L4  Application                                                            │
│     games/*/Application                                                    │
│     services, commands, jobs, projections, use-case result records         │
├────────────────────────────────────────────────────────────────────────────┤
│ L3  Domain                                                                 │
│     games/*/Domain                                                         │
│     pure domain transitions, aggregates, policies, state machines          │
├────────────────────────────────────────────────────────────────────────────┤
│ L2  Platform contracts                                                     │
│     BotFramework.Contracts + BotFramework.Sdk                              │
│     modules, repositories, event store, projections, event bus, ports      │
├────────────────────────────────────────────────────────────────────────────┤
│ L1  Infrastructure                                                         │
│     games/*/Infrastructure + BotFramework.Host + transport projects        │
│     Postgres, Redis/CAP, ClickHouse, gRPC, migrations, jobs, admin, ops    │
└────────────────────────────────────────────────────────────────────────────┘

BotFramework.Telegram and BotFramework.Discord are intentionally not part of the backend SDK. Channels are adapter boundaries, not domain dependencies.

Physical layout

Game backend modules use a consistent directory shape:

games/Games.X/
  Application/
    Services/ Jobs/ Projections/ Results/ Analytics/
  Domain/
    Configuration/ Commands/ Entities/ Events/ Rules/ Results/
  Infrastructure/
    Persistence/ Migrations/ Modules/ Rendering/ Integrations/ Queues/

Telegram adapters live separately:

games/Games.X.Telegram/
  Handlers/
  Rendering/
  CallbackData/
  Modules/

Transport adapters live separately when the module can run across a process boundary:

games/Games.X.Transport.Grpc/
  Protos/
  Clients/
  Servers/
  Mapping/

BotFramework.Host is feature-first:

Admin/ Analytics/ Commands/ Composition/ Contracts/ Economics/ Events/
Health/ Localization/ Persistence/ Random/ Redis/ Runtime/ Security/

BotFramework.Telegram owns Telegram-specific runtime concerns:

Composition/ Ingress/ Pipeline/ Routing/ UpdateHandling/ Delivery/ Redis/

BotFramework.Sdk is also feature-first and keeps module-facing namespaces stable:

Admin/ Commands/ Configuration/ Domain/ Events/ Health/ Metrics/
MiniGames/ Modules/ Pipeline/ Projections/ Snapshots/

Atomic game execution and effects

For the system-level container, transaction and concurrency diagrams, see docs/arch.md.

Games that mutate wallets or persistent state should implement a pure IGameAction<TCommand, TState, TResult>. The action receives materialized state, wallet/quota snapshots, framework-provided entropy and UTC time. Decide performs no I/O and returns one complete GameDecision.

transport request
  -> command envelope / idempotency / sorted advisory locks
  -> load snapshots
  -> game.Decide(input)
  -> materialize and validate GameEffectPlan
  -> apply effects in deterministic transaction phases
  -> inbox result + commit
  -> asynchronous outbox delivery

Every declarative consequence implements IGameEffect. Built-in effect categories are deliberately explicit:

  • EconomyEffect debits or credits the command wallet;
  • WalletEconomyEffect targets an explicitly declared wallet in a multi-wallet command;
  • QuotaEffect consumes, restores, or grants capacity on a declared quota;
  • IGameRecord writes module-specific history through a registered writer;
  • IDomainEvent is persisted to the transactional event outbox;
  • ScheduleEffect schedules or cancels a durable command through the schedule outbox.

Admin mutations use the smaller companion kernel. An admin action produces an AdminEffectPlan<TResult> containing typed IAdminEffect values. The Host opens one transaction, resolves exactly one handler for every effect type, applies the finite materialized list in order, appends admin_audit, and commits. Handlers get the restricted IAdminExecutionContext, not NpgsqlConnection or a transaction. Runtime configuration is the first migrated effect (RuntimeConfigurationPatchEffect), so the JSONB update and its audit record cannot commit separately.

The same kernel now covers the administrative write paths for Users, Ledger, Games.Admin, and the Meta season/quest/alert pages:

Area Typed effects Transactional consequences
Users WalletAdjustmentAdminEffect wallet row, version, ledger line and admin_audit
Ledger LedgerRevertAdminEffect locked source line, compensating wallet/ledger update and audit
Games.Admin ClearChatBetsAdminEffect, DisplayNameOverrideAdminEffect pending-bet deletion/refund or override upsert/delete and audit
Meta seasons MetaSeason*AdminEffect season status/config/plans, reward credits, meta_event_log and audit
Meta alerts MetaAlertStatusAdminEffect risk flag state, meta_event_log and audit
Meta quests MetaQuestCatalogSaveAdminEffect, MetaQuestCatalogReloadAdminEffect validated catalog replacement/reload and audit

Pages keep their existing read models and response contracts. Only the mutation boundary goes through the executor, so the monolith and split BFF paths share the same effect handlers. Reward effects use deterministic operation ids; retrying a season payout therefore cannot create a second ledger entry. The clear-bets effect refunds and removes all supported mini-game rows in the same PostgreSQL transaction, then clears process/distributed session state after commit.

The quest catalog is currently file-backed for compatibility. Its effect writes a temporary file and atomically replaces the catalog before recording the audit row; the filesystem replacement cannot be rolled back by PostgreSQL. A future DB/object store catalog can use the same effect contract for full database atomicity.

Workflow effects and scheduled work

Not every mutation starts as a pure game decision. Quest progress/claims, tournament lifecycle commands and season settlement use IAtomicEffectExecutor with the same finite, typed effect discipline. A module registers one AtomicEffectHandler<T> per effect; the Host supplies only a restricted SQL context, takes the declared locks, checks the inbox, applies all effects, stores the result and commits once. These handlers do not receive NpgsqlConnection, a transaction, or a service locator. Deterministic operation ids make wallet rewards safe to retry.

Quartz remains the trigger and durable schedule store, not the business transaction boundary. ScheduleExecutionPolicy makes background semantics explicit:

  • Misfire: FireOnce, Ignore, or DoNothing;
  • Concurrency: Disallow (the default) or Allow;
  • BatchSize: a bounded amount of work passed as batch-size to the command;
  • MaxAttempts and RetryBackoff: bounded command-level retries.

The scheduler maps these values to Quartz trigger/job settings. The scheduled command itself still enters the atomic/effect executor, so a retry or a process restart cannot partially settle a season. Heavy rendering and analytics remain post-commit consumers; they must not be hidden inside a scheduled effect handler.

Typed configuration validation

Modules register tunable options and their validator explicitly:

services.BindOptions<HorseOptions, HorseOptionsValidator>(HorseOptions.SectionName);

public sealed class HorseOptionsValidator
    : FluentConfigurationValidator<HorseOptions>
{
    public HorseOptionsValidator()
    {
        RuleFor(x => x.HorseCount).InclusiveBetween(2, 16);
        RuleFor(x => x.TimezoneOffsetHours).InclusiveBetween(-14, 14);
    }
}

IConfigurationValidator<TOptions> lives in BotFramework.Sdk and depends only on neutral validation result types. FluentConfigurationValidator<TOptions> is a Host adapter; modules may use it without coupling their domain contracts to FluentValidation. The same validator runs at startup (ValidateOnStart) and for admin preview/apply. Runtime JSON is deserialized strictly: unknown sections, unknown nested properties, invalid shapes, and semantic rule failures are rejected with structured path, code, and message issues. A valid patch is normalized, previewed as effective typed options, and only then handed to the admin effect executor.

Contract at a glance

The SDK portion of the system has no database, DI, transport, outbox, or async dependency. A game action is a synchronous function from an input snapshot to a decision:

public interface IGameAction<TCommand, TState, TResult>
{
    GameDecision<TState, TResult> Decide(
        GameActionInput<TState, TCommand> input);
}

GameActionInput contains the command, loaded state, primary-wallet snapshot, declared quota snapshots, named entropy and the framework UTC timestamp. The action must not read the clock, generate randomness, resolve services, query a database, publish messages, or start background work.

public sealed class CoinFlipAction
    : IGameAction<CoinFlipCommand, NoGameState, CoinFlipResult>
{
    public GameDecision<NoGameState, CoinFlipResult> Decide(
        GameActionInput<NoGameState, CoinFlipCommand> input)
    {
        if (input.Wallet.Balance < input.Command.Stake)
            return new(
                DecisionStatus.Rejected,
                input.State,
                CoinFlipResult.InsufficientBalance,
                [], [], [], [], [],
                RejectionReason: "insufficient_balance");

        var won = input.Entropy.GetDouble("coin-flip") >= 0.5;
        var economy = won
            ? new[] { EconomyEffect.Debit(input.Command.Stake, "coinflip.stake"),
                      EconomyEffect.Credit(input.Command.Stake * 2, "coinflip.win") }
            : new[] { EconomyEffect.Debit(input.Command.Stake, "coinflip.stake") };

        return new(
            DecisionStatus.Accepted,
            input.State,
            new CoinFlipResult(won),
            economy,
            [],
            [],
            [new CoinFlipCompleted(input.Command.UserId, won, input.UtcNow)],
            []);
    }
}

The descriptor supplies infrastructure metadata without putting it in the domain command: game id, command id, aggregate id, wallet identity, quota identities, entropy names, and additional lock keys. Lock keys are acquired in stable sorted order.

public sealed class CoinFlipDescriptor
    : GameExecutionDescriptor<CoinFlipCommand, NoGameState, CoinFlipResult>
{
    public override string GameId => "coinflip";
    public override string CommandId(CoinFlipCommand command) => command.CommandId;
    public override string AggregateId(CoinFlipCommand command) =>
        $"{command.ChatId}:{command.UserId}";
    public override long ChatId(CoinFlipCommand command) => command.ChatId;
    public override string DisplayName(CoinFlipCommand command) => command.DisplayName;
    public override WalletIdentity Wallet(CoinFlipCommand command) =>
        new(command.UserId, command.ChatId);
    public override IReadOnlyList<string> EntropyNames => ["coin-flip"];
}

Register the action, descriptor and state store explicitly in the backend module. This keeps discovery deterministic and avoids a reflection DSL:

services
    .AddScoped<IGameAction<CoinFlipCommand, NoGameState, CoinFlipResult>,
        CoinFlipAction>()
    .AddScoped<GameExecutionDescriptor<CoinFlipCommand, NoGameState, CoinFlipResult>,
        CoinFlipDescriptor>()
    .AddScoped<IGameStateStore<CoinFlipCommand, NoGameState>,
        CoinFlipStateStore>();

The compatibility application service depends on IAtomicGameExecutor<CoinFlipCommand,NoGameState,CoinFlipResult> and delegates the complete mutation path to it. Transport handlers continue to depend on the existing logical game contract.

Effect categories and guarantees

Effect Target Transactional behavior
EconomyEffect Descriptor primary wallet Balance check, wallet update and ledger row; protected wager debits pass player-protection checks
WalletEconomyEffect Explicit (userId, balanceScopeId) Multi-wallet balance update and ledger row; every wallet lock must be declared by the descriptor
QuotaEffect.Consume Declared quota Increases used capacity and rejects overflow
QuotaEffect.Restore Declared quota Returns consumed capacity, clamped at zero
QuotaEffect.Grant Declared quota Adds future capacity and may move usage below zero
IGameRecord Module history table Written by exactly one registered typed record writer
custom IGameEffect Module-owned state Applied by exactly one typed handler through IGameExecutionContext
IDomainEvent Game event outbox Written once per command/event index and delivered after commit
ScheduleEffect Schedule outbox Durable schedule/cancel request delivered after commit

WalletEconomyEffect is intentionally separate from primary-wallet EconomyEffect. It supports transfers, challenge stakes, race payouts, and other multi-wallet decisions. It enforces non-negative balances and ledger consistency, but primary-wallet player-protection checks are not implicitly reapplied to arbitrary target wallets. A module introducing protected multi-wallet wager debits must model and test that policy explicitly.

GameEffectSet materializes these categories before the first mutation. GameEffectPlan rejects null effects, mutations on rejected decisions, unknown quotas, missing or duplicate handlers, and built-in effects placed in the custom category. Effects are not represented by IAsyncEnumerable: laziness would make the transaction boundary and retry semantics depend on enumeration progress.

The Host applies effects in a fixed order inside one PostgreSQL transaction:

player protection
  -> economy
  -> quotas
  -> aggregate state
  -> module records
  -> custom typed effects
  -> event outbox
  -> schedule outbox
  -> command inbox result
  -> commit

Economy ledger rows, events and schedules use batch SQL. Quota and custom effects are grouped once while building the plan. PostgreSQL commands on one connection are still awaited sequentially; parallel commands on the same transaction are not supported by Npgsql.

Modules can define additional typed effects without receiving the connection or transaction directly:

public sealed record AchievementEffect(
    long UserId,
    string AchievementId) : IGameEffect;

public sealed class AchievementEffectHandler
    : GameEffectHandler<AchievementEffect>
{
    protected override Task ApplyBatchAsync(
        IReadOnlyList<AchievementEffect> effects,
        IGameExecutionContext context,
        CancellationToken ct) =>
        context.ExecuteAsync(
            """
            INSERT INTO achievements (user_id, achievement_id)
            SELECT item.user_id, item.achievement_id
            FROM unnest(@UserIds, @AchievementIds)
                AS item(user_id, achievement_id)
            ON CONFLICT DO NOTHING
            """,
            new
            {
                UserIds = effects.Select(effect => effect.UserId).ToArray(),
                AchievementIds = effects.Select(effect => effect.AchievementId).ToArray(),
            },
            ct);
}

Register the handler explicitly:

services.AddAtomicGameEffectHandler<AchievementEffectHandler>();

Then include materialized instances in GameDecision.CustomEffects. Handlers of the same effect type receive one batch. Their Order values and effect type names provide stable ordering. A handler failure rolls back every earlier mutation, including wallet, quota, state, records and outbox rows.

External work that must not affect the game commit belongs behind a committed domain event/outbox consumer. Analytics, Telegram delivery and other remote calls must not be implemented as transactional custom effect handlers.

Durable workflow boundary

The framework exposes BotFramework.Host.Workflows as a reusable long-running workflow primitive. A module implements immutable commands with IDurableWorkflowCommand, registers its handler assembly, and calls:

builder.AddDurableWorkflows(typeof(MyWorkflowHandler).Assembly);

The framework owns PostgreSQL-backed durable command delivery, correlation, partitioning, retries, step persistence, replay and generic saga state. The module uses IDurableWorkflowDispatcher at its transport/application boundary and IDurableWorkflowStepExecutor inside a command handler. Domain mutation remains in the module's own AtomicEffect/transaction boundary.

durable_workflow_steps stores workflow/command/causation ids, command type, original command_json, payload, result, status and terminal marker. It is an operator-visible timeline and recovery projection, not the source of domain truth. IDurableWorkflowReplayService requeues a failed or non-terminal command while keeping its original command id, so application inboxes and external operation-id idempotency can protect against duplicate mutations.

The smallest consumer example is samples/CoinFlip/CoinFlip.Workflow/CoinFlipWorkflow.cs. It shows the command, handler and transport/application dispatcher boundary without importing any game-specific workflow code into the framework.

Meta's tournament workflow is the first consumer. Its Wallet mutations are workflow steps, outside the local Backend transaction: join entry fees, final prizes and cancellation refunds use stable Wallet operation ids. Definitive local rejection triggers a compensating .rollback mutation. A process/network failure is retried durably with the same command and operation ids; the client may receive Pending while that retry continues. The existing Telegram and Discord client outboxes remain the delivery mechanism for notifications, and the CAP domain-event outbox remains unchanged.

State stores and concurrency profiles

IGameStateStore<TCommand,TState> loads and saves module state through the limited IGameExecutionContext. It never owns the transaction. The framework also provides a JSON aggregate store for ordinary versioned state; modules may provide transaction-aware stores for existing normalized tables.

Choose the smallest aggregate key that protects a real invariant:

  • user game: game:{chatId}:{userId};
  • turn-based table: table:{inviteCode};
  • multi-wallet operation: one aggregate plus every affected wallet lock;
  • realtime board: tile:{index}, so different cells remain parallel;
  • batch settlement: race/table id plus the complete declared payout-wallet set.

Commands with the same aggregate key execute sequentially. Commands with different aggregate keys can run concurrently unless they share another declared lock such as a wallet or quota. Do not use a global game lock merely because the game has a global read model.

Turn-based games can use the SDK types in Execution/TurnBased for common revision, actor, turn and terminal-state validation. They still emit a normal materialized GameDecision; there is no task tree or hidden saga.

Idempotency and retry behavior

The transport supplies a stable command id. Before loading state, the executor locks the command and aggregate and checks game_command_idempotency:

  • a new command stores entropy, applies the decision and persists the result;
  • a duplicate returns the previously serialized result without reapplying effects;
  • a failure or cancellation before commit rolls back state, ledger, records, inbox completion and outbox rows together;
  • a response lost after commit is recovered by retrying the same command id.

Transport payloads do not need to expose the id publicly. Telegram message or callback identity, gRPC metadata, and HTTP idempotency headers can populate the framework envelope. Generating a fresh id inside a retrying facade does not provide lost-response recovery.

When adding or migrating a mutation:

  1. Put rules and calculations in a synchronous IGameAction.
  2. Pass time and named entropy through GameActionInput.
  3. Return every database consequence as state or a materialized effect list.
  4. Declare every quota and every additional wallet/aggregate lock up front.
  5. Keep the state store and custom handlers transaction-aware and transport-free.
  6. Publish analytics and remote notifications from committed events.
  7. Test deterministic decisions, rejection without effects, rollback, duplicate command ids, concurrent same/different aggregates, and lost-response replay.
  8. Keep read-only queries outside the executor; the effect system is for commands.

Rendering, batching and media history

Heavy GIF/image generation is runtime work, not an atomic game effect. A game first commits its decision, then submits an immutable render specification to IRenderQueue. BotFramework.Rendering describes the specification with a content-addressed RenderKey, checks the shared artifact store, deduplicates identical in-flight work, and runs a registered IRenderJob<TSpec> in a bounded TPL Dataflow worker.

committed game result
  -> immutable render spec
  -> content key (renderer + version + input hash)
  -> memory/in-flight dedup
  -> MinIO cache hit -------------------------------> artifact
  -> bounded TPL queue -> CPU render -> MinIO put --> artifact
  -> optional RenderHistoryEntry manifest

There are separate interactive and background/prewarm queues. They share one global MaxParallelism semaphore, so together they cannot exceed the configured CPU budget. Queue capacity provides backpressure; PrewarmAsync may accept a whole matrix, but it cannot create unbounded active renders. This is intentionally not IAsyncEnumerable: one render is one finite, awaitable artifact.

A renderer is explicit and versioned:

public sealed class BoardRenderJob : IRenderJob<BoardRenderSpec>
{
    public RenderKey Describe(BoardRenderSpec spec) =>
        new("board", "3", HashCanonical(spec), "png", "image/png");

    public ValueTask<RenderOutput> RenderAsync(BoardRenderSpec spec, CancellationToken ct) =>
        ValueTask.FromResult(RenderOutput.FromBytes(RenderBoard(spec), "board.png"));
}

services.AddRenderJob<BoardRenderSpec, BoardRenderJob>();

RendererVersion must change whenever pixels, encoding, fonts, localization or canonicalization change. Specifications must contain every input that affects the bytes and must not read mutable game state during rendering.

Horse registers deterministic winner/variant GIF specifications. Quartz runs horse.render-prewarm at 03:15 UTC and feeds the complete winner × variant matrix through the bounded prewarm queue; a cache miss is still rendered on demand. Poker board PNGs use the same queue and content deduplication.

When MinIO is enabled, artifacts live under artifacts/{renderer}/{version}/{hash}.{extension}. Match history stores small JSON manifests under history/{game}/{aggregate}/...; manifests point to the same immutable artifact instead of copying GIF/PNG bytes. Authenticated admin inspection is available through:

  • GET /admin/render-history/{gameId}/{aggregateId}?take=50;
  • GET /admin/render-artifact/{rendererId}/{version}/{hash}.{extension}.

Configuration is under Rendering:

{
  "Rendering": {
    "QueueCapacity": 64,
    "MaxParallelism": 0,
    "MaxArtifactBytes": 33554432,
    "Minio": {
      "Enabled": true,
      "Endpoint": "minio:9000",
      "AccessKey": "minioadmin",
      "SecretKey": "change-me",
      "Bucket": "casinoshiz-media",
      "Secure": false
    }
  }
}

MaxParallelism = 0 selects half of the available logical processors (at least one). MinIO is a cache/history dependency, not a transaction participant. Read or write failure falls back to a transient artifact for the current response; history writes are retried and logged without changing an already committed game result. Production credentials must come from secrets, not committed settings.

Host composition

The combined compatibility host can compose backend and channel adapters in one process. The example below enables Telegram; Discord can be enabled by adding the Discord composition and the corresponding channel modules.

var builder = WebApplication.CreateBuilder(args);

builder.AddBotFramework()
    .AddModule<DiceModule>()
    .AddModule<PokerModule>()
    .AddModule<SecretHitlerModule>();

builder.AddTelegramFramework()
    .AddTelegramModule<DiceTelegramModule>()
    .AddTelegramModule<PokerTelegramModule>()
    .AddTelegramModule<SecretHitlerTelegramModule>();

var app = builder.Build();

app.UseBotFramework();
app.UseTelegramFramework();

app.Run();

A split deployment composes the backend and each channel BFF separately. The backend-facing game contracts stay the same in both cases.

Backend process:

var builder = WebApplication.CreateBuilder(args);

builder.AddBackendFramework()
    .AddModule<DiceModule>()
    .AddModule<PokerModule>()
    .AddModule<SecretHitlerModule>();

var app = builder.Build();

app.UseBackendFramework();

app.Run();

Discord BFF process:

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();
builder.AddDiscordBackend();

// Each client uses Backend:GameAddresses:<GameId> when it is set;
// Backend:GrpcAddress remains the monolith/single-backend fallback.
builder.Services.AddDiceGrpcClient(/* resolved Dice address */);
builder.Services.AddPokerGrpcClient(/* resolved Poker address */);
builder.Services.AddDiceDiscord();
builder.Services.AddPokerDiscord();

var app = builder.Build();
app.UseDiscordBackend();
app.Run();

A channel BFF owns ingress, presentation and remote client wiring. It must not open a game or service database directly.

Telegram BFF process:

var builder = WebApplication.CreateBuilder(args);

builder.AddTelegramBff()
    .AddTelegramModule<DiceTelegramModule>()
    .AddTelegramModule<PokerTelegramModule>()
    .AddTelegramModule<SecretHitlerTelegramModule>();

var app = builder.Build();

app.UseTelegramFramework();

app.Run();

The composition root decides whether a contract is implemented locally or through a transport adapter such as gRPC. Application-facing interfaces do not change when a module crosses the process boundary.

Module registration

Backend modules implement the framework module contract and register their services:

public sealed class MyGameModule : IModule
{
    public void ConfigureServices(IModuleServiceCollection services)
    {
        services.AddApplicationService<MyGameService>();
        services.AddProjection<MyProjection>();
        services.RegisterAggregate<MyAggregate>(PersistenceStrategy.EventSourced);
        services.AddMigrations<MyGameMigrations>();
    }
}

Telegram modules register handlers and presentation services:

public sealed class MyGameTelegramModule : ITelegramModule
{
    public void ConfigureServices(ITelegramModuleServiceCollection services)
    {
        services.AddHandler<MyGameCommandHandler>();
        services.AddRenderer<MyGameRenderer>();
    }
}

Discord modules follow the same rule with channel-specific handlers:

public static class MyGameDiscordModule
{
    public static IServiceCollection AddMyGameDiscord(
        this IServiceCollection services) => services
        .AddScoped<IDiscordMessageHandler, MyGameDiscordHandler>()
        .AddScoped<IDiscordInteractionHandler, MyGameDiscordInteractionHandler>();
}

IDiscordMessageHandler and IDiscordInteractionHandler call logical game contracts. They do not move game state or persistence into the channel adapter.

A new module should be able to start with a small set of contracts and grow only when it needs persistence, projections, transport adapters or Telegram presentation.

Telegram update pipeline and routing

Every Telegram update flows through the Telegram update pipeline and then the attribute router:

Exception → Deduplication → Logging → RateLimit → KnownChats → UpdateRouter

Routing is attribute-driven. A Telegram adapter adds a handler by implementing IUpdateHandler, decorating it with route attributes and registering it in the Telegram module:

services.AddHandler<MyHandler>();

Supported route attributes include:

[Command]
[CallbackPrefix]
[CallbackFallback]
[MessageDice]
[ChannelPost]

Only the Telegram layer knows Telegram update types, bot clients and rendering details. Backend services receive application commands through logical contracts.

Discord interaction pipeline and UX

Discord has a separate adapter runtime, but the same application boundary:

Discord Gateway
  -> DiscordHostedService
  -> message/interaction router
  -> UX rate limit and cooldown
  -> channel handler
  -> logical game contract / gRPC client

The interaction router supports slash commands, autocomplete, buttons/selects and modals. Input that is naturally multi-field (for example a table code, bet and raise amount) belongs in a modal rather than in a plain-text command. Handlers should use DiscordEmbeds for structured responses and DiscordLocalization for ru/en text. The adapter owns Discord-specific formatting; game results remain channel-neutral.

Component custom IDs are backed by persistent component tokens. A token is validated before a component or modal handler runs, so a button from before a restart is rejected as stale instead of being interpreted against new state. Rate limiting and cooldown decisions happen at the UX layer before the handler is invoked. This protects both local and horizontally scaled BFF replicas; the underlying game executor still remains the authority for idempotency and state concurrency.

Deployment shapes

The framework supports one codebase across four operational shapes. The composition root and environment select the shape; game/application contracts do not change.

Combined compatibility host

CasinoShiz.Host
  backend modules
  Telegram modules
  admin UI
  persistence
  eventing
  jobs

This mode is useful for local development, compatibility and simple deployment.

The monolith keeps the existing shared postgres/cazino database and loads all registered game modules. Backend:ServiceName may be omitted; the framework keeps the legacy Quartz scheduler partition for existing schedules.

Legacy microservices profile

The compatibility split keeps one all-games Backend while separating service and channel processes:

CasinoShiz.Backend
  backend modules
  event store
  projections
  jobs
  admin compatibility surface

CasinoShiz.TelegramBff
  Telegram ingress
  update routing
  Telegram handlers
  gRPC/local contract clients

CasinoShiz.AdminBff
  browser auth/session
  reverse proxy / operations calls
  no direct database access

CasinoShiz.IdentityService
  identity-owned storage and contracts

CasinoShiz.WalletService
  wallet, ledger, limits and protection

The service databases are separate physical ownership boundaries:

Backend  -> backend-postgres/backend
Identity -> identity-postgres/identity
Wallet   -> wallet-postgres/wallet

Identity and Wallet run only their own migrations. Backend never reads their tables; composed admin/player views call Identity, Wallet and Backend APIs.

Distributed game profile

The distributed Compose profile uses the same CasinoShiz.Backend image for each selected game service. Backend:Modules controls which module is loaded:

Backend__Modules=dice
Backend__ServiceName=game-dice
ConnectionStrings__Postgres=...backend...

Each channel BFF resolves a game independently:

Backend__GrpcAddress=http://game-admin:8081       # fallback/control plane
Backend__GameAddresses__Poker=http://game-poker:8081
Backend__GameAddresses__Dice=http://game-dice:8081

If a per-game address is absent, the BFF falls back to Backend:GrpcAddress, preserving the monolith and legacy microservices path. Native dice transports use named gRPC clients, so DiceCube, Darts, Football, Basketball and Bowling can also be routed independently.

The service DNS names are stable, so scaling changes only replica count:

docker compose --profile distributed up --scale game-poker=3

The game service owns only its selected module migrations and schedule rows. Other game services may share the Backend PostgreSQL instance, but they do not perform cross-module table reads.

Kubernetes / Helm

The Helm chart deploys the same image and configuration model as the distributed Compose profile. Each game is a Deployment/Service pair; Identity, Wallet and Backend have separate PostgreSQL StatefulSets, and Redis carries CAP/event transport. A game can be scaled without a code or image variant:

helm upgrade --install cazinoshiz ./deploy/helm/cazinoshiz
kubectl scale deployment game-poker --replicas=3

Production installations can replace the chart's PostgreSQL/Redis templates with managed services by overriding addresses, credentials and storage values.

Transport choice belongs to composition. Modules should depend on logical contracts, not on whether the target is local, gRPC or another transport.

Persistence styles

The framework supports two aggregate persistence styles.

Classical aggregates

Classical aggregates are persisted by module-owned stores/repositories, usually with Dapper and module-specific tables. Most simple game modules should use this style.

Event-sourced aggregates

Event-sourced aggregates implement IEventSourcedAggregate and are registered by the module:

services.RegisterAggregate<MyAggregate>(PersistenceStrategy.EventSourced);

This registration wires:

  • IRepository<MyAggregate>EventSourcedRepository<MyAggregate>
  • IAggregateFactory<MyAggregate>DefaultAggregateFactory<MyAggregate> unless the module already registered a custom factory

The event-sourced repository owns the aggregate lifecycle:

  1. Load stored events from IEventStore.
  2. Deserialize and replay them through LoadFromHistory.
  3. Append PendingEvents with optimistic concurrency.
  4. Dispatch appended events through EventDispatcher.
  5. Call MarkEventsCommitted after successful append.

DefaultAggregateFactory<T> tries to create aggregates through DI, first with the stream id as a string constructor argument and then without it. A module can override this by registering its own IAggregateFactory<T> before calling RegisterAggregate<T>(PersistenceStrategy.EventSourced).

Event flow

The current event-sourced flow is intentionally post-commit:

Application service
  ↓
IRepository<TAggregate>
  ↓
EventSourcedRepository<TAggregate>
  ↓
IEventStore.AppendAsync(...)        // commits module_events
  ↓
EventDispatcher.DispatchAsync(...)  // projections, domain bus, analytics

IEventStore remains a small storage primitive: load stream and append events. It does not know about projections, subscribers or analytics.

EventDispatcher fans each committed event into:

  • matching IProjections;
  • IDomainEventBus subscribers, including framework-wide subscribers;
  • IAnalyticsService.Track(...).

Post-commit dispatch means a dispatch failure does not roll back the already committed event append. Projection handlers and subscribers must be idempotent. Recovery should be done with replay/rebuild jobs or targeted retries.

ProjectionContext.Transaction is nullable. It is normally null in the current implementation. The field exists so a future same-transaction unit-of-work implementation can pass a provider-specific transaction object without changing module contracts.

Cross-module domain events

IDomainEventBus lets modules react to events without referencing each other. Subscriptions are pattern-based:

Pattern

Meaning

sh.game_ended

exact event

sh.*

every event from one module

*.game_ended

one action from any module

*

every event

At startup, EventSubscriptionInitializer subscribes framework listeners to *:

  • EventLogSubscriber writes events to event_log for admin/history views.
  • ClickHouseEventMirror mirrors events to ClickHouse when analytics is enabled.

Modules add their own subscribers with:

services.AddDomainEventSubscription<MySubscriber>("poker.*");

IDomainEventBus is an abstraction. The active implementation can be in-process or backed by CAP/Redis Streams depending on composition and configuration.

When Redis/CAP is enabled, the event path is:

game transaction
  -> game_event_outbox (PostgreSQL)
  -> lease-based outbox dispatcher
  -> CAP PostgreSQL outbox + Redis Streams
  -> CapEventConsumer
  -> local projections/subscribers

CAP consumer groups are derived from Backend:ServiceName. Replicas of one logical service use the same group and load-balance delivery. Different game services use different groups, so a subscriber in game-meta is not skipped by game-poker. Consumers and projections must still be idempotent because the transport is at-least-once.

Projections

Projections are module-owned read models. A projection declares which event types it handles:

public sealed class MyProjection : IProjection
{
    public IReadOnlySet<string> SubscribedEventTypes { get; } =
        new HashSet<string> { "poker.hand_won" };

    public Task ApplyAsync(IDomainEvent ev, ProjectionContext ctx, CancellationToken ct)
    {
        // update read model table
    }
}

Register it in the module:

services.AddProjection<MyProjection>();

Projections run after the event append commits. They should be idempotent and safe to replay.

Event tables

module_events stores event-sourced aggregate streams. It is the source of truth for replayable aggregate state.

event_log is a flat audit/history stream populated by EventLogSubscriber. It can contain events from event-sourced modules and other published domain events. Admin history pages should read this table, not replay aggregate streams.

module_snapshots stores optional aggregate snapshots through ISnapshotStore<T>.

event_dispatch_failures stores failed post-commit dispatch attempts for targeted retry and operational recovery.

Outbox and side effects

The framework distinguishes event delivery from external side effects.

Domain/integration events can flow through the configured event bus. External effects such as Telegram messages emitted from subscribers and background jobs should use a durable outbox.

Telegram outbox records are persisted before sending. In the monolith, the local dispatcher claims due rows and sends them to Telegram. In the split deployment, the Backend relay claims rows, publishes a CAP command through Redis to Telegram BFF, and marks a row sent only after the BFF confirms the Telegram message id. Both modes reclaim expired leases and retain retry metadata on failure.

Handlers that respond immediately to live Telegram updates may still send direct Telegram responses. Critical asynchronous notifications should use the outbox.

The same failure model applies to Discord: discord_outbox is persisted in the owning PostgreSQL database and Discord BFF replicas claim rows with leases. Message delivery is therefore independent from the update/interaction request that produced it.

Distributed outbox and scheduler ownership

game_event_outbox is a fan-out source for the configured event bus. Its SKIP LOCKED lease means any healthy Backend replica can publish an event once; CAP then fans it out to the logical consumer groups.

game_schedule_outbox is different: a schedule must be applied by the service that registered its command. Every row stores game_id, and a distributed worker claims only rows for its loaded modules. Migration 027_schedule_outbox_ownership backfills older scoped schedule IDs.

Quartz uses Backend:ServiceName as the persistent sched_name partition. Replicas of game-poker therefore cluster together, while a game-dice replica cannot acquire Poker triggers. The monolith falls back to the legacy CasinoShiz scheduler name for upgrade compatibility.

Wallet and identity boundaries

Wallet and identity are logical service boundaries.

Backend modules should not directly query wallet or identity tables. They should use framework contracts such as wallet read/write ports, player directory ports and service adapters selected by composition.

In a combined host those ports may be implemented locally. In a split deployment they may be implemented by gRPC clients pointing at CasinoShiz.IdentityService and CasinoShiz.WalletService.

The Admin BFF is the composition boundary for cross-service read models. For example, player and admin views call Identity, Wallet and Backend Operations over gRPC and return one response to the browser:

/api/aggregation/players/{userId}
  -> Identity player data
  -> Wallet balance/protection data

/api/aggregation/admin
  -> Backend operations data
  -> Wallet analytics data

These are composed read models, not database joins. No BFF or Backend process may connect to another service's PostgreSQL database or read another service's tables directly.

Wallet invariants such as idempotent debits/credits, balance scopes, ledger append, limits, cooldowns and self-exclusion belong to the wallet owner, not to individual game modules.

Migrations

Framework-owned tables are created by framework migrations first. Module migrations are then applied through IModuleMigrations and tracked in __module_migrations.

Migrations are forward-only raw SQL migrations executed via Dapper.

Startup uses a PostgreSQL advisory lock around the migration phase. This is required when Kubernetes starts many game replicas simultaneously. In the monolith, framework and all loaded module migrations run against cazino. In the legacy microservices profile, Backend, Identity and Wallet each run only their own migration set against their own connection string. In the distributed profile, every game process runs framework migrations plus the selected module's migrations against the Backend database.

Backend migrations must not create or mutate Wallet/Identity-owned tables except during explicitly marked compatibility transitions. A module that is not selected must not be required for a selected service to start.

Adding a new backend module

A new backend module should:

  1. Define its application service contract or use an existing framework contract.
  2. Define domain model, commands, results and events.
  3. Choose classical persistence or event-sourced persistence.
  4. Register services through ConfigureServices(IModuleServiceCollection).
  5. Register migrations through IModuleMigrations.
  6. Register projections and domain-event subscribers when needed.
  7. Keep Telegram, HTTP and transport-specific code out of the backend module.

Adding a new Telegram adapter module

A new Telegram adapter should:

  1. Reference BotFramework.Telegram.
  2. Reference the game contract or backend-facing interface it needs.
  3. Implement Telegram handlers using route attributes.
  4. Parse Telegram input and render Telegram responses.
  5. Call logical application contracts, not concrete backend services.
  6. Register handlers through ConfigureServices(ITelegramModuleServiceCollection).

The adapter should not own game state, wallet state or persistence.

Adding a new event-sourced module

A new module that wants event sourcing should:

  1. Define domain events implementing IDomainEvent with stable EventType strings, e.g. mygame.round_started.
  2. Define an aggregate implementing IEventSourcedAggregate.
  3. Register it with RegisterAggregate<MyAggregate>(PersistenceStrategy.EventSourced).
  4. Optionally register IAggregateFactory<MyAggregate> if the default DI/id constructor creation is not enough.
  5. Register projections with AddProjection<TProjection>().
  6. Register cross-module subscribers with AddDomainEventSubscription<TSubscriber>(pattern).

After that, appended aggregate events automatically flow through projections, event log, ClickHouse mirror, module subscribers and analytics.

Framework rules

  • BotFramework.Sdk should remain transport-neutral.
  • BotFramework.Telegram is the framework project that owns Telegram runtime types.
  • Backend modules should not depend on Telegram adapters.
  • Telegram adapters should not own persistence.
  • Transport adapters should live outside domain/application modules.
  • Composition roots decide local vs remote implementation.
  • Event handlers, projections and subscribers must be idempotent.
  • External side effects that must survive process failure should use an outbox.
  • Wallet and identity tables are owned by their services, not by games.
  • Game modules should communicate through contracts and events, not by importing another game's internals.
Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.9.0-preview.1 100 7/16/2026