Files
maraphon-app/plans/initial-implementation/phase-1-solution-and-domain.md
T
alexei.dolgolyov 61114ea31b feat: implement Phase 1 — solution skeleton and domain model
Creates the 9-project .NET 8 solution (5 src + 4 test) with Marathon.Domain
fully implemented: value objects (SportCode, EventId, OddsRate, OddsValue,
BetScope hierarchy), enums (Side, BetType, OddsSource, AnomalyKind), and
entities (Sport, Country, League, Event, Bet, OddsSnapshot, EventResult,
Anomaly) with all invariants enforced in constructors. 96 domain tests pass
(FluentAssertions + xUnit). Directory.Build.props and Directory.Packages.props
centralise build settings and NuGet versions. Both Marathon.sln and Marathon.slnx
are committed; dotnet build Marathon.sln succeeds with 0 warnings/errors.
2026-05-05 01:20:28 +03:00

12 KiB

Phase 1: Solution Skeleton + Domain Model

Status: Done Parent plan: 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

  • 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)
  • Add Directory.Build.props at repo root with shared settings:
    <Project>
      <PropertyGroup>
        <TargetFramework>net8.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
        <LangVersion>12</LangVersion>
        <TreatWarningsAsErrors Condition="'$(Configuration)'=='Release'">true</TreatWarningsAsErrors>
        <AnalysisLevel>latest</AnalysisLevel>
      </PropertyGroup>
    </Project>
    
  • Add Directory.Packages.props for centralized NuGet versions (mark <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>).
  • Add .editorconfig at repo root with C# formatting rules consistent with CLAUDE.md conventions (file-scoped namespaces, 4-space indent, etc.).
  • 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<Bet> 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
  • Implement domain invariants in record constructors / static factory methods.
  • 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:
    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

  • Solution builds (dotnet build)
  • Domain tests all pass (96 tests, 0 failed)
  • No external deps in Marathon.Domain.csproj except framework packages
  • Public API surface is minimal — only what later phases need
  • All types follow CLAUDE.md naming/style conventions

Handoff to Next Phase

Domain Type Names and Signatures

Namespace conventions:

  • Enums: Marathon.Domain.EnumsSide, BetType, OddsSource, AnomalyKind
  • Value objects: Marathon.Domain.ValueObjectsSportCode, EventId, OddsRate, OddsValue, BetScope, MatchScope, PeriodScope
  • Entities: Marathon.Domain.EntitiesSport, Country, League, Event, Bet, OddsSnapshot, EventResult, Anomaly

BetScope representation: sealed record hierarchy (chosen for type safety and pattern-matching ergonomics).

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.