Document 06 · Gridiron Legend

Engineering Spec — Backend Architecture

Part 4Provisional

GRIDIRON LEGEND

Engineering Specification — Part 4: Backend Architecture

Supersedes the earlier draft. Implements Part 3's contracts against Part 2's aggregates, inside Part 1's boundaries. No production code. No frameworks named unless the pattern is meaningless without one. No new gameplay mechanics.


1. DOMAIN MODULE ORGANIZATION

Seven domain modules plus a Shared Kernel (Blueprint 2.5), each domain module owning its commands, queries, handlers, repositories, services, and events exclusively — restated here as the backend's organizing unit, since everything else in this document is scoped by domain first, technical concern second.


2. FOLDER / PROJECT STRUCTURE

/src
  /shared-kernel
    /event-bus  /state-machine-framework  /resolution-layer  /league-structure

  /domains
    /league             /commands /queries /handlers /repositories /services /events
    /franchise-economy  /commands /queries /handlers /repositories /services /events
    /roster-contract    /commands /queries /handlers /repositories /services /events
    /simulation         /commands /queries /handlers /repositories /services /events
    /ai                 /commands /queries /handlers /repositories /services /events
    /story-history      /queries /handlers /repositories /events   -- no /commands folder,
                                                                        not empty, absent
    /online             /commands /queries /handlers /repositories /services /events

  /read-models          -- not nested under any domain; subscribes to Event Bus like
                            any other consumer, never writes back
    /franchise-dashboard /league-history-search /coaching-tree /standings /draft-board

  /application
    /dependency-injection  /scheduler  /background-jobs  /configuration  /logging

  /infrastructure
    /persistence            -- repository implementations
    /event-bus-transport
    /observability           -- logging sinks, metrics, tracing

The absent /commands folder under story-history remains the single most important line in this structure — a folder that was never created cannot be misconfigured, which is a stronger guarantee than any runtime check below can offer.


3. DEPENDENCY INJECTION STRATEGY

Domain-scoped DI modules; cross-domain repository injection is not wireable, not merely discouraged — a command handler in one domain requesting another domain's repository fails at container-build time.

SharedKernelModule (singleton, visible to all domains):
  → EventBus, StateMachineFrameworkService, ResolutionLayerService, LeagueStructureRepo

FranchiseEconomyDomainModule:
  → exports (public): FranchiseQueryService, CapQueryService
  → internal (not exported, unreachable from other modules' containers):
    OwnerRepository, CapLedgerRepository, FinancialLedgerRepository,
    StadiumRepository, FacilityRepository, FanLoyaltyRepository,
    FrontOfficeReputationRepository, CoachRepository, CoordinatorRepository,
    MedicalStaffRepository

AI Domain's registration depends on every domain's command-handling entry points (not their repositories) — identical to a human-triggered command's dependency graph, wired the same way rather than specially, resolving the tension Part 1 Section 2.5 already named between "AI Domain is a bounded context" and "AI and humans use the same command path."


4. COMMAND HANDLERS

CommandHandler<TCommand>:
  handle(command: TCommand, issuedBy: ActorContext) → CommandResultDTO:
    1. authorize(issuedBy, command)                    → AuthorizationError
    2. validateSchema(command.payload)                   → ValidationError
    3. validateBusinessRules(command, domainState)        → DomainRuleViolation
    4. checkAggregateState(command.targetAggregateId)      → AggregateStateError
    5. repository.save(mutatedAggregate)                    → ConcurrencyConflict possible
    6. eventBus.publish(resultingEvents)

    Any failure at 1-4 → audit_log entry, structured error returned (Section 15),
    no state change, no event published.

No handler branches on issuedBy.actorType anywhere in steps 1–6 — a lint-rule-worthy check against Blueprint Principle 2, not a suggestion.


5. QUERY HANDLERS

QueryHandler<TQuery>:
  handle(query: TQuery) → TOutputDTO:
    - default: read model (eventually consistent)
    - explicitly whitelisted exception (e.g. GetCapSpaceSummary): direct
      write-side repository read, for zero-staleness-tolerance validation
      queries only — adding to this whitelist requires the same justification
      already on record for the existing exception, never developer convenience

6. EVENT HANDLERS

Wired directly from Part 3 Section 10's subscription map. Story/History Domain's handlers receive a read-only repository binding — no writable repository is ever injected into that domain's container scope (Section 3), so there is no code path by which a Story/History handler could call repository.save() on anything, regardless of what a future engineer attempts to write.


7. WORKER ARCHITECTURE

Two parallel-worker pools, one concurrency pattern (Blueprint Section 9's gather-then-apply-deterministically), reused rather than duplicated:

  • AI Decision Workers — one per AI-controlled franchise per decision cycle, computing from a frozen state view, outputs collected and applied in deterministic (franchise-ID) order through the normal Command Handler pipeline.
  • Simulation Workers — games within a week are mutually independent until standings/injuries update, so a week's games resolve in parallel workers, results gathered, standings/injuries committed deterministically afterward. This extension wasn't in Part 1 (scoped there to the AI-parallelism risk specifically) but follows the identical pattern, not a new one.

8. SIMULATION SCHEDULER

Two distinct schedulers, matching Part 2 Section 8.7's Simulation-vs-Real time distinction — conflating them would be the same class of bug the simulation_sequence fix (Part 2, Section 4) already corrected once, now at the scheduling layer.

  • Simulation Scheduler: advances discrete simulation phases (week → week, season → offseason → next season). Triggered by an explicit player command (SimulateWeek, SimulateSeason) or, in auto-sim/testing contexts, by the AutoSimTestHarness — never by a real-world clock. This is what drives the Simulation Loop (Part 1, Section 6).
  • Real-Time Scheduler: manages wall-clock-bound events — multiplayer bidding window open/close (Finding 9), turn timers, media rights cycle telegraphing lead time. Operates entirely on Real Timestamps and never influences simulation outcome ordering, only when a simulation-affecting command becomes eligible to be issued.

9. BACKGROUND JOBS

Job Trigger Purpose
AutoSimTestHarness runs Scheduled (pre-launch gate, periodic post-launch) Strategy-dominance detection, AI difficulty calibration (Finding 3), long-horizon economic validation (Finding 8)
Read-model rebuild On-demand (schema version change) or automatic at startup if a version mismatch is detected Cheapest migration category (Part 2, Section 7) — read models are disposable projections of event_log, never a source of truth
CapLoopholeMonitorService audit pass Scheduled, tied to Module 33's tuning cycle Cross-system emergent exploit detection (Bible 32.2)
TankingDetectorService / RosterInvestmentDetectorService evaluation End of each season, after Resolution Layer completes Reads audit_log and event_log for the season just closed
CollusionDetectorService evaluation Periodic, multiplayer leagues only Statistical anomaly detection over trade-value ratios (Finding 5)

10. AI EXECUTION PIPELINE

The full path from "an AI-controlled franchise exists" to "a command is applied," stated end-to-end since Section 7 covered the worker mechanics but not the decision-production logic itself:

1. DecisionCycleSchedulerService invokes each AI-controlled franchise's worker at
   the appropriate cadence phase (offseason / in-season / draft-day / trade-deadline
   — Bible 24.2), never more or less frequently than a human GM's own opportunities
   to act at that phase.
2. Worker reads: AIPersonality, CompetenceTier (per relevant staff role), and a
   frozen snapshot of current domain state via that domain's QueryService — never
   a direct repository read, same rule as any other cross-domain access.
3. Decision-policy logic evaluates the frozen state against personality/competence
   and produces zero or more Command DTOs (Part 3, Section 1) — the SAME DTO shapes
   a human's UI would produce, not an AI-specific variant.
4. Produced commands respect the Decision Taxonomy identically to a human's:
   AI does not skip Strategic-tier decisions or resolve them via a shortcut —
   it "decides" them through the same command path, evaluated at the cadence
   phase where that tier is normally addressed.
5. Commands from all workers in this cycle are collected (Section 7), ordered
   deterministically, and each is submitted to the Command Handler pipeline
   (Section 4) exactly as a human-submitted command would be — including full
   authorization, validation, and audit_log treatment.

AI never bypasses step 5's pipeline under any circumstance, including performance pressure — restating Blueprint Principle 9 ("performance optimizations may not change outcomes") at the one point in the system most likely to tempt a shortcut.


11. REPOSITORY IMPLEMENTATION STRATEGY

Framework-agnostic by design, since naming a specific ORM or query builder here would be exactly the kind of premature framework choice this phase is instructed to avoid — the pattern is specified, not the library.

Repository<TAggregate> implementation shape:
  - backed by a Unit-of-Work scoped to exactly one aggregate transaction
    (Section 12)
  - aggregate reconstruction from persisted row(s) via a Data Mapper — the
    aggregate's in-memory shape and its storage shape are never assumed
    identical, even where they happen to look similar today
  - getById() hydrates a full aggregate; save() diffs against the loaded
    version and writes only changed fields, checking the version column
    (Section 13) as part of the same write
  - softDelete() (Part 2, Section 8.5) is the only deletion path any
    repository implements — there is no hardDelete() method to accidentally
    call, at the interface level (Part 3, Section 6), not just by convention

A framework choice is genuinely required at one specific point, flagged rather than silently made: something has to execute SQL and manage connections. That choice is deferred to actual implementation time, not this document — what's specified here is the shape the chosen tool must support (Unit-of-Work, Data Mapper, optimistic version checks), which any reasonable persistence library can satisfy.


12. TRANSACTION BOUNDARIES

No database transaction ever spans more than one aggregate root. This is a hard rule, not a default — restating Section 1's aggregate decomposition (Part 2, Section 1) at the transaction level, since that decomposition only actually delivers its parallelism benefit if transactions respect it.

Multi-aggregate consistency is achieved without multi-aggregate transactions, via patterns already established elsewhere in this document set rather than a new one invented here:

  • End-of-Season Resolution Layer (Part 1, Section 4): reads frozen snapshots, computes independently, commits each metric's aggregate separately within the same logical pass.
  • Trade (touching two franchises' rosters): the Trade aggregate itself (proposal, acceptance, status) is one transaction. Asset transfer — updating each affected Player/DraftPick aggregate's franchise_id — is a separate, subsequent transaction per asset, coordinated by a domain service acting as a lightweight saga: if an asset-transfer step fails partway through, the saga's compensating action reverts the Trade aggregate to a failed state and logs the partial-failure condition to audit_log, rather than leaving the system in a silently inconsistent state.

13. OPTIMISTIC CONCURRENCY

Every aggregate row carries a monotonically-incrementing aggregate_version column (an addition to Part 2's schema, made explicit here since Part 2 didn't specify a concurrency-control column). repository.save() includes the version read at load time in its write condition; if the stored version has advanced since the read (another worker or request modified the same aggregate first), the write fails with ConcurrencyConflict rather than silently overwriting.

This matters most exactly where parallelism is heaviest: AI Decision Workers (Sections 7/10) and concurrent multiplayer submissions are the two places overlapping writes to the same aggregate are actually likely — two AI trade proposals touching the same player, for instance. ConcurrencyConflict handling (retry policy) is specified in Section 18 (Error Propagation) rather than here, since the concern here is detection, not response.


14. LOGGING

Logging is operational/debugging infrastructure — it is explicitly not a substitute for event_log or audit_log (Part 2, Sections 4 and 4.5), and the three must not be conflated. event_log is permanent gameplay truth; audit_log is permanent operational truth for exploit/collusion investigation; application logs are ephemeral, rotated, and exist for engineers debugging the system itself, not for any in-game or investigative purpose.

Every command execution carries a correlation_id, generated at step 1 of the
  Command Handler pipeline (Section 4), threaded through every log line the
  handler emits AND stored in the corresponding audit_log row — this is what
  lets an engineer trace "what actually happened for this one request" across
  logs without those logs themselves needing to be permanent or queryable at
  the same integrity level as audit_log.

Log levels: standard severity tiers (debug/info/warn/error), environment-
  configured (Section 15) — verbose in development, restrained in production,
  never gameplay-affecting regardless of level, since logging must never be on
  a code path that influences simulation outcomes.

15. CONFIGURATION

Two categories of configuration, requiring different change-control processes — conflating them would let a gameplay-balance change slip through as if it were an infrastructure tweak.

  • Infrastructure configuration (DB connection details, log levels, environment flags): standard environment-scoped config, changeable by ops engineers through normal deployment processes.
  • Tuning configuration — the TuningVariableRegistry (Bible 33.2): cap collar percentage, realignment cadence, RiskState severity thresholds, and every other value the Bible itself marked "config, pending Module 33 balancing." These are gameplay-affecting values and must go through the same rigor as any other Bible-governed decision — version-controlled, tied to AutoSimTestHarness validation before a change ships, never editable as a casual runtime environment variable.

16. INTERNAL SERVICE COMMUNICATION

Within a domain: direct method calls, same process, no indirection needed. Across domains: exclusively Event Bus (async, for anything that isn't a direct read) or QueryService interfaces (sync, read-only) — never a direct call into another domain's service or repository. This consolidates a rule that's been stated piecemeal across Sections 3, 6, and 11 into one explicit place.


17. PERFORMANCE CONSIDERATIONS

Distinct from Part 1's gameplay-facing Performance Budgets (game simulation speed, AI cycle time) — this section covers backend-internal concerns that serve those budgets without being identical to them:

  • Connection pooling sized to the AI Worker pool's parallelism (Section 7) — up to ~40 concurrent workers means the pool must not become the bottleneck the parallelism was designed to avoid elsewhere.
  • Read-model projection lag is itself a budget: Part 1's <1s League History query target is only achievable if the projection pipeline (Section 6's Event Bus subscribers building read models) keeps pace with event_log growth.
  • Caching for frequently-read, rarely-changed data — League/Conference/Division structure, TuningVariableRegistry current values.
  • N+1 query avoidance in repository implementations, particularly for any read path that hydrates multiple related aggregates (a franchise dashboard touching seven separate aggregate roots per Part 2, Section 1).

18. ERROR PROPAGATION

Part 3 Section 9's error taxonomy propagates from repository/service through the Command Handler to the caller without being swallowed at any layer:

ConcurrencyConflict (Section 13) → automatic bounded retry (recommend 3 attempts)
  at the Command Handler level BEFORE surfacing to the caller — transient
  contention among parallel AI workers is expected and often self-resolves on
  retry; only exhausted-retry conflicts surface upward.

ValidationError, AuthorizationError, DomainRuleViolation, AggregateStateError →
  never retried automatically — surfaced immediately, with the structured
  error shape from Part 3 Section 9 intact, never collapsed into a generic
  message before it reaches the caller.

SystemError → always logged (Section 14) with full context BEFORE propagating,
  never silently caught-and-ignored anywhere in the call chain.

19. TESTING HOOKS

Distinct from Part 1 Section 12's testing strategy (what to test) — this specifies the backend hooks that make that strategy executable:

  • Repository swappability via DI (Section 3): test doubles substitute for real repository implementations without touching domain or handler code.
  • Explicit seed injection point for the Simulation Core, satisfying Blueprint Section 6's determinism requirement operationally: a test harness supplies a seed and a starting snapshot, captures the resulting event sequence, and asserts on it.
  • Event log replay hooks: a test instance can be seeded with a captured event_log sequence and assert on the resulting read-model state after projection.

End of Engineering Specification, Part 4 (Backend Architecture), complete version. Supersedes the earlier partial draft. Part 5 (Frontend Architecture) follows, consuming only the DTOs and read models this document's contracts (Part 3) already define.