Files
maraphon-app/plans/initial-implementation/phase-4-application-and-workers.md
T
alexei.dolgolyov 2acbaa5b77 feat(phase-4): application layer + background workers — 202/202 tests green
Use cases (Marathon.Application/UseCases/):
- PullUpcomingEventsUseCase: scrape + persist new events + capture pre-match snapshots
- PullLiveOddsUseCase: refresh live snapshots for all stored events
- PullResultsUseCase: Phase 4 scaffold; delegates to ScrapeResultsAsync (Phase 3 no-op);
  Phase 8 will replace with watch-list polling
- ExportToExcelUseCase: resolves export dir from StorageOptions, delegates to IExcelExporter

ApplicationModule.AddMarathonApplication(IServiceCollection) — no IConfiguration needed.

Background workers (Marathon.Infrastructure/Workers/):
- UpcomingEventsPoller: Cronos 6-field cron schedule (default every 6 h)
- LiveOddsPoller: fixed interval (WorkerOptions.LivePollIntervalSeconds, default 30 s)
- ResultsWatchListPoller: scaffold, disabled by default (WorkerOptions.ResultsPollerEnabled=false)
All three: exception-swallowing, cancellation-aware, scoped DI via CreateAsyncScope().

InfrastructureModule.AddMarathonInfrastructure(IServiceCollection, IConfiguration):
- Composes AddMarathonPersistence + AddMarathonScraping + WorkerOptions + 3 hosted services

App.xaml.cs: replace reflection-based TryAddApplicationAndInfrastructure with direct
AddMarathonApplication() + AddMarathonInfrastructure(config) calls.

Resolved Phase 3 TODO: bind Sports:Basketball:QuarterMode from config in ScrapingModule.

appsettings.json: add Workers.LivePollIntervalSeconds, ResultsPollIntervalSeconds,
ResultsPollerEnabled; add Sports.Basketball.QuarterMode.

Settings.razor + WorkerOptions (UI) + SharedResource.*.resx: surface new Workers fields.

Tests: +14 Application use-case tests, +3 Infrastructure worker tests (185 → 202 total).
2026-05-05 12:28:15 +03:00

8.5 KiB

Phase 4: Application Layer + Background Workers

Status: Done Parent plan: PLAN.md Domain: backend Depends on: Phase 1 (Domain), Phase 2 (Storage), Phase 3 (Scraping)

Objective

Wire scraping + storage together via use-case orchestrators in the Application layer and background services that execute pollers on configurable intervals.

Tasks

  • Implement use cases in Marathon.Application/UseCases/:
    • PullUpcomingEventsUseCase(IOddsScraper, IEventRepository, ISnapshotRepository)
      • ExecuteAsync(CancellationToken) → fetch upcoming events, persist new ones, capture initial pre-match snapshots for each
    • PullLiveOddsUseCase(IOddsScraper, IEventRepository, ISnapshotRepository)
      • ExecuteAsync(CancellationToken) → for each currently-live event, fetch a fresh snapshot, persist it
    • PullResultsUseCase(IOddsScraper, IEventRepository, IResultRepository)
      • ExecuteAsync(DateRange range, IReadOnlyList<EventId>? selection, CancellationToken) → fetch results for completed events (all or selected)
    • ExportToExcelUseCase(IExcelExporter, IOptions<StorageOptions>, ILogger)
      • ExecuteAsync(DateRange, ExportKind, CancellationToken)
  • Implement background services in Marathon.Infrastructure/Workers/:
    • UpcomingEventsPoller : BackgroundService — runs PullUpcomingEventsUseCase on a configurable cron-like schedule (default: every 6 hours, Cronos 6-field)
    • LiveOddsPoller : BackgroundService — runs PullLiveOddsUseCase every WorkerOptions.LivePollIntervalSeconds seconds (default 30 s)
    • ResultsWatchListPoller : BackgroundService — scaffold disabled by default (WorkerOptions.ResultsPollerEnabled = false); formal impl in Phase 8
    • All honor CancellationToken, log via ILogger<T>, skip cycles gracefully on errors
  • Add WorkerOptions POCO bound to Workers:* config (in Marathon.Infrastructure.Configuration; UI mirror in Marathon.UI.Services): UpcomingScheduleCron, LivePollerEnabled, UpcomingPollerEnabled, LivePollIntervalSeconds, ResultsPollerEnabled, ResultsPollIntervalSeconds
  • Add ApplicationModule.AddMarathonApplication(IServiceCollection) in Marathon.Application/ApplicationModule.cs — no IConfiguration needed
  • Add InfrastructureModule.AddMarathonInfrastructure(IServiceCollection, IConfiguration) in Marathon.Infrastructure/InfrastructureModule.cs — composes Persistence + Scraping + Workers
  • Replace reflection wiring in App.xaml.cs with direct AddMarathonApplication() + AddMarathonInfrastructure(config) calls; removed TryAddApplicationAndInfrastructure and TryInvokeExtension helpers
  • Bind Sports:Basketball:QuarterMode from config in ScrapingModule (Phase 3 TODO resolved)
  • Add new Workers keys to appsettings.json + SharedResource.*.resx + Settings.razor
  • Tests in Marathon.Application.Tests/UseCases/:
    • Mock IOddsScraper + repos with NSubstitute
    • PullUpcomingEventsUseCaseTests: persists new events, skips duplicates, tolerates snapshot failures
    • PullLiveOddsUseCaseTests: one snapshot per live event, survives per-event errors
    • PullResultsUseCaseTests: selection filter, null=all-in-range, idempotency, persists scraped results
    • ExportToExcelUseCaseTests: delegates to exporter with correct args, propagates exporter exceptions
  • Tests in Marathon.Infrastructure.Tests/Workers/:
    • LiveOddsPollerTests: happy-path invokes use case; disabled flag skips use case; exception-swallowing (continues running after use-case error)

Files to Modify/Create

  • src/Marathon.Application/UseCases/*.cs
  • src/Marathon.Application/DependencyInjection.cs
  • src/Marathon.Infrastructure/Workers/UpcomingEventsPoller.cs
  • src/Marathon.Infrastructure/Workers/LiveOddsPoller.cs
  • src/Marathon.Infrastructure/Configuration/WorkerOptions.cs
  • tests/Marathon.Application.Tests/UseCases/**
  • tests/Marathon.Infrastructure.Tests/Workers/**

Acceptance Criteria

  • Compiles (Big Bang).
  • Use cases depend only on Application abstractions (no Infrastructure refs).
  • Workers honor cancellation and don't crash on transient errors.
  • All variable timing/enabling is configurable.

Notes

  • Use IHostedService from Microsoft.Extensions.Hosting — works in WPF host via Host.CreateApplicationBuilder() pattern (Phase 5 will expose this).
  • For the cron-style upcoming poller, prefer the Cronos package (small, mature) over hand-rolled scheduling.
  • Big Bang: compile-only smoke check.

Review Checklist

  • Use cases have no Infrastructure dependencies
  • All three pollers configurable (interval, enable/disable)
  • Cancellation propagated correctly (OperationCanceledException re-thrown, breaks loop)
  • Errors logged, not propagated out of ExecuteAsync

Handoff to Next Phase

For Phase 6 (Event Browsing UI)

Use case names, namespaces, and DI lifetimes

All use cases are in Marathon.Application.UseCases, registered Scoped:

Class ExecuteAsync signature Return type
PullUpcomingEventsUseCase (CancellationToken) (int EventsProcessed, int NewEvents, int SnapshotsCaptured)
PullLiveOddsUseCase (CancellationToken) int (snapshots captured)
PullResultsUseCase (DateRange, IReadOnlyList<DomainEventId>?, CancellationToken) (int Inspected, int ResultsLoaded, int Skipped)
ExportToExcelUseCase (DateRange, ExportKind, CancellationToken) string (absolute output path)

DomainEventId alias: using DomainEventId = Marathon.Domain.ValueObjects.EventId; (needed to disambiguate from Microsoft.Extensions.Logging.EventId).

How to inject and call from a Blazor component

@inject PullUpcomingEventsUseCase Puller
@inject ExportToExcelUseCase Exporter

// In an event handler:
var result = await Puller.ExecuteAsync(CancellationToken.None);
// result.EventsProcessed, result.NewEvents, result.SnapshotsCaptured

var path = await Exporter.ExecuteAsync(range, ExportKind.Combined, CancellationToken.None);

Important caveat: Use cases are Scoped. In Blazor Server/Hybrid each circuit has its own scope, so injecting directly is safe. Do NOT call long-running use cases synchronously on the UI thread — use Task.Run or await with a progress indicator. Ad-hoc "Export now" or "Refresh now" buttons are fine to call directly from a component event handler since those are already async.

BackgroundService names

Class Config key Default Notes
UpcomingEventsPoller Workers:UpcomingPollerEnabled true Cron driven (Workers:UpcomingScheduleCron, default every 6 h)
LiveOddsPoller Workers:LivePollerEnabled true Fixed interval (Workers:LivePollIntervalSeconds, default 30 s)
ResultsWatchListPoller Workers:ResultsPollerEnabled false Disabled until Phase 8

All three are registered via AddMarathonInfrastructure. They start automatically with the IHost. No manual wiring needed.

WorkerOptions POCO locations

Two separate WorkerOptions classes exist (same JSON shape, different namespaces):

  • Marathon.Infrastructure.Configuration.WorkerOptions — used by workers (immutable init setters)
  • Marathon.UI.Services.WorkerOptions — used by the Settings page (mutable set setters)

Both bind to "Workers" in appsettings.json. Phase 6 can read live values via IOptionsMonitor<Marathon.UI.Services.WorkerOptions> (already registered by AddMarathonUi).

ApplicationModule entry point

services.AddMarathonApplication();          // no IConfiguration needed
services.AddMarathonInfrastructure(config); // wires Persistence + Scraping + Workers

These are already called in App.xaml.cs. Phase 6 needs no changes to DI setup.

New config keys added in Phase 4

"Workers": {
  "UpcomingScheduleCron": "0 0 */6 * * *",
  "LivePollerEnabled": true,
  "UpcomingPollerEnabled": true,
  "LivePollIntervalSeconds": 30,
  "ResultsPollIntervalSeconds": 300,
  "ResultsPollerEnabled": false
},
"Sports": {
  "Basketball": { "QuarterMode": false }
}

Phase 3 TODO resolved

ScrapingModule now binds Sports:Basketball:QuarterMode from config and passes it to the PeriodScopeMapper constructor. The TODO comment is removed.

Tests added

  • Marathon.Application.Tests: 14 new tests (1 placeholder → 15 total) covering all 4 use cases.
  • Marathon.Infrastructure.Tests: 3 new worker tests (77 → 80 total).
  • Total suite: 185 → 202 passing.