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).
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— runsPullUpcomingEventsUseCaseon a configurable cron-like schedule (default: every 6 hours, Cronos 6-field)LiveOddsPoller : BackgroundService— runsPullLiveOddsUseCaseeveryWorkerOptions.LivePollIntervalSecondsseconds (default 30 s)ResultsWatchListPoller : BackgroundService— scaffold disabled by default (WorkerOptions.ResultsPollerEnabled = false); formal impl in Phase 8- All honor
CancellationToken, log viaILogger<T>, skip cycles gracefully on errors
- Add
WorkerOptionsPOCO bound toWorkers:*config (inMarathon.Infrastructure.Configuration; UI mirror inMarathon.UI.Services):UpcomingScheduleCron,LivePollerEnabled,UpcomingPollerEnabled,LivePollIntervalSeconds,ResultsPollerEnabled,ResultsPollIntervalSeconds - Add
ApplicationModule.AddMarathonApplication(IServiceCollection)inMarathon.Application/ApplicationModule.cs— noIConfigurationneeded - Add
InfrastructureModule.AddMarathonInfrastructure(IServiceCollection, IConfiguration)inMarathon.Infrastructure/InfrastructureModule.cs— composes Persistence + Scraping + Workers - Replace reflection wiring in
App.xaml.cswith directAddMarathonApplication()+AddMarathonInfrastructure(config)calls; removedTryAddApplicationAndInfrastructureandTryInvokeExtensionhelpers - Bind
Sports:Basketball:QuarterModefrom config inScrapingModule(Phase 3 TODO resolved) - Add new
Workerskeys toappsettings.json+SharedResource.*.resx+Settings.razor - Tests in
Marathon.Application.Tests/UseCases/:- Mock
IOddsScraper+ repos with NSubstitute PullUpcomingEventsUseCaseTests: persists new events, skips duplicates, tolerates snapshot failuresPullLiveOddsUseCaseTests: one snapshot per live event, survives per-event errorsPullResultsUseCaseTests: selection filter, null=all-in-range, idempotency, persists scraped resultsExportToExcelUseCaseTests: delegates to exporter with correct args, propagates exporter exceptions
- Mock
- 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/*.cssrc/Marathon.Application/DependencyInjection.cssrc/Marathon.Infrastructure/Workers/UpcomingEventsPoller.cssrc/Marathon.Infrastructure/Workers/LiveOddsPoller.cssrc/Marathon.Infrastructure/Configuration/WorkerOptions.cstests/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
IHostedServicefromMicrosoft.Extensions.Hosting— works in WPF host viaHost.CreateApplicationBuilder()pattern (Phase 5 will expose this). - For the cron-style upcoming poller, prefer the
Cronospackage (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 (immutableinitsetters)Marathon.UI.Services.WorkerOptions— used by the Settings page (mutablesetsetters)
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.