Jak wydzielić GameRunner i zastosować SRP – Refaktoryzacja do Clean Architecture [6/6]  [Część 11]

Jak wydzielić GameRunner i zastosować SRP – Refaktoryzacja do Clean Architecture [6/6]  [Część 11]

Twój Program.cs zaczyna przypominać centrum dowodzenia NASA?
Tworzy kontener DI, uruchamia grę, obsługuje błędy i jeszcze wyświetla wyniki?
To znak, że pora na refaktoryzację i wdrożenie zasady SRP (Single Responsibility Principle)!

W tym artykule pokażę Ci, jak krok po kroku wydzielić klasę GameRunner, uporządkować odpowiedzialności i nadać Twojej aplikacji czytelność, testowalność i profesjonalną strukturę.


🧩 Czym jest SRP i dlaczego ma znaczenie

Zasada Single Responsibility Principle (SRP) mówi, że klasa powinna mieć tylko jeden powód do zmiany. W praktyce oznacza to, że każda klasa powinna odpowiadać za jedno, konkretne zadanie.

Przykład:

  • Program.cs powinien tylko uruchamiać aplikację (bootstrap),
  • GameRunner powinien zarządzać przebiegiem gry,
  • BattleService – odpowiadać za logikę walki.

To prosty, ale kluczowy krok w stronę czystej architektury.

💡 Zobacz też: Tworzenie klas w C# – zasady i najlepsze praktyki


⚙️ Problem: przeładowany Program.cs

Wielu programistów zaczyna z czymś takim:

using DevHobby.Code.RPG.Application.Interfaces;
using DevHobby.Code.RPG.Infrastructure.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

public class Program
{
    static async Task Main(string[] args)
    {
        // Tworzymy i konfigurujemy Host
        var host = CreateHostBuilder(args).Build();

        // Uruchamiamy aplikację
        await RunGameAsync(host);                 
    }

    private static IHostBuilder CreateHostBuilder(string[] args) => 
        Host.CreateDefaultBuilder(args)
        .ConfigureServices((hostContext, services) =>
        {
            services.AddRpgServices();
        });

    private static async Task RunGameAsync(IHost host)
    {
        Console.WriteLine("Nacisnij Enter aby zobaczyć Bohaterów Gry");
        Console.ReadLine();
        Console.WriteLine("=== ARENA WALKI ===\n");

        try
        {
            // Tworzymy scope dla tej sesji gry
            using var serviceScope = host.Services.CreateScope();

            // DI Container automatycznie dostarcza wszystkie zależności
            var gameService = serviceScope.ServiceProvider.GetRequiredService<IGameService>();

            // Ładowanie postaci
            gameService.PobierzPostacie(("postacie.json"));

            // Podłączanie event handlerów
            foreach (var postac in gameService.Postacie)
                postac.KomunikatWygenerowany += Console.WriteLine;

            // Wyświetlanie statusów przed walką
            gameService.WyswietlStatusy();

            Console.WriteLine("Nacisnij Enter aby rozpocząć Grę");
            Console.ReadLine();
            Console.WriteLine("\n--- WALKA ROZPOCZĘTA! ---");

            // Start walki
            var zwyciezca = gameService.Start();

            // Podsumowanie
            Console.WriteLine("\n=== WALKA ZAKOŃCZONA ===");
            gameService.WyswietlStatusy();

            if (zwyciezca != null)
                Console.WriteLine($"\n🏆 {zwyciezca.Imie} WYGRYWA!");
            else
                Console.WriteLine("\n 💥 Wszyscy polegli w walce!");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"❌ Błąd: {ex.Message}");
        }
    }
}

Wygląda niewinnie, ale w miarę rozwoju projektu Program.cs:

  • tworzy zależności,
  • inicjuje logikę,
  • obsługuje błędy,
  • uruchamia grę,
  • wyświetla wyniki…

To klasyczny przypadek złamania SRP. Program.cs robi wszystko, więc ciężko go testować i rozwijać.


🧱 Rozwiązanie: wydzielenie GameRunner

Celem refaktoryzacji jest przeniesienie całej logiki uruchamiania gry do nowej klasy GameRunner.

✳️ Krok 1: Utwórz klasę GameRunner

using DevHobby.Code.RPG.Application.Interfaces;
using DevHobby.Code.RPG.Core.Entities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace DevHobby.Code.RPG.ConsoleApp;

public class GameRunner
{
    public async Task RunAsync(IHost host)
    {      
        try
        {
            // Tworzymy scope dla tej sesji gry
            using var serviceScope = host.Services.CreateScope();

            // DI Container automatycznie dostarcza wszystkie zależności
            var gameService = serviceScope.ServiceProvider.GetRequiredService<IGameService>();

            await RunGameAsync(gameService);
        }
        catch (FileNotFoundException ex)
        {
            DisplayError($"Nie znaleziono pliku: {ex.Message}");
        }
        catch (InvalidOperationException ex)
        {
            DisplayError($"Błąd konfiguracji: {ex.Message}");
        }
        catch (Exception ex)
        {
            DisplayError($"Nieoczekiwany Błąd: {ex.Message}");
        }
        finally
        {
            // Ten kod wykona się ZAWSZE - nawet jeśli wystąpił błąd
            WaitForExit();
        }
    }

    private async Task RunGameAsync(IGameService gameService)
    {
        WaitForUserInput("Nacisnij Enter aby zobaczyć Bohaterów Gry");
        DisplayWelcomeMessage();

        InitializeGame(gameService);
        SetupEventHandlers(gameService);
        DisplayGameStatus(gameService);

        WaitForUserInput("Nacisnij Enter aby rozpocząć Grę");
        var zwyciezca = StartBattle(gameService);
        DisplayResults(gameService, zwyciezca);
    }

    private void DisplayResults(IGameService gameService, Postac zwyciezca)
    {
        // Podsumowanie
        Console.WriteLine("\n=== WALKA ZAKOŃCZONA ===");
        gameService.WyswietlStatusy();

        if (zwyciezca != null)
            Console.WriteLine($"\n🏆 {zwyciezca.Imie} WYGRYWA!");
        else
            Console.WriteLine("\n 💥 Wszyscy polegli w walce!");
    }

    private Postac StartBattle(IGameService gameService)
    {
        Console.WriteLine("\n--- WALKA ROZPOCZĘTA! ---");

        // Start walki
        return gameService.Start();
    }

    private void DisplayGameStatus(IGameService gameService)
    {
        // Wyświetlanie statusów przed walką
        gameService.WyswietlStatusy();
    }

    private void SetupEventHandlers(IGameService gameService)
    {
        // Podłączanie event handlerów
        foreach (var postac in gameService.Postacie)
            postac.KomunikatWygenerowany += Console.WriteLine;
    }

    private void InitializeGame(IGameService gameService)
    {
        // Ładowanie postaci
        var configPath = GetConfigPath();
        gameService.PobierzPostacie(configPath);
    }

    private string GetConfigPath()
    {
        return Path.Combine(
           AppDomain.CurrentDomain.BaseDirectory,
           Program.CONFIG_FILE_NAME); 
    }

    private void DisplayWelcomeMessage()
    {
        Console.WriteLine("=== ARENA WALKI ===\n");
    }

    private void WaitForUserInput(string message)
    {
        Console.WriteLine(message);
        Console.ReadLine();
    }

    private void DisplayError(string message)
    {
        Console.WriteLine($"❌ {message}");
    }

    private void WaitForExit()
    {
        Console.WriteLine("\nNaciśnij dowolny klawisz aby zakończyć...");
        Console.ReadKey();
    }
}

🧠 Co się zmieniło:

  • GameRunner przejmuje odpowiedzialność za uruchamianie i obsługę wyjątków,
  • Program.cs pozostaje prosty i czysty,
  • logika gry jest łatwa do testowania i rozszerzania.

✳️ Krok 2: Uprość Program.cs

using DevHobby.Code.RPG.ConsoleApp;
using DevHobby.Code.RPG.Infrastructure.Extensions;
using Microsoft.Extensions.Hosting;

public class Program
{
    // Stała zamiast "magic string" - łatwo zmienić w jednym miejscu
    public const string CONFIG_FILE_NAME = "postacie.json";

    static async Task Main(string[] args)
    {
        // Tworzymy i konfigurujemy Host
        var host = CreateHostBuilder(args).Build();

        // Uruchamiamy aplikację
        var gameRunner = new GameRunner();
        await gameRunner.RunAsync(host);                 
    }

    private static IHostBuilder CreateHostBuilder(string[] args) => 
        Host.CreateDefaultBuilder(args)
        .ConfigureServices((hostContext, services) =>
        {
            services.AddRpgServices();
        });
}

Teraz Program.cs pełni tylko jedną rolę – buduje kontener DI i uruchamia aplikację.
Resztą zajmuje się GameRunner.


✳️ Krok 3: Zyskaj testowalność

Dzięki tej refaktoryzacji możesz teraz łatwo testować GameRunner w izolacji:

[Fact]
public async Task RunAsync_ShouldHandleExceptionsGracefully()
{
    var mockGameService = new Mock<IGameService>();
    mockGameService.Setup(x => x.RunAsync()).ThrowsAsync(new Exception("Test error"));

    var runner = new GameRunner(mockGameService.Object);

    await runner.RunAsync();

    // Sprawdź, że błąd został obsłużony, a aplikacja nie padła
}

💪 Korzyści z wydzielenia GameRunner

✔️ Czysty, czytelny Program.cs
✔️ Zasada SRP w praktyce
✔️ Łatwiejsze testy jednostkowe
✔️ Mniej błędów przy rozwoju projektu
✔️ Kod gotowy do rozbudowy i utrzymania


🚀 Podsumowanie

Refaktoryzacja Program.cs i wydzielenie GameRunner to prosta zmiana, która radykalnie poprawia czytelność, strukturę i testowalność Twojego projektu.

To właśnie esencja Clean Architecture — oddzielenie odpowiedzialności, klarowny przepływ zależności i logika podzielona na sensowne warstwy.

👉 Zobacz też: Wprowadzenie do Clean Architecture w C#
👉 Następny krok: Testowanie warstwy aplikacji w C# – praktyczne podejście


Jeśli ten artykuł pomógł Ci uporządkować Program.cs,
zostaw komentarz poniżej 👇 i napisz, jakie klasy w Twoim projekcie potrzebują refaktoryzacji.
Chcesz więcej praktycznych przykładów Clean Architecture w C#? – C# Clean Architecture w Praktyce

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *