Files
maraphon-app/plans/initial-implementation/phase-1-solution-and-domain.md
T

6.0 KiB

Phase 1: Solution Skeleton + Domain Model

Status: Not Started 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 UTC (Offset == TimeSpan.Zero)
    • Domain types are immutable (no settable public properties)

Files to Modify/Create

  • Marathon.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
  • 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