BotFramework.Rest
0.9.0-preview.1
dotnet add package BotFramework.Rest --version 0.9.0-preview.1
NuGet\Install-Package BotFramework.Rest -Version 0.9.0-preview.1
<PackageReference Include="BotFramework.Rest" Version="0.9.0-preview.1" />
<PackageVersion Include="BotFramework.Rest" Version="0.9.0-preview.1" />
<PackageReference Include="BotFramework.Rest" />
paket add BotFramework.Rest --version 0.9.0-preview.1
#r "nuget: BotFramework.Rest, 0.9.0-preview.1"
#:package BotFramework.Rest@0.9.0-preview.1
#addin nuget:?package=BotFramework.Rest&version=0.9.0-preview.1&prerelease
#tool nuget:?package=BotFramework.Rest&version=0.9.0-preview.1&prerelease
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:
EconomyEffectdebits or credits the command wallet;WalletEconomyEffecttargets an explicitly declared wallet in a multi-wallet command;QuotaEffectconsumes, restores, or grants capacity on a declared quota;IGameRecordwrites module-specific history through a registered writer;IDomainEventis persisted to the transactional event outbox;ScheduleEffectschedules 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, orDoNothing;Concurrency:Disallow(the default) orAllow;BatchSize: a bounded amount of work passed asbatch-sizeto the command;MaxAttemptsandRetryBackoff: 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:
- Put rules and calculations in a synchronous
IGameAction. - Pass time and named entropy through
GameActionInput. - Return every database consequence as state or a materialized effect list.
- Declare every quota and every additional wallet/aggregate lock up front.
- Keep the state store and custom handlers transaction-aware and transport-free.
- Publish analytics and remote notifications from committed events.
- Test deterministic decisions, rejection without effects, rollback, duplicate command ids, concurrent same/different aggregates, and lost-response replay.
- 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:
- Load stored events from
IEventStore. - Deserialize and replay them through
LoadFromHistory. - Append
PendingEventswith optimistic concurrency. - Dispatch appended events through
EventDispatcher. - Call
MarkEventsCommittedafter 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; IDomainEventBussubscribers, 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 *:
EventLogSubscriberwrites events toevent_logfor admin/history views.ClickHouseEventMirrormirrors 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:
- Define its application service contract or use an existing framework contract.
- Define domain model, commands, results and events.
- Choose classical persistence or event-sourced persistence.
- Register services through
ConfigureServices(IModuleServiceCollection). - Register migrations through
IModuleMigrations. - Register projections and domain-event subscribers when needed.
- Keep Telegram, HTTP and transport-specific code out of the backend module.
Adding a new Telegram adapter module
A new Telegram adapter should:
- Reference
BotFramework.Telegram. - Reference the game contract or backend-facing interface it needs.
- Implement Telegram handlers using route attributes.
- Parse Telegram input and render Telegram responses.
- Call logical application contracts, not concrete backend services.
- 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:
- Define domain events implementing
IDomainEventwith stableEventTypestrings, e.g.mygame.round_started. - Define an aggregate implementing
IEventSourcedAggregate. - Register it with
RegisterAggregate<MyAggregate>(PersistenceStrategy.EventSourced). - Optionally register
IAggregateFactory<MyAggregate>if the default DI/id constructor creation is not enough. - Register projections with
AddProjection<TProjection>(). - 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.Sdkshould remain transport-neutral.BotFramework.Telegramis 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 | Versions 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. |
-
net10.0
- BotFramework.Contracts (>= 0.9.0-preview.1)
- MediatR (>= 12.4.1)
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 10.0.9)
- Microsoft.AspNetCore.OpenApi (>= 10.0.9)
- Microsoft.OpenApi (>= 2.7.5)
- StackExchange.Redis (>= 3.0.7)
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 |