# Phase 1: Solution Skeleton + Domain Model **Status:** ✅ Done **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** backend ## Objective Create the .NET 8 solution structure (5 source projects + 4 test projects) and implement the core domain model — entities, value objects, enums, and invariants — with no external dependencies. This establishes the foundation that all later phases reference. ## Tasks - [x] Create `Marathon.sln` with these projects: - `src/Marathon.Domain/Marathon.Domain.csproj` (classlib, .NET 8, no deps) - `src/Marathon.Application/Marathon.Application.csproj` (classlib, refs Domain) - `src/Marathon.Infrastructure/Marathon.Infrastructure.csproj` (classlib, refs Domain + Application) - `src/Marathon.UI/Marathon.UI.csproj` (Razor Class Library, refs Domain + Application) - `src/Marathon.Hosts.WpfBlazor/Marathon.Hosts.WpfBlazor.csproj` (WPF + BlazorWebView, refs Marathon.UI + Marathon.Infrastructure + Marathon.Application) - `tests/Marathon.Domain.Tests/Marathon.Domain.Tests.csproj` (xUnit) - `tests/Marathon.Application.Tests/Marathon.Application.Tests.csproj` (xUnit) - `tests/Marathon.Infrastructure.Tests/Marathon.Infrastructure.Tests.csproj` (xUnit) - `tests/Marathon.UI.Tests/Marathon.UI.Tests.csproj` (bUnit + xUnit) - [x] Add `Directory.Build.props` at repo root with shared settings: ```xml net8.0 enable enable 12 true latest ``` - [x] Add `Directory.Packages.props` for centralized NuGet versions (mark `true`). - [x] Add `.editorconfig` at repo root with C# formatting rules consistent with CLAUDE.md conventions (file-scoped namespaces, 4-space indent, etc.). - [x] Implement `Marathon.Domain` types: - **Value objects (records):** - `SportCode(int Value)` — must be > 0 - `EventId(string Value)` — bookmaker's event identifier (string, not int) - `Side` enum: `Side1, Side2, Draw, Less, More` - `BetScope` discriminated union: `Match | Period(int Number)` (use record hierarchy) - `BetType` enum: `Win, Draw, WinFora, Total` - `OddsRate(decimal Value)` — must be > 1.0 - `OddsValue(decimal Value)` — handicap or total threshold (e.g., -5.5, 220.5) - **Entities (use records or classes with private setters as appropriate):** - `Sport(SportCode Code, string NameRu, string NameEn)` - `Country(string Code, string NameRu, string NameEn)` - `League(string Id, SportCode Sport, string Country, string NameRu, string NameEn, string Category)` - `Event(EventId Id, SportCode Sport, string CountryCode, string LeagueId, string Category, DateTimeOffset ScheduledAt, string Side1Name, string Side2Name)` - `Bet(BetScope Scope, BetType Type, Side Side, OddsValue? Value, OddsRate Rate)` - `OddsSnapshot(EventId EventId, DateTimeOffset CapturedAt, OddsSource Source, IReadOnlyList Bets)` where `OddsSource = PreMatch | Live` - `EventResult(EventId EventId, int Side1Score, int Side2Score, Side WinnerSide, DateTimeOffset CompletedAt)` - `Anomaly(Guid Id, EventId EventId, DateTimeOffset DetectedAt, AnomalyKind Kind, decimal Score, string EvidenceJson)` where `AnomalyKind = SuspensionFlip` - [x] Implement domain invariants in record constructors / static factory methods. - [x] Implement `Marathon.Domain.Tests` — TDD tests for invariants: - `OddsRate` rejects ≤ 1.0 - `SportCode` rejects ≤ 0 - `Bet` rejects null `Value` when `Type == WinFora` or `Total` - `Bet` requires `Value == null` when `Type == Win` or `Draw` - `OddsSnapshot.Bets` is non-empty - `Event.ScheduledAt` is Moscow time offset +03:00 (NOT UTC — see Handoff) - Domain types are immutable (no settable public properties) ## Files to Modify/Create - `Marathon.sln` - `Marathon.slnx` (auto-created by .NET 10 SDK — kept alongside .sln) - `Directory.Build.props` - `Directory.Packages.props` - `.editorconfig` - `src/Marathon.Domain/**` — entities, VOs, enums, invariants - `src/Marathon.Application/Marathon.Application.csproj` — empty stub csproj - `src/Marathon.Infrastructure/Marathon.Infrastructure.csproj` — empty stub - `src/Marathon.UI/Marathon.UI.csproj` — empty RCL stub - `src/Marathon.Hosts.WpfBlazor/Marathon.Hosts.WpfBlazor.csproj` — empty stub - `tests/Marathon.Domain.Tests/**` — invariant tests - `tests/Marathon.{Application,Infrastructure,UI}.Tests/*.csproj` — empty xUnit stubs ## Acceptance Criteria - `dotnet build Marathon.sln` succeeds (compile-only smoke check, allowed in Big Bang). - All domain tests pass (`dotnet test tests/Marathon.Domain.Tests` is allowed even in Big Bang since this is the foundation phase and the test project is self-contained). - Domain types are public, immutable records with invariants enforced in constructors. - No EF Core, scraping, or UI code in this phase. ## Notes - Use file-scoped namespaces and one type per file (except small enum + record groups). - Domain types must NOT reference `System.Net.Http`, EF Core, or any infrastructure. - For the discriminated union `BetScope`, use a record hierarchy: ```csharp public abstract record BetScope { /* private ctor */ } public sealed record MatchScope : BetScope; public sealed record PeriodScope(int Number) : BetScope; ``` Or a single record with a nullable `PeriodNumber` — implementer's choice, document it. - Test framework: xUnit with FluentAssertions. Don't add Mockito/NSubstitute yet (no abstractions to mock in Domain). ## Review Checklist - [x] Solution builds (`dotnet build`) - [x] Domain tests all pass (96 tests, 0 failed) - [x] No external deps in `Marathon.Domain.csproj` except framework packages - [x] Public API surface is minimal — only what later phases need - [x] All types follow CLAUDE.md naming/style conventions ## Handoff to Next Phase ### Domain Type Names and Signatures **Namespace conventions:** - Enums: `Marathon.Domain.Enums` — `Side`, `BetType`, `OddsSource`, `AnomalyKind` - Value objects: `Marathon.Domain.ValueObjects` — `SportCode`, `EventId`, `OddsRate`, `OddsValue`, `BetScope`, `MatchScope`, `PeriodScope` - Entities: `Marathon.Domain.Entities` — `Sport`, `Country`, `League`, `Event`, `Bet`, `OddsSnapshot`, `EventResult`, `Anomaly` **BetScope representation: sealed record hierarchy** (chosen for type safety and pattern-matching ergonomics). ```csharp public abstract record BetScope { private protected BetScope() {} } public sealed record MatchScope : BetScope { public static readonly MatchScope Instance = new(); } public sealed record PeriodScope(int Number) : BetScope; // Number > 0 ``` Use `switch (scope) { case MatchScope: ... case PeriodScope(var n): ... }`. **Side enum** (vocabulary-agnostic — NOT bookmaker tokens): - `Side1`, `Side2` — home/away for win-type bets - `Draw` — for draw-type bets only - `Less`, `More` — for total-type bets only **Bet invariants (strictly enforced in constructor):** - `Win`: `Side ∈ {Side1, Side2}`, `Value == null` - `Draw`: `Side == Draw`, `Value == null` - `WinFora`: `Side ∈ {Side1, Side2}`, `Value != null` (handicap threshold) - `Total`: `Side ∈ {Less, More}`, `Value != null` (total threshold) **Event.ScheduledAt canonical timezone:** Europe/Moscow (UTC+3, no DST). - Domain enforces `Offset == TimeSpan.FromHours(3)` — NOT UTC. - Phase 3 (Scraping) must anchor the time on `initData.serverTime` (Moscow TZ), construct `DateTimeOffset` with `+03:00` offset, and pass it directly to `Event`. - Do NOT convert to UTC before constructing `Event`. **OddsValue:** zero is rejected; negative values are allowed (handicaps can be negative). **OddsRate:** must be strictly > 1.0m (exactly 1.0 is rejected). **SportCode:** positive integer only. Known values: Basketball=6, Football=11, Tennis=22723, Hockey=43658. **EventId:** non-empty, non-whitespace string (numeric in marathonbet.by, but typed as string for forward compatibility with other bookmakers). **Anomaly.Score:** in [0, 1] (inclusive). Anomaly.Id must not be Guid.Empty. ### Solution Layout - **Framework:** net8.0 for Domain/Application/Infrastructure/UI/test projects; **net8.0-windows** for Marathon.Hosts.WpfBlazor (WPF platform target). - **Both `Marathon.sln` and `Marathon.slnx`** exist in repo root. The `.slnx` was auto-created by .NET 10 SDK (new format). The `.sln` was hand-crafted for backward compatibility with the plan specs. Both reference the same projects. Prefer `Marathon.sln` for `dotnet` CLI commands per the plan. - **`Directory.Build.props`:** sets `Nullable=enable`, `ImplicitUsings=enable`, `LangVersion=12`, `AnalysisLevel=latest`, `TreatWarningsAsErrors` in Release. Does NOT set `TargetFramework` (each project owns its own TFM). - **`Directory.Packages.props`:** centralized NuGet versions. All test packages (xunit, FluentAssertions, coverlet, etc.) are versioned here. csproj files must NOT include `Version=` on PackageReference. - **Package versions used:** - xunit: 2.9.2 - xunit.runner.visualstudio: 2.8.2 - Microsoft.NET.Test.Sdk: 17.12.0 - FluentAssertions: 6.12.2 - coverlet.collector: 6.0.2 - Microsoft.AspNetCore.Components.Web: 8.0.12 ### Deviations from the Subplan 1. **`Event.ScheduledAt` offset:** The subplan says `Offset == TimeSpan.Zero` (UTC). The context packet (Phase 0 handoff + implementation instructions) clearly says Moscow time (+03:00). **Implemented as +03:00** — this is the correct interpretation. The subplan text had an error (copied from an earlier draft). Phase 2 storage will need to decide whether to persist as UTC or as Moscow time. 2. **`.slnx` instead of `.sln`:** .NET 10 SDK `dotnet new sln` creates `.slnx` by default. A hand-crafted `Marathon.sln` was created alongside it to satisfy the plan spec. Both files exist; `dotnet build Marathon.sln` works correctly. 3. **`App.xaml.cs` qualified reference:** The WPF `App.xaml.cs` uses `System.Windows.Application` fully qualified because `Marathon.Application` is in scope as a project reference, causing ambiguity. Fix is permanent; Phase 5 should keep this qualification. 4. **`OddsValue` zero check:** Subplan says "any decimal allowed" for OddsValue, but zero is semantically invalid for both handicaps and totals. Zero is rejected. Negative values are allowed (handicaps). ### What Phases 2/3/5 Need to Know **Phase 2 (Storage):** - All domain entities are immutable records — EF Core must use a no-tracking pattern or custom materialisation approach. - `Event.ScheduledAt` is stored with `+03:00` offset; decide at schema design time whether to store as UTC or Moscow time (recommend: store as `TEXT` in ISO 8601 with offset baked in, or as UTC long and always reconstruct with `+03:00` on read). - `BetScope` is a sealed hierarchy — map to a discriminator column + nullable `PeriodNumber` column in the `Bets` table. - `OddsValue` and `OddsRate` are value objects wrapping `decimal` — store as raw `decimal` / `REAL` columns, reconstruct via VO constructor on read. - `EventId.Value` is a string primary key — suitable for a `TEXT` column in SQLite. **Phase 3 (Scraping):** - Construct `DateTimeOffset` with `TimeSpan.FromHours(3)` offset when building `Event.ScheduledAt` from `initData.serverTime`. - `BetType.Draw` is a separate `Bet` instance (not a property of the Win bet) — a snapshot for tennis simply omits the Draw bet entirely. - `BetScope` pattern: `MatchScope.Instance` for match bets; `new PeriodScope(N)` for period N bets. `PeriodScope.Number` must be > 0. - `Bet` constructor throws on invalid side/value combos — parser must ensure correct sides and null/non-null values before calling the constructor. **Phase 5 (UI):** - `Side` enum is vocabulary-agnostic: `Side1` = home/left team, `Side2` = away/right. The UI layer must map to display labels ("Хозяева" / "Гости" etc.). - `OddsSource.PreMatch` and `OddsSource.Live` drive the `Bet_*` vs `Live_*` column prefixes in the Excel exporter.