# 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.