Jasny przepływ kodu

od Command-Event do Fluent Method Chaining i Monad w C#

Jasny przepływ kodu:
od Command-Event
do Fluent Method Chaining
i Monad w C#

@walenciukC

Speaker
Ilość kodu generowanego i "tylko" czytanego kodu rośnie
screenshot
screenshot
screenshot
screenshot
screenshot
screenshot

Jak napisać kod, który będzie zrozumiały nawet dla twojego product ownera czy scrum mastera?

Jak czytam cudzą aplikację?
Od momentu startu? Bo chcemy mieć pełny obraz
  1. Od momentu startu
Potem chcemy się skupić na konkretnym fragmencie, który nas interesuje
screenshot
screenshot
  1. Vertical Slices mówi nam, że przy pomocy
Chcemy pokazać konkretny przepływ kodu, który będzie kogoś interesował

Wniosek 1:

Nazwy klas, metod, folderów, projektów kierują nasz wzrok i mózg do konkretnego fragmentu kodu, który nas interesuje

W Domain Driven Design mamy "Ubiquitous Language", czyli wspólny język między biznesem a programistami

Tak żebyś wiedział, gdzie szukać tego w kodzie, gdy ktoś biznesowo opisze Ci problem z daną aplikacją

A teraz opowiem o tym, co mi nie wyszło
  1. Chciałem kiedyś napisać czytelny kod przy pomocy

Wniosek 2:

Spodobało mi się to, że w tym kodzie nie wyrzucałem wyjątków.

Miałem "Rezultaty", które mówiły mi, czy dane polecenie się wykonało, czy nie, i co poszło nie tak

Nie sterowałem przepływem wyjątkami

  1. Pierwszy problem

Wniosek 3:

"Zdarzenia" na pewno nie powinny sterować przepływem.

"Zdarzenia" czy strumień zdarzeń powinny dawać możliwość podpinania dodatkowego kodu, gdy dane polecenie się wykonało.

  1. Drugi problem

Wniosek 4:

Nieważne "jak", muszę mieć w jednym miejscu w kodzie widok przepływu kodu

  1. Trzeci problem

Wniosek 5:

O ile to możliwe, stworzyć polecenia jak klocki Lego, którym mogę dowolnie zmieniać kolejność.

Chociaż wiem, że ma to swoją cenę

Wniosek 6:

Zobaczyłem potrzebę podziału przepływu per "jeden element" i per "cały batch", a w tym rozwiązaniu średnio to było widać

Kod nie był czytelny
Ale jak to? Przecież użyłem MediatR

O ile po nazwie można było wszystko znaleźć, to najbardziej bolesny był brak czytelności i zrozumienia przepływu kodu

  1. Czego chcę od mojego kodu
  1. Czego chcę od mojego kodu
screenshot

Zaraz, naprawdę bez wyjątków?

Wyjątki można wyrzucać tylko wtedy, gdy nastąpił błąd krytyczny i chcecie wyłączyć bądź zablokować całą aplikację

Warto stworzyć sobie "wajhę", która będzie wrzucała wyjątki zamiast błędnych "rezultatów", aby debugowało się wygodniej

Scentralizowany i widoczny przepływ kodu to jest to

To usunie potrzebę kminienia, jak projekty, foldery i pliki powinny być ułożone, bo będzie to widać w jednym miejscu, na starcie aplikacji

  1. Od momentu startu
To zobaczmy, jak można to zrealizować w kodzie
  1. Przykład demo
Istnieje wiele sposobów, aby to napisać
Nie ma jednego właściwego rozwiązania

Wniosek 7:

Nie ma co trzymać się sztywno pewnych zasad. Railway Oriented Programming powiedziałby nam, że metody muszą się nazywać "Bind", "Map", "Match" i mieć konkretne sygnatury.

Albo mieć klasy "Maybe", "Either"

Ale to nie jest ważne. Ważne jest, żeby mieć czytelny przepływ kodu

Tutaj wstaw wykład na temat "Monad"

Jednak wiem, że ten "wykład" nic nie wniesie

Oto mój przykład

Wariant 1: fluent flow z metodami statycznymi

                    
                        var filePipeline = Flow.Start()
                            .Then(StartTimingCommand.Run(bus))
                            .Then(OpenFileCommand.Run(bus))
                            .Then(ParseCsvCommand.Run(bus))
                            .Then(ValidateRowsCommand.Run(bus, app.Rules))
                            .Then(DeduplicationCommand.Run(bus))
                            .ThenBranch("SchemaRoute", branch => branch
                                .When("Orders",   ctx => ctx.Schema == "Orders",   pipelineOrdersBranch)
                                .When("Sales",    ctx => ctx.Schema == "Sales",    pipelineSalesBranch)
                                .When("Users",    ctx => ctx.Schema == "Users",    pipelineUsersBranch)
                                .When("Products", ctx => ctx.Schema == "Products", pipelineProductsBranch))
                            .ThenWaitForAll("Enrichment",
                                GeoLookupCommand.Run(bus),
                                AuditStampCommand.Run(bus))
                            .Then(MapEntitiesCommand.Run(bus))
                            .Then(SaveBatchCommand.Run(bus))
                            .Build();
                    
                

Wariant 2: fluent flow z instancjami komend

                    
                        var filePipeline = Flow.Start()
                            .Then(new TimingCommand(bus))
                            .Then(new OpenFileCommand(bus))
                            .Then(new ParseCsvCommand(bus))
                            .Then(new ValidateRowsCommand(bus, app.Rules))
                            .Then(new DeduplicationCommand(bus))
                            .ThenBranch("SchemaRoute", branch => branch
                                .When("Orders",   ctx => ctx.Schema == "Orders",   ordersBranch)
                                .When("Sales",    ctx => ctx.Schema == "Sales",    salesBranch)
                                .When("Users",    ctx => ctx.Schema == "Users",    usersBranch)
                                .When("Products", ctx => ctx.Schema == "Products", productsBranch))
                            .ThenWaitForAll("Enrichment",
                                new GeoLookupCommand(bus),      // np. geolokalizacja regionu
                                new AuditStampCommand(bus))     // np. audit timestamp
                            .Then(new MapEntitiesCommand(bus))
                            .Then(new SaveBatchCommand(bus))
                            .Build();
                    
                
Oto ostatnia forma

Wariant 3: flow z DI i typami komend

                    
                        var flow = Flow.Start()

                            // cross-cutting
                            .Then<StartTimingCommand>()
                            // wspólne kroki wejściowe
                            .Then<OpenFileCSVCommand>()      // → ctx.FileContext
                            .Then<ParseCsvCommand>()         // → ctx.ParsedFile
                            .Then<ValidateRowsCommand>()     // → ctx.ValidatedFile  | ctx.NoItemsReason
                            .Then<DeduplicationCommand>()    // → ctx.ValidatedFile  | ctx.NoItemsReason

                            // rozgałęzienie per schemat
                            .ThenBranch("SchemaRoute", branch => branch
                                .When("Orders",   ctx => ctx.FileContext?.Schema == "Orders",
                                    then => then.Then<CalcOrderTotalsCommand>())
                                .When("Sales",    ctx => ctx.FileContext?.Schema == "Sales",
                                    then => then.Then<CurrencyConvertCommand>())
                                .When("Users",    ctx => ctx.FileContext?.Schema == "Users",
                                    then => then.Then<ValidateEmailCommand>())               // | ctx.NoItemsReason
                                .When("Products", ctx => ctx.FileContext?.Schema == "Products",
                                    then => then.Then<ValidateStockCommand>()))

                            // wspólne kroki wyjściowe
                            .Then<MapEntitiesCommand>()                        // → ctx.Entities

                            // równoległe wzbogacenie
                            .ThenWaitForAll<GeoLookupCommand, AuditStampCommand>("Enrichment")

                            .Then<SaveBatchCommand>()                          // → ctx.ImportResult

                            // BuildScoped — każde wywołanie = nowy scope DI
                            .BuildScoped(services);
                    
                

BatchPipeline: potok dla wielu plików

                    
                        public class BatchPipeline
                        {
                            private readonly ScopedFlow _flow;
                            private readonly IPipelineEventBus _bus;

                            public BatchPipeline(ScopedFlow flow, IPipelineEventBus bus)
                            {
                                _flow = flow;
                                _bus = bus;
                            }

                            public async Task<Result<BatchReport>> ProcessBatchAsync(IEnumerable<string> filePaths)
                            {
                                var paths = filePaths.ToArray();

                                var missing = paths.Where(p => !File.Exists(p)).ToArray();
                                if (missing.Length > 0)
                                    return Result<BatchReport>.Fail(
                                        $"Paths not found: {string.Join(", ", missing.Select(Path.GetFileName))}");

                                var imports  = new List<ImportResult>();
                                var errors   = new List<FileError>();
                                var skipped  = new List<FileSkipped>();

                                foreach (var path in paths)
                                {
                                    Console.WriteLine($"\n  Plik: {Path.GetFileName(path)}");

                                    var ctx = new PipelineContext { FilePath = path };
                                    await _flow.RunAsync(ctx);

                                    if (ctx.Failed)
                                        errors.Add(new FileError(path, ctx.Error!));
                                    else if (ctx.IsEmpty)
                                        skipped.Add(new FileSkipped(path, ctx.NoItemsReason!));
                                    else
                                        imports.Add(ctx.ImportResult!);

                                    ctx.Trace.Dump();
                                }

                                _bus.Publish(new PipelineEvent.BatchCompleted(
                                    paths, imports.Count, errors.Count));

                                return Result<BatchReport>.Ok(new BatchReport(
                                    Total: paths.Length,
                                    Succeeded: imports.Count,
                                    Skipped: skipped.Count,
                                    Failed: errors.Count,
                                    Imports: imports,
                                    SkippedFiles: skipped,
                                    Errors: errors));
                            }
                        }
                    
                

Zdarzenia potoku i prosty EventBus

                    
                        public abstract record PipelineEvent(string FilePath, DateTime Timestamp)
                        {
                            public record StepStarted(
                                string FilePath, string StepName
                            ) : PipelineEvent(FilePath, DateTime.UtcNow);

                            public record StepCompleted(
                                string FilePath, string StepName, TimeSpan Duration, string Detail
                            ) : PipelineEvent(FilePath, DateTime.UtcNow);

                            public record StepFailed(
                                string FilePath, string StepName, string Error
                            ) : PipelineEvent(FilePath, DateTime.UtcNow);

                            public record StepNoItems(
                                string FilePath, string StepName, string Reason
                            ) : PipelineEvent(FilePath, DateTime.UtcNow);

                            public record FileCompleted(
                                string FilePath, int RowsSaved, int RowsSkipped
                            ) : PipelineEvent(FilePath, DateTime.UtcNow);

                            public record BatchCompleted(
                                string[] FilePaths, int Succeeded, int Failed
                            ) : PipelineEvent("batch", DateTime.UtcNow);
                        }

                        public interface IPipelineEventBus
                        {
                            void Publish(PipelineEvent evt);
                            IDisposable Subscribe(Action<PipelineEvent> handler);
                        }

                        public class PipelineEventBus : IPipelineEventBus
                        {
                            private readonly List<Action<PipelineEvent>> _handlers = [];

                            public void Publish(PipelineEvent evt)
                            {
                                foreach (var handler in _handlers)
                                    handler(evt);
                            }

                            public IDisposable Subscribe(Action<PipelineEvent> handler)
                            {
                                _handlers.Add(handler);
                                return new Unsubscriber(() => _handlers.Remove(handler));
                            }

                            private sealed class Unsubscriber(Action onDispose) : IDisposable
                            {
                                public void Dispose() => onDispose();
                            }
                        }

                    
                

Subskrybent: logowanie kroków w konsoli

                    
                        public static class EventSubscribers
                        {
                            /// <summary>
                            /// Loguje każdy krok na konsolę z ikonami i kolorami.
                            /// </summary>
                            public static Action<PipelineEvent> ConsoleLogger => evt =>
                            {
                                switch (evt)
                                {
                                    case PipelineEvent.StepStarted e:
                                        Console.ForegroundColor = ConsoleColor.DarkGray;
                                        Console.WriteLine($"    => {e.StepName}...");
                                        Console.ResetColor();
                                        break;

                                    case PipelineEvent.StepCompleted e:
                                        Console.ForegroundColor = ConsoleColor.Green;
                                        Console.Write($"    OK {e.StepName,-22}");
                                        Console.ResetColor();
                                        Console.WriteLine($" {e.Duration.TotalMilliseconds,6:F1}ms  {e.Detail}");
                                        break;

                                    case PipelineEvent.StepFailed e:
                                        Console.ForegroundColor = ConsoleColor.Red;
                                        Console.WriteLine($"    OH NO {e.StepName,-22} {e.Error}");
                                        Console.ResetColor();
                                        break;

                                    case PipelineEvent.StepNoItems e:
                                        Console.ForegroundColor = ConsoleColor.Yellow;
                                        Console.WriteLine($"    ○ {e.StepName,-22} {e.Reason}");
                                        Console.ResetColor();
                                        break;

                                    case PipelineEvent.FileCompleted e:
                                        Console.ForegroundColor = ConsoleColor.Cyan;
                                        Console.WriteLine($"    => {Path.GetFileName(e.FilePath)}: " +
                                                        $"{e.RowsSaved} saved, {e.RowsSkipped} skipped");
                                        Console.ResetColor();
                                        break;

                                    case PipelineEvent.BatchCompleted e:
                                        Console.WriteLine();
                                        Console.ForegroundColor = e.Failed == 0 ? ConsoleColor.Green : ConsoleColor.Yellow;
                                        Console.WriteLine($"    Batch: {e.Succeeded} ok / {e.Failed} errors " +
                                                        $"({e.FilePaths.Length} total)");
                                        Console.ResetColor();
                                        break;
                                }
                            };

                            /// <summary>
                            /// Zbiera metryki do analizy — czasy kroków, error rate itp.
                            /// </summary>
                            public class MetricsCollector
                            {
                                private readonly List<PipelineEvent.StepCompleted> _completed = [];
                                private readonly List<PipelineEvent.StepFailed> _failed = [];

                                public IReadOnlyList<PipelineEvent.StepCompleted> Completed => _completed;
                                public IReadOnlyList<PipelineEvent.StepFailed> Failed => _failed;

                                public Action<PipelineEvent> Handler => evt =>
                                {
                                    if (evt is PipelineEvent.StepCompleted sc) _completed.Add(sc);
                                    if (evt is PipelineEvent.StepFailed sf) _failed.Add(sf);
                                };

                                public void PrintSummary()
                                {
                                    Console.WriteLine("\n── Metrics ──────────────────────────────");
                                    var byStep = _completed.GroupBy(c => c.StepName);
                                    foreach (var group in byStep)
                                    {
                                        var avg = group.Average(c => c.Duration.TotalMilliseconds);
                                        var max = group.Max(c => c.Duration.TotalMilliseconds);
                                        Console.WriteLine($"  {group.Key,-22} avg={avg:F1}ms  max={max:F1}ms  n={group.Count()}");
                                    }
                                    Console.WriteLine($"  Errors: {_failed.Count}");
                                }
                            }
                        }

                    
                

Program.cs: cały flow w jednym miejscu

                    
                        using Microsoft.Extensions.DependencyInjection;
                        using CsvBatchImporterDI;
                        using CsvBatchImporterDI.Commands;
                        using CsvBatchImporterDI.Events;
                        using CsvBatchImporterDI.Models;
                        using CsvBatchImporterDI.Pipeline;
                        
                        var services = CompositionRoot.CreateServices();
                        
                        var bus = services.GetRequiredService<PipelineEventBus>();
                        var options = services.GetRequiredService<PipelineOptions>();
                        var env = services.GetRequiredService<EnvironmentInfo>();
                        var metrics = services.GetRequiredService<EventSubscribers.MetricsCollector>();
                        
                        bus.Subscribe(EventSubscribers.ConsoleLogger);
                        bus.Subscribe(metrics.Handler);
                        
                        var flow = Flow.Start()
                        
                            // cross-cutting
                            .Then<StartTimingCommand>()
                        
                            // wspólne kroki wejściowe
                            .Then<OpenFileCSVCommand>()      // → ctx.FileContext
                            .Then<ParseCsvCommand>()         // → ctx.ParsedFile
                            .Then<ValidateRowsCommand>()     // → ctx.ValidatedFile  | ctx.NoItemsReason
                            .Then<DeduplicationCommand>()    // → ctx.ValidatedFile  | ctx.NoItemsReason
                        
                            // rozgałęzienie per schemat
                            .ThenBranch("SchemaRoute", branch => branch
                                .When("Orders", ctx => ctx.FileContext?.Schema == "Orders",
                                    then => then.Then<CalcOrderTotalsCommand>())
                                .When("Sales", ctx => ctx.FileContext?.Schema == "Sales",
                                    then => then.Then<CurrencyConvertCommand>())
                                .When("Users", ctx => ctx.FileContext?.Schema == "Users",
                                    then => then.Then<ValidateEmailCommand>()) // | ctx.NoItemsReason
                                .When("Products", ctx => ctx.FileContext?.Schema == "Products",
                                    then => then.Then<ValidateStockCommand>()))
                        
                            // wspólne kroki wyjściowe
                            .Then<MapEntitiesCommand>()       // → ctx.Entities
                        
                            // równoległe wzbogacenie
                            .ThenWaitForAll<GeoLookupCommand, AuditStampCommand>("Enrichment")
                        
                            .Then<SaveBatchCommand>()         // → ctx.ImportResult
                        
                            // BuildScoped — każde wywołanie = nowy scope DI
                            .BuildScoped(services);
                        
                        var input = SampleBatchFiles.Create();
                        
                        ConsoleBatchReporter.PrintStartup(env, options, flow, input);
                        
                        var batch = new BatchPipeline(flow, bus);
                        var result = await batch.ProcessBatchAsync(input.ExistingPaths);
                        
                        ConsoleBatchReporter.PrintReport(result, input.MissingPaths, metrics);
                    


                    
                

CsvBatchImporterDI: SampleBatchFiles

                    
                        namespace CsvBatchImporterDI;
                        
                        public static class SampleBatchFiles
                        {
                            public static BatchInput Create()
                            {
                                var baseDir = AppContext.BaseDirectory;
                                var dataDir = Path.Combine(baseDir, "SampleData");
                        
                                var filePaths = new[]
                                {
                                    Path.Combine(dataDir, "users.csv"),                    // → sukces
                                    Path.Combine(dataDir, "users_all_invalid.csv"),        // → NoItemsToProcess (all rows fail validation)
                                    Path.Combine(dataDir, "orders.csv"),                   // → sukces
                                    Path.Combine(dataDir, "sales.csv"),                    // → sukces
                                    Path.Combine(dataDir, "invoices_unknown_schema.csv"),  // → Failure (unknown schema)
                                    Path.Combine(dataDir, "missing_file.csv"),             // → pominięty (pre-filter)
                                };
                        
                                return new BatchInput(
                                    ExistingPaths: filePaths.Where(File.Exists).ToArray(),
                                    MissingPaths: filePaths.Where(p => !File.Exists(p)).ToArray());
                            }
                        }
                        
                        public record BatchInput(string[] ExistingPaths, string[] MissingPaths);
                    
                

CsvBatchImporterDI: ConsoleBatchReporter

                    
                        using CsvBatchImporterDI.Events;
                        using CsvBatchImporterDI.Models;
                        using CsvBatchImporterDI.Pipeline;
                        
                        namespace CsvBatchImporterDI;
                        
                        public static class ConsoleBatchReporter
                        {
                            public static void PrintStartup(
                                EnvironmentInfo env,
                                PipelineOptions options,
                                ScopedFlow flow,
                                BatchInput input)
                            {
                                Console.WriteLine("╔══════════════════════════════════════════════╗");
                                Console.WriteLine("║    CSV Batch Importer  [Flow + Scoped DI]   ║");
                                Console.WriteLine("╚══════════════════════════════════════════════╝");
                        
                                Console.Write($"\nEnvironment: {env.Name}  |  BreakOnError: ");
                                if (options.BreakOnError)
                                {
                                    Console.ForegroundColor = ConsoleColor.Yellow;
                                    Console.WriteLine("true  ← debugger zatrzyma się na błędzie");
                                }
                                else
                                {
                                    Console.ForegroundColor = ConsoleColor.Green;
                                    Console.WriteLine("false ← tryb produkcyjny");
                                }
                                Console.ResetColor();
                        
                                if (input.MissingPaths.Length > 0)
                                {
                                    Console.ForegroundColor = ConsoleColor.Yellow;
                                    Console.WriteLine("\nPominięte (nie znaleziono):");
                                    foreach (var missing in input.MissingPaths)
                                        Console.WriteLine($"  ✗ {Path.GetFileName(missing)}");
                                    Console.ResetColor();
                                }
                        
                                Console.WriteLine($"\nPipeline: {string.Join(" → ", flow.GetRegistered())}");
                                Console.WriteLine($"Batch: {input.ExistingPaths.Length} plików\n");
                                Console.WriteLine("─────────────────────────────────────────────");
                            }
                        
                            public static void PrintReport(
                                Result<BatchReport> result,
                                string[] missingPaths,
                                EventSubscribers.MetricsCollector metrics)
                            {
                                Console.WriteLine("\n─────────────────────────────────────────────");
                                Console.WriteLine("RAPORT BATCHA");
                                Console.WriteLine("─────────────────────────────────────────────");
                        
                                if (!result.IsSuccess)
                                {
                                    Console.ForegroundColor = ConsoleColor.Red;
                                    Console.WriteLine($"Batch zakończony błędem: {result.Error}");
                                    Console.ResetColor();
                                    return;
                                }
                        
                                var report = result.Value!;
                        
                                if (report.Succeeded > 0)
                                {
                                    Console.ForegroundColor = ConsoleColor.Green;
                                    Console.WriteLine($"\nZaimportowane ({report.Succeeded}):");
                                    Console.ResetColor();
                                    foreach (var import in report.Imports)
                                    {
                                        var skip = import.RowsSkipped > 0 ? $" ({import.RowsSkipped} pominięto)" : "";
                                        Console.WriteLine($"  + {Path.GetFileName(import.FilePath)}: {import.RowsSaved} wierszy{skip}");
                                    }
                                }
                        
                                if (report.Skipped > 0)
                                {
                                    Console.ForegroundColor = ConsoleColor.Yellow;
                                    Console.WriteLine($"\nPominięte — brak wierszy ({report.Skipped}):");
                                    Console.ResetColor();
                                    foreach (var skipped in report.SkippedFiles)
                                        Console.WriteLine($"  ○ {Path.GetFileName(skipped.Path)}: {skipped.Reason}");
                                }
                        
                                var allErrors = report.Errors
                                    .Concat(missingPaths.Select(p => new FileError(p, "File not found")))
                                    .ToList();
                        
                                if (allErrors.Count > 0)
                                {
                                    Console.ForegroundColor = ConsoleColor.Red;
                                    Console.WriteLine($"\nBłędy ({allErrors.Count}):");
                                    Console.ResetColor();
                                    foreach (var error in allErrors)
                                        Console.WriteLine($"  - {Path.GetFileName(error.Path)}: {error.Reason}");
                                }
                        
                                var total = report.Total + missingPaths.Length;
                                Console.WriteLine($"\nŁącznie: {total}  |  OK: {report.Succeeded}  |  Puste: {report.Skipped}  |  Błędy: {allErrors.Count}");
                        
                                Console.ForegroundColor = allErrors.Count == 0 && report.Skipped == 0 ? ConsoleColor.Green
                                    : report.Succeeded > 0 ? ConsoleColor.Yellow : ConsoleColor.Red;
                                Console.WriteLine(allErrors.Count == 0 && report.Skipped == 0 ? "Batch OK."
                                    : report.Succeeded > 0 ? "Częściowy sukces." : "Batch failed.");
                                Console.ResetColor();
                        
                                metrics.PrintSummary();
                            }
                        }
                    
                

CsvBatchImporterDI: bazowa klasa Command

                    
                        using System.Diagnostics;
                        using CsvBatchImporterDI.Events;
                        using CsvBatchImporterDI.Models;
                        using CsvBatchImporterDI.Pipeline;
                        
                        namespace CsvBatchImporterDI.Commands;
                        
                        /// <summary>
                        /// Bazowa klasa dla kroków biznesowych.
                        /// Automatycznie: pomija się gdy ctx.Failed, mierzy czas, emituje eventy, zapisuje trace.
                        /// Podklasa implementuje tylko Execute.
                        /// </summary>
                        public abstract class Command(IPipelineEventBus bus) : ICommand
                        {
                            public abstract string Name { get; }
                        
                            public async Task InvokeAsync(PipelineContext ctx, FlowDelegate next)
                            {
                                if (ctx.Halted)
                                {
                                    await next(ctx);
                                    return;
                                }
                        
                                bus.Publish(new PipelineEvent.StepStarted(ctx.FilePath, Name));
                                var sw = Stopwatch.StartNew();
                        
                                try
                                {
                                    Execute(ctx);
                                }
                                catch (Exception ex)
                                {
                                    ctx.Error = $"{Name}: {ex.Message}";
                                }
                        
                                sw.Stop();
                        
                                if (ctx.Failed)
                                {
                                    ctx.Trace.Add(Name, ctx.Error!, sw.Elapsed, false);
                                    bus.Publish(new PipelineEvent.StepFailed(ctx.FilePath, Name, ctx.Error!));
                                }
                                else if (ctx.IsEmpty)
                                {
                                    ctx.Trace.Add(Name, ctx.NoItemsReason!, sw.Elapsed, true);
                                    bus.Publish(new PipelineEvent.StepNoItems(ctx.FilePath, Name, ctx.NoItemsReason!));
                                }
                                else
                                {
                                    var detail = GetDetail(ctx);
                                    ctx.Trace.Add(Name, detail, sw.Elapsed, true);
                                    bus.Publish(new PipelineEvent.StepCompleted(ctx.FilePath, Name, sw.Elapsed, detail));
                                }
                        
                                await next(ctx);
                            }
                        
                            /// <summary>Logika kroku — ustaw pola na ctx, lub ustaw ctx.Error w razie błędu.</summary>
                            protected abstract void Execute(PipelineContext ctx);
                        
                            /// <summary>Krótki opis wyniku do trace'a.</summary>
                            protected virtual string GetDetail(PipelineContext ctx) => "ok";
                        }
                    
                

CsvBatchImporterDI: Flow i ScopedFlow

                    
                        using Microsoft.Extensions.DependencyInjection;
                        using CsvBatchImporterDI.Models;
                        
                        namespace CsvBatchImporterDI.Pipeline;
                        
                        // ── Delegate ──────────────────────────────────────────────────────
                        
                        public delegate Task FlowDelegate(PipelineContext ctx);
                        
                        // ── Command interface ─────────────────────────────────────────────
                        
                        public interface ICommand
                        {
                            string Name { get; }
                            Task InvokeAsync(PipelineContext ctx, FlowDelegate next);
                        }
                        
                        // ── Flow builder ──────────────────────────────────────────────────
                        
                        public class Flow
                        {
                            private readonly List<Func<IServiceProvider, ICommand>> _factories = [];
                            private readonly List<string> _names = [];
                        
                            private Flow() { }
                        
                            public static Flow Start() => new();
                        
                            /// <summary>Dodaj krok — instancja podana jawnie.</summary>
                            public Flow Then(ICommand command)
                            {
                                _factories.Add(_ => command);
                                _names.Add(command.Name);
                                return this;
                            }
                        
                            /// <summary>Dodaj krok — typ rozwiązywany z DI per scope.</summary>
                            public Flow Then<T>() where T : ICommand
                            {
                                _factories.Add(sp => sp.GetRequiredService<T>());
                                _names.Add(typeof(T).Name.Replace("Command", ""));
                                return this;
                            }
                        
                            /// <summary>Warunkowy krok z DI.</summary>
                            public Flow ThenIf<T>(Func<PipelineContext, bool> predicate) where T : ICommand
                            {
                                _factories.Add(sp => new ConditionalCommand(predicate, sp.GetRequiredService<T>()));
                                _names.Add($"If({typeof(T).Name.Replace("Command", "")})");
                                return this;
                            }
                        
                            /// <summary>Rozgałęzienie — sub-flow budowane ze scope'u w runtime.</summary>
                            public Flow ThenBranch(string name, Action<BranchBuilder> configure)
                            {
                                var branch = new BranchBuilder(name);
                                configure(branch);
                                _factories.Add(_ => branch.Build());
                                _names.Add(name);
                                return this;
                            }
                        
                            /// <summary>Równoległe wykonanie z DI.</summary>
                            public Flow ThenWaitForAll<T1, T2>(string name)
                                where T1 : ICommand
                                where T2 : ICommand
                            {
                                _factories.Add(sp => new WaitForAllCommand(name, [
                                    sp.GetRequiredService<T1>(),
                                    sp.GetRequiredService<T2>()
                                ]));
                                _names.Add(name);
                                return this;
                            }
                        
                            /// <summary>BuildScoped — każde wywołanie RunAsync tworzy nowy scope.</summary>
                            public ScopedFlow BuildScoped(IServiceProvider rootProvider)
                                => new(rootProvider, _factories, _names);
                        
                            /// <summary>Build bez scope'u (testy, singleton flow).</summary>
                            public FlowDelegate Build()
                                => BuildWith(new EmptyServiceProvider());
                        
                            internal FlowDelegate BuildWith(IServiceProvider sp)
                            {
                                var commands = _factories.Select(f => f(sp)).ToList();
                                FlowDelegate terminal = _ => Task.CompletedTask;
                                return commands.AsEnumerable().Reverse()
                                    .Aggregate(terminal, (next, cmd) => ctx => cmd.InvokeAsync(ctx, next));
                            }
                        
                            internal List<Func<IServiceProvider, ICommand>> Factories => _factories;
                        
                            public IReadOnlyList<string> GetRegistered() => _names;
                        
                            private class EmptyServiceProvider : IServiceProvider
                            {
                                public object? GetService(Type serviceType) => null;
                            }
                        
                            internal class EmptyServiceProviderPublic : IServiceProvider
                            {
                                public object? GetService(Type serviceType) => null;
                            }
                        }
                        
                        // ── Scoped flow ───────────────────────────────────────────────────
                        
                        public class ScopedFlow
                        {
                            private readonly IServiceProvider _rootProvider;
                            private readonly List<Func<IServiceProvider, ICommand>> _factories;
                            private readonly IReadOnlyList<string> _names;
                        
                            internal ScopedFlow(
                                IServiceProvider rootProvider,
                                List<Func<IServiceProvider, ICommand>> factories,
                                List<string> names)
                            {
                                _rootProvider = rootProvider;
                                _factories = factories;
                                _names = names;
                            }
                        
                            /// <summary>
                            /// Nowy scope DI → zbuduj łańcuch → wykonaj → dispose scope.
                            /// Scoped serwisy (DbContext, UnitOfWork) żyją per plik.
                            /// </summary>
                            public async Task RunAsync(PipelineContext ctx)
                            {
                                await using var scope = _rootProvider.CreateAsyncScope();
                                ctx.ScopedServices = scope.ServiceProvider;
                        
                                var commands = _factories.Select(f => f(scope.ServiceProvider)).ToList();
                                FlowDelegate terminal = _ => Task.CompletedTask;
                                var pipeline = commands.AsEnumerable().Reverse()
                                    .Aggregate(terminal, (next, cmd) => c => cmd.InvokeAsync(c, next));
                        
                                await pipeline(ctx);
                            }
                        
                            public IReadOnlyList<string> GetRegistered() => _names;
                        }
                        
                        // ── Warunkowy wrapper ─────────────────────────────────────────────
                        
                        internal class ConditionalCommand(
                            Func<PipelineContext, bool> predicate,
                            ICommand inner) : ICommand
                        {
                            public string Name => $"If({inner.Name})";
                        
                            public async Task InvokeAsync(PipelineContext ctx, FlowDelegate next)
                            {
                                if (predicate(ctx))
                                    await inner.InvokeAsync(ctx, next);
                                else
                                    await next(ctx);
                            }
                        }
                        
                        // ── Branch — sub-flow budowany ze scoped providera w runtime ──────
                        
                        public class BranchBuilder
                        {
                            private readonly string _name;
                            private readonly List<(string label, Func<PipelineContext, bool> predicate, Flow subFlow)> _branches = [];
                        
                            internal BranchBuilder(string name) => _name = name;
                        
                            /// <summary>Dodaj gałąź — lambda konfiguruje sub-flow, bez jawnego Flow.Start().</summary>
                            public BranchBuilder When(string label, Func<PipelineContext, bool> predicate, Action<Flow> configure)
                            {
                                var subFlow = Flow.Start();
                                configure(subFlow);
                                _branches.Add((label, predicate, subFlow));
                                return this;
                            }
                        
                            internal BranchCommand Build() => new(_name, _branches);
                        }
                        
                        internal class BranchCommand(
                            string name,
                            List<(string label, Func<PipelineContext, bool> predicate, Flow subFlow)> branches) : ICommand
                        {
                            public string Name => name;
                        
                            public async Task InvokeAsync(PipelineContext ctx, FlowDelegate next)
                            {
                                foreach (var (label, predicate, subFlow) in branches)
                                {
                                    if (predicate(ctx))
                                    {
                                        ctx.Trace.Add(Name, $"→ {label}", TimeSpan.Zero, true);
                        
                                        // Buduj sub-flow z tego samego scoped providera
                                        var sp = ctx.ScopedServices ?? new Flow.EmptyServiceProviderPublic();
                                        var subPipeline = subFlow.BuildWith(sp);
                                        await subPipeline(ctx);
                        
                                        await next(ctx);
                                        return;
                                    }
                                }
                        
                                ctx.Trace.Add(Name, "→ (no match)", TimeSpan.Zero, true);
                                await next(ctx);
                            }
                        }
                        
                        // ── WaitForAll ────────────────────────────────────────────────────
                        
                        internal class WaitForAllCommand(string name, ICommand[] commands) : ICommand
                        {
                            public string Name => name;
                        
                            public async Task InvokeAsync(PipelineContext ctx, FlowDelegate next)
                            {
                                if (ctx.Failed) { await next(ctx); return; }
                        
                                var sw = System.Diagnostics.Stopwatch.StartNew();
                        
                                var tasks = commands.Select(cmd =>
                                {
                                    FlowDelegate noop = _ => Task.CompletedTask;
                                    return cmd.InvokeAsync(ctx, noop);
                                });
                        
                                await Task.WhenAll(tasks);
                                sw.Stop();
                        
                                var names = string.Join(" + ", commands.Select(c => c.Name));
                                ctx.Trace.Add(Name, $"parallel: {names}", sw.Elapsed, !ctx.Failed);
                        
                                await next(ctx);
                            }
                        }
                    
                
Potok jest uruchamiany z zakresem "scoped"
To, co pozwala na wymianę kolejności poleceń, to

PipelineContext: mutowalny stan i trzy ścieżki

                    
                        /// <summary>
                        /// Mutowalny kontekst przepływu — każde polecenie czyta/pisze swoje pola.
                        /// Jeśli Error != null, kolejne polecenia powinny się pominąć.
                        /// </summary>
                        public class PipelineContext
                        {
                            public required string FilePath { get; init; }

                            // Wyniki poszczególnych kroków — nullable bo wypełniane stopniowo
                            public FileContext? FileContext { get; set; }
                            public ParsedFile? ParsedFile { get; set; }
                            public ValidatedFile? ValidatedFile { get; set; }
                            public List<MappedEntity>? Entities { get; set; }
                            public ImportResult? ImportResult { get; set; }

                            // Błąd — jeśli ustawiony, pipeline się zatrzymuje
                            public string? Error { get; set; }
                            public bool Failed => Error is not null;

                            // Trzecia ścieżka — brak elementów do przetworzenia (nie błąd!)
                            public string? NoItemsReason { get; set; }
                            public bool IsEmpty => NoItemsReason is not null;

                            // Cokolwiek przerwało pipeline
                            public bool Halted => Failed || IsEmpty;

                            // Metadane do debugowania i raportowania
                            public PipelineTrace Trace { get; } = new();

                            // Dodatkowe dane z kroków schema-specific (np. OrdersTotal, CurrencyConverted)
                            public Dictionary<string, string> Metadata { get; } = [];

                            // Scoped DI provider — ustawiany przez ScopedFlow, dostępny dla sub-flow
                            public IServiceProvider? ScopedServices { get; set; }
                        }

                    
                
Napisałem wiele wariantów tego kodu

Z wbudowaną obsługą statystyk, drukowania przepływu w konsoli i tworzenia pliku logu per nazwany pipeline dzięki Serilog,

oraz z Circuit Breakerem, który blokuje cały pipeline, jeśli było wcześniej więcej błędów niż "N"

Oto przykład handlera z Kafka Flow

KafkaFlow: batch handler jako wejście do potoku

                    

                        /// <summary>
                        /// KafkaFlow middleware for batched inventory sync messages.
                        /// Uses AddBatching + WithManualMessageCompletion.
                        /// Commits the batch offset only on successful pipeline execution.
                        /// Register as Transient.
                        /// </summary>
                        public class InventorySyncBatchHandler : IMessageMiddleware
                        {
                            private readonly InventoryPipelineFactory              _factory;
                            private readonly ILogger<InventorySyncBatchHandler>    _logger;
                            private readonly PipelineLogsOptions _pipelineLogsOptions;

                            public InventorySyncBatchHandler(InventoryPipelineFactory factory,
                             ILogger<InventorySyncBatchHandler> logger, 
                                PipelineLogsOptions pipelineLogsOptions)
                            {
                                _factory = factory;
                                _logger = logger;
                                _pipelineLogsOptions = pipelineLogsOptions;
                            }

                            public async Task Invoke(IMessageContext context, MiddlewareDelegate next)
                            {
                                var batch = context.GetMessagesBatch();

                                _logger.LogInformation
                                ("Received inventory sync batch: {Count} messages", batch.Count);

                                var syncMessages = new List<KafkaInventorySyncMessage>();

                                foreach (var msg in batch)
                                {
                                    if (msg.Message.Value is KafkaInventorySyncMessage syncMsg)
                                    {
                                        syncMessages.Add(syncMsg);
                                    }
                                    else
                                    {
                                        _logger.LogWarning("Unexpected message type in inventory batch: {Type}",
                                            msg.Message.Value?.GetType().Name ?? "null");
                                    }
                                }

                                if (!syncMessages.Any())
                                {
                                    _logger.LogWarning("No valid inventory sync messages in batch — skipping");
                                    return;
                                }

                                var pipelineContext = BuildContext(syncMessages);
                                var name = _pipelineLogsOptions.Pipelines[nameof(InventorySyncBatchHandler)];
                                var result          = await _factory.Create(name).ExecuteAsync(pipelineContext);

                                if (result.IsSuccess)
                                {
                                    batch.Commit();
                                    _logger.LogInformation(
                                        "Inventory batch SUCCESS | BatchId: {Id} | Persisted: {P}/{T} | Warehouses: {W} | Duration: {D}ms",
                                        pipelineContext.BatchId,
                                        pipelineContext.PersistedItems.Count,
                                        pipelineContext.Items.Count,
                                        pipelineContext.ByWarehouse.Count,
                                        result.ExecutionStats?.TotalDuration.TotalMilliseconds ?? 0);
                                }
                                else if (result.WasTerminatedEarly)
                                {
                                    _logger.LogWarning("Inventory batch terminated early: {Reason}. Batch NOT committed.",
                                        result.TerminationReason);
                                }
                                else
                                {
                                    _logger.LogError(
                                        "Inventory batch FAILED: {Error}. Batch NOT committed — {Count} messages will be re-delivered.",
                                        result.ErrorMessage, batch.Count);
                                }
                            }

                            private static InventorySyncContext BuildContext(List<KafkaInventorySyncMessage> messages)
                            {
                                var context = new InventorySyncContext
                                {
                                    ProcessingStartedAt = DateTime.UtcNow,
                                    BatchSize           = messages.Count
                                };

                                foreach (var msg in messages)
                                {
                                    context.Items.Add(new ProcessableInventoryItem { Original = msg });
                                }

                                return context;
                            }
                        }


                        /// <summary>
                        /// Builds inventory sync pipelines on demand.
                        /// Register as Transient.
                        /// </summary>
                        public class InventoryPipelineFactory
                        {
                            private readonly GlobalStatisticsTracker _stats;
                            private readonly IServiceProvider _serviceProvider;
                            private readonly ILogger<InventoryPipelineFactory> _logger;

                            public InventoryPipelineFactory(
                                GlobalStatisticsTracker stats,
                                IServiceProvider serviceProvider,
                                ILogger<InventoryPipelineFactory> logger)
                            {
                                _stats = stats;
                                _serviceProvider = serviceProvider;
                                _logger = logger;
                            }

                            /// <summary>
                            /// Creates a fully built inventory sync pipeline with the given name.
                            /// Each call returns a distinct pipeline instance — use once per handler.
                            /// </summary>
                            public CommandPipeline<InventorySyncContext> Create(string pipelineName)
                            {
                                var pipeline = new CommandPipeline<InventorySyncContext>(
                                    _stats, _serviceProvider, pipelineName);

                                pipeline.AddEventHandler(new KafkaPipelineEventHandler());

                                new CommandChainBuilder<InventorySyncContext>(pipeline)
                                    .Then<ValidateInventoryCommand>()
                                    .Then<AggregateInventoryCommand>()
                                    .Then<PersistInventoryCommand>()
                                    .Build();

                                _logger.LogInformation(
                                    "[{Pipeline}] Inventory pipeline built: Validate → Aggregate → Persist",
                                    pipelineName);

                                return pipeline;
                            }
                        }
                    
                

KafkaFlow: konfiguracja konsumera i DI

                    
                        using KafkaFlow;
                        using KafkaFlow.Serializer;
                        using KafkaPipeline.ConsumerDI;
                        using KafkaPipeline.ConsumerDI.Pipeline;
                        using KafkaPipeline.ConsumerDI.Pipeline.Handlers;
                        using KafkaPipeline.Shared;
                        using Microsoft.Extensions.Configuration;
                        using Microsoft.Extensions.DependencyInjection;
                        using Microsoft.Extensions.Hosting;
                        using Serilog;
                        using Serilog.Events;

                        namespace KafkaPipeline.ConsumerDI;

                        public class Program
                        {
                            private const string InventoryConsumerName = "inventory-consumer";

                            public static async Task Main(string[] args)
                            {
                                var configuration = new ConfigurationBuilder()
                                    .SetBasePath(Directory.GetCurrentDirectory())
                                    .AddJsonFile("appsettings.json", optional: false)
                                    .AddEnvironmentVariables()
                                    .Build();

                                var pipelineLogs = configuration
                                    .GetSection(PipelineLogsOptions.Section)
                                    .Get<PipelineLogsOptions>() ?? new PipelineLogsOptions();

                                Log.Logger = BuildLogger(pipelineLogs);

                                try
                                {
                                    Log.Information("╔════════════════════════════════════════════════════════════════╗");
                                    Log.Information("║   Kafka Pipeline Consumer — DI Edition                        ║");
                                    Log.Information("║   All commands resolved from IServiceScope per execution      ║");
                                    Log.Information("║   Broker: localhost:19092                                     ║");
                                    Log.Information("║   Topics:                                                     ║");
                                    Log.Information("║     • {Topic,-46}║", KafkaTopics.OrdersInbound   + " (linear pipeline)");
                                    Log.Information("║     • {Topic,-46}║", KafkaTopics.PaymentsInbound + " (branching pipeline)");
                                    Log.Information("║     • {Topic,-46}║", KafkaTopics.InventorySync   + " (batch pipeline)");
                                    Log.Information("╚════════════════════════════════════════════════════════════════╝");

                                    var host =  Host
                                        .CreateDefaultBuilder(args)
                                        .UseSerilog()
                                        .ConfigureServices((context, services) =>
                                        {
                                            services.Configure<PipelineLogsOptions>(
                                                context.Configuration.GetSection(PipelineLogsOptions.Section));

                                            // Register all pipeline services, commands, repositories, handlers, factories
                                            services.AddSingleton<PipelineLogsOptions>(pipelineLogs);
                                            services.AddKafkaPipelineServices();

                                            services.AddKafkaFlowHostedService(kafka => kafka
                                                .UseMicrosoftLog()
                                                .AddCluster(cluster => cluster
                                                    .WithBrokers(new[] { "localhost:19092" })
                                                    .CreateTopicIfNotExists(KafkaTopics.InventorySync, numberOfPartitions: 3, replicationFactor: 1)
                                                    // ── Consumer : Inventory Sync (batch pipeline) ───────────────────────
                                                    .AddConsumer(consumer => consumer
                                                        .Topic(KafkaTopics.InventorySync)
                                                        .WithGroupId(KafkaConsumerGroups.InventorySyncGroup)
                                                        .WithName(InventoryConsumerName)
                                                        .WithBufferSize(200)
                                                        .WithWorkersCount(1)
                                                        .WithAutoOffsetReset(AutoOffsetReset.Earliest)
                                                        .WithManualMessageCompletion()
                                                        .AddMiddlewares(middlewares => middlewares
                                                            .AddDeserializer<JsonCoreDeserializer>()
                                                            .AddBatching(50, TimeSpan.FromSeconds(5))
                                                            .Add<InventorySyncBatchHandler>()
                                                        )
                                                    )
                                                )
                                            );
                                        })
                                        .Build();

                                    try
                                    {
                                        var inventoryFactory = host.Services.GetRequiredService<InventoryPipelineFactory>();
                                        Log.Information("All pipeline factories built successfully");
                                    }
                                    catch (Exception ex)
                                    {
                                        Log.Fatal(ex, "Failed to build pipeline factories");
                                        return;
                                    }


                                    await host.RunAsync();

                                }
                                catch (Exception ex)
                                {
                                    Log.Fatal(ex, "Consumer terminated unexpectedly");
                                }
                                finally
                                {
                                    Log.Information("Consumer shutting down...");
                                    await Log.CloseAndFlushAsync();
                                }
                            }

                            private static ILogger BuildLogger(PipelineLogsOptions options)
                            {
                                var config = new LoggerConfiguration()
                                    .MinimumLevel.Debug()
                                    .MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
                                    .MinimumLevel.Override("KafkaFlow",  LogEventLevel.Warning)
                                    .Enrich.FromLogContext()
                                    .WriteTo.Console(
                                        outputTemplate: "[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Message:lj}{NewLine}{Exception}")
                                    .WriteTo.File(
                                        path: Path.Combine(options.Directory, "log.txt"),
                                        rollingInterval: RollingInterval.Hour,
                                        outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] [{CommandPipelineName}] [{ExecutionId}] {Message:lj}{NewLine}{Exception}");

                                // Per-pipeline sub-loggers — filter by CommandPipelineName enriched via LogContext
                                foreach (var pipelineName in options.Pipelines.Values.Distinct())
                                {
                                    var name    = pipelineName; // local copy for lambda closure
                                    var logPath = Path.Combine(options.Directory, name, "log.txt");

                                    config = config.WriteTo.Logger(lc => lc
                                        .Filter.ByIncludingOnly(evt =>
                                            evt.Properties.TryGetValue("CommandPipelineName", out var value)
                                            && value.ToString().Trim('"') == name)
                                        .WriteTo.File(
                                            path: logPath,
                                            rollingInterval: RollingInterval.Hour,
                                            outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] [{ExecutionId}] {Message:lj}{NewLine}{Exception}"));
                                }

                                return config.CreateLogger();
                            }
                        }

                    
                

Kontekst z wieloma kolekcjami po krokach

                    
                        public class InventorySyncContext
                        {
                            public string   BatchId             { get; set; } = Guid.NewGuid().ToString("N")[..8];
                            public DateTime ProcessingStartedAt { get; set; } = DateTime.UtcNow;
                            public int      BatchSize           { get; set; }

                            public List<ProcessableInventoryItem>             Items          { get; set; } = new();
                            public List<ProcessableInventoryItem>             ValidatedItems { get; set; } = new();
                            public Dictionary<string, List<ProcessableInventoryItem>>      ByWarehouse    { get; set; } = new();
                            public List<ProcessableInventoryItem>             PersistedItems { get; set; } = new();
                            public List<ProcessableInventoryItem>             FailedItems    { get; set; } = new();
                        }
                    
                
Lepiej mieć jedną kolekcję, która będzie zmieniać się w potoku

Prostszy kontekst: jedna kolekcja robocza

                    
                        public class InventorySyncContext
                        {
                            public string   BatchId             { get; set; } = Guid.NewGuid().ToString("N")[..8];
                            public DateTime ProcessingStartedAt { get; set; } = DateTime.UtcNow;
                            public int      BatchSize           { get; set; }

                            public List<ProcessableInventoryItem>                          Items          { get; set; } = new();
                            public List<ProcessableInventoryItem>                          FailedItems    { get; set; } = new();
                        }
                    
                
screenshot
Już ktoś przygotował rozwiązanie "funkcyjne" w C#
screenshot
screenshot
screenshot
screenshot

OneOf jako union type w C#

                    
                        OneOf<User, Error>

                        OneOf<User, NotFound, ValidationError>

                        OneOf<Success, Failure>
                    
                
Czy to jest ta słynna "monada"?
Monada to pudełko z wartością oraz regułą: "co wolno zrobić dalej"
Na pewno jest to najlepszy sposób na symulowanie union types w C#

OneOf<Success, Failure, Empty>: trzy ścieżki

                    
                        using OneOf;
                        
                        namespace CsvImporter;
                        
                        /// <summary>
                        /// Extension methods dla OneOf&lt;TSuccess, TFailure, TEmpty&gt; — trzy ścieżki:
                        ///   T0 = sukces → kontynuuj łańcuch
                        ///   T1 = failure → przerwij (błąd)
                        ///   T2 = empty  → przerwij (brak elementów, nie błąd)
                        ///
                        /// Wzorowane na OneOf.Chaining, rozszerzone o trzeci wariant.
                        /// </summary>
                        public static class OneOfTripleExtensions
                        {
                            /// <summary>
                            /// Then — wykonaj następny krok jeśli sukces, inaczej propaguj failure/empty.
                            /// </summary>
                            public static async Task<OneOf<TSuccess, TFailure, TEmpty>> Then<TSuccess, TFailure, TEmpty>(
                                this Task<OneOf<TSuccess, TFailure, TEmpty>> currentResult,
                                Func<TSuccess, Task<OneOf<TSuccess, TFailure, TEmpty>>> nextJob)
                            {
                                var result = await currentResult;
                        
                                if (result.IsT1) return result.AsT1;
                                if (result.IsT2) return result.AsT2;
                        
                                return await nextJob(result.AsT0);
                            }
                        
                            /// <summary>
                            /// Then (async-safe) — wersja poprawnie awaitująca.
                            /// </summary>
                            public static async Task<OneOf<TSuccess, TFailure, TEmpty>> ThenAsync<TSuccess, TFailure, TEmpty>(
                                this Task<OneOf<TSuccess, TFailure, TEmpty>> currentResult,
                                Func<TSuccess, Task<OneOf<TSuccess, TFailure, TEmpty>>> nextJob)
                            {
                                var result = await currentResult;
                        
                                if (result.IsT1) return result.AsT1; // failure
                                if (result.IsT2) return result.AsT2; // empty
                        
                                return await nextJob(result.AsT0);
                            }
                        
                            /// <summary>
                            /// IfThen — wykonaj krok TYLKO gdy predykat == true i jest sukces.
                            /// </summary>
                            public static async Task<OneOf<TSuccess, TFailure, TEmpty>> IfThen<TSuccess, TFailure, TEmpty>(
                                this Task<OneOf<TSuccess, TFailure, TEmpty>> currentResult,
                                Func<TSuccess, bool> condition,
                                Func<TSuccess, Task<OneOf<TSuccess, TFailure, TEmpty>>> nextJob)
                            {
                                var result = await currentResult;
                        
                                if (result.IsT1) return result.AsT1;
                                if (result.IsT2) return result.AsT2;
                        
                                var success = result.AsT0;
                                if (!condition(success)) return success;
                        
                                return await nextJob(success);
                            }
                        
                            /// <summary>
                            /// Sync Then — dla kroków synchronicznych.
                            /// </summary>
                            public static async Task<OneOf<TSuccess, TFailure, TEmpty>> Then<TSuccess, TFailure, TEmpty>(
                                this Task<OneOf<TSuccess, TFailure, TEmpty>> currentResult,
                                Func<TSuccess, OneOf<TSuccess, TFailure, TEmpty>> nextJob)
                            {
                                var result = await currentResult;
                        
                                if (result.IsT1) return result.AsT1;
                                if (result.IsT2) return result.AsT2;
                        
                                return nextJob(result.AsT0);
                            }
                        }
                    
                    
                

Rejestracja kroków pipeline'u w DI

                    
                        using Microsoft.Extensions.DependencyInjection;
                        using CsvImporter.Commands;
                        
                        namespace CsvImporter;
                        
                        public static class CompositionRoot
                        {
                            public static IServiceProvider CreateServices()
                            {
                                var services = new ServiceCollection();
                        
                                // Kroki pipeline'u — każdy jako serwis DI
                                services.AddScoped<FileCommands>();
                                services.AddScoped<ValidationCommands>();
                                services.AddScoped<MappingCommands>();
                                services.AddScoped<PersistenceCommands>();
                        
                                // Schema-specific
                                services.AddScoped<OrderCommands>();
                                services.AddScoped<SalesCommands>();
                                services.AddScoped<UserCommands>();
                                services.AddScoped<ProductCommands>();
                        
                                // Enrichment
                                services.AddScoped<EnrichmentCommands>();
                        
                                return services.BuildServiceProvider();
                            }
                        }
                    

                    
                

Uruchomienie pipeline'u z OneOf

                    
                        using Microsoft.Extensions.DependencyInjection;
                        using CsvImporter;
                        using CsvImporter.Models;
                        using CsvImporter.Commands;
                        
                        var services = CompositionRoot.CreateServices();
                        var input = SampleBatchFiles.Create();
                        
                        ConsoleBatchReporter.PrintStartup(input);
                        
                        // ═══════════════════════════════════════════════════
                        //  PIPELINE — cały przepływ w jednym miejscu
                        // ═══════════════════════════════════════════════════
                        
                        foreach (var path in input.ExistingPaths)
                        {
                            Console.WriteLine($"\n  Plik: {Path.GetFileName(path)}");
                        
                            await using var scope = services.CreateAsyncScope();
                            var sp = scope.ServiceProvider;
                        
                            var file = sp.GetRequiredService<FileCommands>();
                            var validation = sp.GetRequiredService<ValidationCommands>();
                            var mapping = sp.GetRequiredService<MappingCommands>();
                            var persistence = sp.GetRequiredService<PersistenceCommands>();
                            var orders = sp.GetRequiredService<OrderCommands>();
                            var sales = sp.GetRequiredService<SalesCommands>();
                            var users = sp.GetRequiredService<UserCommands>();
                            var products = sp.GetRequiredService<ProductCommands>();
                            var enrichment = sp.GetRequiredService<EnrichmentCommands>();
                        
                            var result = await ImportContext.Create(path)
                        
                                // wspólne kroki wejściowe
                                .ThenAsync(file.OpenFile)
                                .ThenAsync(file.ParseCsv)
                                .ThenAsync(validation.ValidateRows)              // → może: NoItemsToProcess
                                .ThenAsync(validation.Deduplicate)                // → może: NoItemsToProcess
                        
                                // rozgałęzienie per schemat
                                .IfThen(ctx => ctx.Schema == "Orders", orders.CalcTotals)
                                .IfThen(ctx => ctx.Schema == "Sales", sales.ConvertCurrency)
                                .IfThen(ctx => ctx.Schema == "Users", users.ValidateEmails)    // → może: NoItemsToProcess
                                .IfThen(ctx => ctx.Schema == "Products", products.ValidateStock)
                        
                                // wspólne kroki wyjściowe
                                .ThenAsync(mapping.MapEntities)
                                .ThenAsync(enrichment.GeoLookup)
                                .ThenAsync(enrichment.AuditStamp)
                                .ThenAsync(persistence.SaveBatch);
                        
                            ConsoleBatchReporter.PrintItemResult(path, result);
                        }
                    

                    
                

CsvImporter-OneOf: FileCommands

                    
                        using System.Diagnostics;
                        using CsvImporter.Models;
                        
                        namespace CsvImporter.Commands;
                        
                        public class FileCommands
                        {
                            private static readonly string[] SupportedExtensions = [".csv", ".tsv"];
                            private static readonly Dictionary<string, char> DelimiterHints = new() { { ".tsv", '\t' } };
                        
                            private static readonly string[][] KnownSchemas =
                            [
                                ["id", "name", "email"],
                                ["id", "product", "quantity", "price"],
                                ["id", "title", "category", "stock"],
                                ["date", "region", "amount", "currency"]
                            ];
                            private static readonly string[] SchemaNames = ["Users", "Orders", "Products", "Sales"];
                        
                            public Task<PipelineResult> OpenFile(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                        
                                if (!File.Exists(ctx.FilePath))
                                    return Task.FromResult<PipelineResult>(
                                        new FileNotFoundFailure(ctx.FilePath));
                        
                                ctx.DetectedFile = new FileInfo(ctx.FilePath);
                                ctx.Extension = ctx.DetectedFile.Extension.ToLowerInvariant();
                        
                                if (!SupportedExtensions.Contains(ctx.Extension))
                                    return Task.FromResult<PipelineResult>(
                                        new UnsupportedTypeFailure(ctx.Extension));
                        
                                var bytes = File.ReadAllBytes(ctx.FilePath);
                                ctx.Encoding = DetectEncoding(bytes);
                        
                                if (DelimiterHints.TryGetValue(ctx.Extension, out var hint))
                                    ctx.Delimiter = hint;
                                else
                                {
                                    var firstLine = File.ReadLines(ctx.FilePath).FirstOrDefault() ?? "";
                                    var delim = InferDelimiter(firstLine);
                                    if (!delim.HasValue)
                                        return Task.FromResult<PipelineResult>(
                                            new DelimiterFailure());
                                    ctx.Delimiter = delim.Value;
                                }
                        
                                ctx.Header = File.ReadLines(ctx.FilePath)
                                    .FirstOrDefault()?.Split(ctx.Delimiter)
                                    .Select(c => c.Trim().ToLowerInvariant()).ToArray() ?? [];
                        
                                for (var i = 0; i < KnownSchemas.Length; i++)
                                {
                                    if (KnownSchemas[i].All(col => ctx.Header.Contains(col)))
                                    {
                                        ctx.Schema = SchemaNames[i];
                                        sw.Stop();
                                        ctx.AddTrace("OpenFile", $"schema={ctx.Schema}, delim='{ctx.Delimiter}'", sw.Elapsed);
                                        return Task.FromResult<PipelineResult>(ctx);
                                    }
                                }
                        
                                return Task.FromResult<PipelineResult>(
                                    new UnknownSchemaFailure(ctx.Header));
                            }
                        
                            public Task<PipelineResult> ParseCsv(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                var lines = File.ReadAllLines(ctx.FilePath);
                                if (lines.Length < 2)
                                    return Task.FromResult<PipelineResult>(
                                        new EmptyFileFailure());
                        
                                ctx.ParsedRows = lines.Skip(1)
                                    .Select((line, i) => new CsvRow(i + 2, line.Split(ctx.Delimiter))).ToList();
                        
                                sw.Stop();
                                ctx.AddTrace("ParseCsv", $"{ctx.ParsedRows.Count} data rows", sw.Elapsed);
                                return Task.FromResult<PipelineResult>(ctx);
                            }
                        
                            private static string DetectEncoding(byte[] bytes)
                            {
                                if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) return "UTF-8 BOM";
                                if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) return "UTF-16 LE";
                                return bytes.Any(b => b > 0x7F) ? "UTF-8 (extended)" : "UTF-8";
                            }
                        
                            private static char? InferDelimiter(string line)
                            {
                                char[] candidates = [',', ';', '\t', '|'];
                                return candidates.OrderByDescending(c => line.Count(ch => ch == c))
                                    .FirstOrDefault(c => line.Contains(c));
                            }
                        }
                    
                

CsvImporter-OneOf: ProcessingCommands

                    
                        using System.Diagnostics;
                        using CsvImporter.Models;
                        
                        namespace CsvImporter.Commands;
                        
                        public class ValidationCommands
                        {
                            public Task<PipelineResult> ValidateRows(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                var headerCount = ctx.Header.Length;
                                var errors = new List<RowError>();
                                var valid = new List<CsvRow>();
                        
                                foreach (var row in ctx.ParsedRows)
                                {
                                    var rowErrors = new List<string>();
                                    if (row.Fields.Length != headerCount)
                                        rowErrors.Add($"Expected {headerCount} columns, got {row.Fields.Length}");
                        
                                    var empty = row.Fields.Select((f, i) => (f, i))
                                        .Where(x => string.IsNullOrWhiteSpace(x.f))
                                        .Select(x => x.i + 1).ToList();
                                    if (empty.Count > 0)
                                        rowErrors.Add($"Empty field(s) at columns: {string.Join(", ", empty)}");
                        
                                    if (rowErrors.Count > 0)
                                        errors.AddRange(rowErrors.Select(e => new RowError(row.LineNumber, e)));
                                    else
                                        valid.Add(row);
                                }
                        
                                ctx.ValidRows = valid;
                                ctx.ValidationErrors = errors;
                        
                                sw.Stop();
                                ctx.AddTrace("ValidateRows",
                                    errors.Count > 0 ? $"{valid.Count} ok, {errors.Count} errors" : $"all {valid.Count} valid",
                                    sw.Elapsed);
                        
                                // TUTAJ: jeśli walidacja odrzuciła WSZYSTKIE wiersze → NoItemsToProcess
                                if (valid.Count == 0)
                                    return Task.FromResult<PipelineResult>(
                                        new NoItemsToProcess("ValidateRows",
                                            $"All {ctx.ParsedRows.Count} rows failed validation ({errors.Count} errors)",
                                            ctx));
                        
                                return Task.FromResult<PipelineResult>(ctx);
                            }
                        
                            public Task<PipelineResult> Deduplicate(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                var before = ctx.ValidRows.Count;
                        
                                ctx.ValidRows = ctx.ValidRows
                                    .GroupBy(r => string.Join("|", r.Fields))
                                    .Select(g => g.First()).ToList();
                        
                                var removed = before - ctx.ValidRows.Count;
                                sw.Stop();
                                ctx.AddTrace("Deduplicate", $"{ctx.ValidRows.Count} unique ({removed} removed)", sw.Elapsed);
                        
                                // Deduplikacja usunęła wszystko → NoItemsToProcess
                                if (ctx.ValidRows.Count == 0)
                                    return Task.FromResult<PipelineResult>(
                                        new NoItemsToProcess("Deduplicate",
                                            $"All {before} rows were duplicates", ctx));
                        
                                return Task.FromResult<PipelineResult>(ctx);
                            }
                        }
                        
                        public class MappingCommands
                        {
                            public Task<PipelineResult> MapEntities(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                var header = File.ReadLines(ctx.FilePath)
                                    .First().Split(ctx.Delimiter).Select(h => h.Trim()).ToArray();
                        
                                ctx.Entities = ctx.ValidRows.Select(row =>
                                {
                                    var fields = header.Zip(row.Fields.Select(f => f.Trim()))
                                        .ToDictionary(p => p.First, p => p.Second);
                                    return new MappedEntity(
                                        fields.GetValueOrDefault("id", Guid.NewGuid().ToString()), fields);
                                }).ToList();
                        
                                sw.Stop();
                                ctx.AddTrace("MapEntities", $"{ctx.Entities.Count} entities → '{ctx.Schema}'", sw.Elapsed);
                                return Task.FromResult<PipelineResult>(ctx);
                            }
                        }
                        
                        public class PersistenceCommands
                        {
                            public async Task<PipelineResult> SaveBatch(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                await Task.Delay(30);
                        
                                ctx.Result = new ImportResult(ctx.FilePath, ctx.Entities.Count, ctx.ValidationErrors.Count);
                                sw.Stop();
                                ctx.AddTrace("SaveBatch", $"{ctx.Result.RowsSaved} saved, {ctx.Result.RowsSkipped} skipped", sw.Elapsed);
                                return ctx;
                            }
                        }
                    
                

CsvImporter-OneOf: SchemaCommands

                    
                        using System.Diagnostics;
                        using CsvImporter.Models;
                        
                        namespace CsvImporter.Commands;
                        
                        public class OrderCommands
                        {
                            public Task<PipelineResult> CalcTotals(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                var qtyIdx = Array.IndexOf(ctx.Header, "quantity");
                                var priceIdx = Array.IndexOf(ctx.Header, "price");
                        
                                var total = 0m;
                                foreach (var row in ctx.ValidRows)
                                    if (decimal.TryParse(row.Fields[qtyIdx], out var qty) &&
                                        decimal.TryParse(row.Fields[priceIdx], out var price))
                                        total += qty * price;
                        
                                ctx.Metadata["OrdersTotal"] = total.ToString("F2");
                                sw.Stop();
                                ctx.AddTrace("CalcOrderTotals", $"total={total:F2}", sw.Elapsed);
                                return Task.FromResult<PipelineResult>(ctx);
                            }
                        }
                        
                        public class SalesCommands
                        {
                            private static readonly Dictionary<string, decimal> Rates = new()
                                { { "USD", 1.0m }, { "EUR", 1.08m }, { "GBP", 1.27m }, { "PLN", 0.25m } };
                        
                            public Task<PipelineResult> ConvertCurrency(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                var amountIdx = Array.IndexOf(ctx.Header, "amount");
                                var currencyIdx = Array.IndexOf(ctx.Header, "currency");
                                var converted = 0;
                        
                                foreach (var row in ctx.ValidRows)
                                {
                                    var currency = row.Fields[currencyIdx].Trim().ToUpperInvariant();
                                    if (currency != "USD" && decimal.TryParse(row.Fields[amountIdx], out var amount) &&
                                        Rates.TryGetValue(currency, out var rate))
                                    {
                                        var fields = row.Fields.ToArray();
                                        fields[amountIdx] = (amount * rate).ToString("F2");
                                        fields[currencyIdx] = "USD";
                                        ctx.ValidRows[ctx.ValidRows.IndexOf(row)] = new CsvRow(row.LineNumber, fields);
                                        converted++;
                                    }
                                }
                        
                                ctx.Metadata["CurrencyConverted"] = converted.ToString();
                                sw.Stop();
                                ctx.AddTrace("ConvertCurrency", $"{converted} rows → USD", sw.Elapsed);
                                return Task.FromResult<PipelineResult>(ctx);
                            }
                        }
                        
                        public class UserCommands
                        {
                            public Task<PipelineResult> ValidateEmails(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                var emailIdx = Array.IndexOf(ctx.Header, "email");
                                var valid = new List<CsvRow>();
                        
                                foreach (var row in ctx.ValidRows)
                                {
                                    var email = row.Fields[emailIdx].Trim();
                                    if (!email.Contains('@') || !email.Contains('.'))
                                        ctx.ValidationErrors.Add(new RowError(row.LineNumber, $"Invalid email: '{email}'"));
                                    else
                                        valid.Add(row);
                                }
                        
                                ctx.ValidRows = valid;
                                sw.Stop();
                                ctx.AddTrace("ValidateEmails", $"{valid.Count} valid emails", sw.Elapsed);
                        
                                // Wszystkie emaile niepoprawne → NoItemsToProcess
                                if (valid.Count == 0)
                                    return Task.FromResult<PipelineResult>(
                                        new NoItemsToProcess("ValidateEmails", "All emails invalid", ctx));
                        
                                return Task.FromResult<PipelineResult>(ctx);
                            }
                        }
                        
                        public class ProductCommands
                        {
                            public Task<PipelineResult> ValidateStock(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                var stockIdx = Array.IndexOf(ctx.Header, "stock");
                                var corrected = 0;
                        
                                foreach (var row in ctx.ValidRows)
                                    if (int.TryParse(row.Fields[stockIdx].Trim(), out var stock) && stock < 0)
                                    {
                                        var fields = row.Fields.ToArray();
                                        fields[stockIdx] = "0";
                                        ctx.ValidRows[ctx.ValidRows.IndexOf(row)] = new CsvRow(row.LineNumber, fields);
                                        corrected++;
                                    }
                        
                                ctx.Metadata["StockCorrected"] = corrected.ToString();
                                sw.Stop();
                                ctx.AddTrace("ValidateStock", $"{corrected} negative stocks → 0", sw.Elapsed);
                                return Task.FromResult<PipelineResult>(ctx);
                            }
                        }
                        
                        public class EnrichmentCommands
                        {
                            public async Task<PipelineResult> GeoLookup(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                var regions = new Dictionary<string, string>
                                    { { "North", "52.23°N" }, { "South", "50.06°N" }, { "East", "51.25°N" }, { "West", "51.10°N" } };
                        
                                await Task.Delay(20);
                                var matched = 0;
                                foreach (var e in ctx.Entities)
                                    if (e.Fields.TryGetValue("region", out var r) && regions.TryGetValue(r, out var c))
                                        { e.Fields["geo_coords"] = c; matched++; }
                        
                                ctx.Metadata["GeoMatched"] = matched.ToString();
                                sw.Stop();
                                ctx.AddTrace("GeoLookup", $"{matched} geocoded", sw.Elapsed);
                                return ctx;
                            }
                        
                            public Task<PipelineResult> AuditStamp(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                var stamp = DateTime.UtcNow.ToString("O");
                                foreach (var e in ctx.Entities)
                                    { e.Fields["_imported_at"] = stamp; e.Fields["_source_file"] = Path.GetFileName(ctx.FilePath); }
                        
                                ctx.Metadata["AuditStamp"] = stamp;
                                sw.Stop();
                                ctx.AddTrace("AuditStamp", $"stamped {ctx.Entities.Count} entities", sw.Elapsed);
                                return Task.FromResult<PipelineResult>(ctx);
                            }
                        }
                    
                

CsvImporter-OneOf: PrintItemResult

                    
                        using CsvImporter.Models;
                        
                        namespace CsvImporter;
                        
                        public static class ConsoleBatchReporter
                        {
                            public static void PrintStartup(BatchInput input)
                            {
                                Console.WriteLine("╔══════════════════════════════════════════════╗");
                                Console.WriteLine("║   CSV Batch Importer  [OneOf.Chaining]      ║");
                                Console.WriteLine("╚══════════════════════════════════════════════╝");
                        
                                if (input.MissingPaths.Length > 0)
                                {
                                    Console.ForegroundColor = ConsoleColor.Yellow;
                                    Console.WriteLine("\nPominięte (nie znaleziono):");
                                    foreach (var missing in input.MissingPaths)
                                        Console.WriteLine($"  ✗ {Path.GetFileName(missing)}");
                                    Console.ResetColor();
                                }
                        
                                Console.WriteLine($"\nBatch: {input.ExistingPaths.Length} plików\n");
                                Console.WriteLine("─────────────────────────────────────────────");
                            }
                        
                            public static void PrintItemResult(string path, PipelineResult result)
                            {
                                result.Switch(
                                    ctx =>
                                    {
                                        PrintTrace(ctx.Trace, ConsoleColor.Green);
                        
                                        var skip = ctx.Result!.RowsSkipped > 0 ? $" ({ctx.Result.RowsSkipped} pominięto)" : "";
                                        Console.ForegroundColor = ConsoleColor.Green;
                                        Console.WriteLine($"    + OK: {ctx.Result.RowsSaved} wierszy{skip}");
                                        Console.ResetColor();
                                    },
                                    failure =>
                                    {
                                        Console.ForegroundColor = ConsoleColor.Red;
                                        Console.WriteLine($"    ✗ {Path.GetFileName(path)}: {failure}");
                                        Console.ResetColor();
                                    },
                                    noItems =>
                                    {
                                        PrintTrace(noItems.Context.Trace, ConsoleColor.DarkGray);
                        
                                        Console.ForegroundColor = ConsoleColor.Yellow;
                                        Console.WriteLine($"    ○ PUSTE: {noItems.Reason}");
                                        Console.ResetColor();
                                    });
                            }
                        
                            private static void PrintTrace(IEnumerable<TraceEntry> trace, ConsoleColor color)
                            {
                                foreach (var entry in trace)
                                {
                                    Console.ForegroundColor = color;
                                    Console.Write($"    ✓ {entry.Step,-22}");
                                    Console.ResetColor();
                                    Console.WriteLine($" {entry.Duration.TotalMilliseconds,6:F1}ms  {entry.Detail}");
                                }
                            }
                        }
                    
                
Jest tutaj jedna duża róźnica
screenshot
screenshot
screenshot
Podobny przykład można napisać w C# 15 który ma już union types

Typ union

                    
                        using System.Diagnostics;
                        
                        namespace CsvBatchImporterCSharp15Union.Models;
                        
                        /// <summary>
                        /// Natywny union type z C# 15 preview.
                        /// Zastępuje OneOf&lt;ImportContext, Failure, NoItemToProcess&gt;.
                        /// </summary>
                        public union PipelineResult(ImportContext, Failure, NoItemToProcess);
                        
                        /// <summary>
                        /// Obiekt stanu przepływu — niesiony przez cały łańcuch Then().
                        ///
                        /// Trzy możliwe wyniki:
                        ///   ImportContext    — sukces, kontynuuj
                        ///   Failure          — błąd, przerwij
                        ///   NoItemToProcess  — pusta kolekcja po filtrze, przerwij (ale to nie błąd)
                        /// </summary>
                        public record ImportContext
                        {
                            public required string FilePath { get; init; }
                        
                            public FileInfo? DetectedFile { get; set; }
                            public string Extension { get; set; } = "";
                            public string Encoding { get; set; } = "UTF-8";
                            public char Delimiter { get; set; } = ',';
                            public string Schema { get; set; } = "Unknown";
                            public string[] Header { get; set; } = [];
                            public List<CsvRow> ParsedRows { get; set; } = [];
                            public List<CsvRow> ValidRows { get; set; } = [];
                            public List<RowError> ValidationErrors { get; set; } = [];
                            public List<MappedEntity> Entities { get; set; } = [];
                            public ImportResult? Result { get; set; }
                        
                            public Dictionary<string, string> Metadata { get; set; } = [];
                            public List<TraceEntry> Trace { get; set; } = [];
                        
                            public void AddTrace(string step, string detail, TimeSpan duration)
                                => Trace.Add(new TraceEntry(step, detail, duration));
                        
                            /// <summary>Punkt wejścia łańcucha — tworzy kontekst opakowany w PipelineResult.</summary>
                            public static Task<PipelineResult> Create(string filePath)
                                => Task.FromResult<PipelineResult>(new ImportContext { FilePath = filePath });
                        }
                        
                        public record CsvRow(int LineNumber, string[] Fields);
                        public record RowError(int LineNumber, string Reason);
                        public record MappedEntity(string Id, Dictionary<string, string> Fields);
                        public record ImportResult(string FilePath, int RowsSaved, int RowsSkipped);
                        public record TraceEntry(string Step, string Detail, TimeSpan Duration);
                        
                        public record FileError(string Path, string Reason);
                        public record BatchReport(int Total, int Succeeded, int Failed,
                            List<ImportResult> Imports, List<FileError> Errors);
                        
                        namespace CsvBatchImporterCSharp15Union.Models;
                        
                        /// <summary>
                        /// Bazowy typ błędu — wariant Failure w PipelineResult.
                        /// Pierwszy Failure zwrócony przez krok przerywa cały łańcuch.
                        /// </summary>
                        public record Failure(string Step, string Reason)
                        {
                            public override string ToString() => $"[{Step}] {Reason}";
                        }
                        
                        // Konkretne typy — można matchować pattern matchingiem
                        public record FileNotFoundFailure(string Path)
                            : Failure("OpenFile", $"File not found: {Path}");
                        
                        public record UnsupportedTypeFailure(string Extension)
                            : Failure("DetectType", $"Unsupported file type: '{Extension}'");
                        
                        public record DelimiterFailure()
                            : Failure("DetectDelimiter", "Cannot detect delimiter. Tried: , ; \\t |");
                        
                        public record UnknownSchemaFailure(string[] Columns)
                            : Failure("DetectSchema", $"Unknown schema. Columns: [{string.Join(", ", Columns)}]");
                        
                        public record EmptyFileFailure()
                            : Failure("ParseCsv", "File is empty or has only a header row.");
                        
                        public record ValidationFailure(int ErrorCount)
                            : Failure("ValidateRows", $"{ErrorCount} validation errors");
                        
                        public record MappingFailure(string Detail)
                            : Failure("MapEntities", Detail);
                        
                        public record SaveFailure(string Detail)
                            : Failure("SaveBatch", Detail);
                        
                        // ── Trzecia ścieżka — ani błąd, ani sukces ──────────────────────
                        
                        /// <summary>
                        /// Filtrowanie usunęło wszystkie wiersze — dalszy przepływ nie ma sensu.
                        /// To NIE jest błąd (plik był poprawny), to informacja o pustym wyniku.
                        /// </summary>
                        public record NoItemToProcess(string Step, string Reason, ImportContext Context)
                        {
                            public override string ToString() => $"[{Step}] {Reason}";
                        }
                    

                    
                

Uruchomienie pipeline'u z Union w C# 15

                    
                        using Microsoft.Extensions.DependencyInjection;
                        using CsvBatchImporterCSharp15Union;
                        using CsvBatchImporterCSharp15Union.Commands;
                        using CsvBatchImporterCSharp15Union.Models;
                        using CsvBatchImporterCSharp15Union.Pipeline;
                        
                        var services = CompositionRoot.CreateServices();
                        var input = SampleBatchFiles.Create();
                        
                        ConsoleBatchReporter.PrintStartup(input);
                        
                        // ═══════════════════════════════════════════════════
                        //  PIPELINE — cały przepływ w jednym miejscu
                        // ═══════════════════════════════════════════════════
                        //
                        //  Trzy ścieżki wynikowe:
                        //    ImportContext      → sukces, plik zaimportowany
                        //    Failure            → błąd (zły format, nieznany schemat, ...)
                        //    NoItemToProcess    → plik poprawny, ale 0 wierszy po filtrowaniu
                        //
                        
                        foreach (var path in input.ExistingPaths)
                        {
                            Console.WriteLine($"\n  Plik: {Path.GetFileName(path)}");
                        
                            await using var scope = services.CreateAsyncScope();
                            var sp = scope.ServiceProvider;
                        
                            var file = sp.GetRequiredService<FileCommands>();
                            var validation = sp.GetRequiredService<ValidationCommands>();
                            var mapping = sp.GetRequiredService<MappingCommands>();
                            var persistence = sp.GetRequiredService<PersistenceCommands>();
                            var orders = sp.GetRequiredService<OrderCommands>();
                            var sales = sp.GetRequiredService<SalesCommands>();
                            var users = sp.GetRequiredService<UserCommands>();
                            var products = sp.GetRequiredService<ProductCommands>();
                            var enrichment = sp.GetRequiredService<EnrichmentCommands>();
                        
                            var result = await ImportContext.Create(path)
                        
                                // wspólne kroki wejściowe
                                .ThenAsync(file.OpenFile)
                                .ThenAsync(file.ParseCsv)
                                .ThenAsync(validation.ValidateRows)              // → może: NoItemToProcess
                                .ThenAsync(validation.Deduplicate)                // → może: NoItemToProcess
                        
                                // rozgałęzienie per schemat
                                .IfThen(ctx => ctx.Schema == "Orders", orders.CalcTotals)
                                .IfThen(ctx => ctx.Schema == "Sales", sales.ConvertCurrency)
                                .IfThen(ctx => ctx.Schema == "Users", users.ValidateEmails)    // → może: NoItemToProcess
                                .IfThen(ctx => ctx.Schema == "Products", products.ValidateStock)
                        
                                // wspólne kroki wyjściowe
                                .ThenAsync(mapping.MapEntities)
                                .ThenWaitForAll("Enrichment", enrichment.GeoLookup, enrichment.AuditStamp)
                                .ThenAsync(persistence.SaveBatch);
                        
                            ConsoleBatchReporter.PrintItemResult(path, result);
                        }
                    
                    
                

C# 15 Union: FileCommands

                    
                        using System.Diagnostics;
                        using CsvBatchImporterCSharp15Union.Models;
                        
                        namespace CsvBatchImporterCSharp15Union.Commands;
                        
                        public class FileCommands
                        {
                            private static readonly string[] SupportedExtensions = [".csv", ".tsv"];
                            private static readonly Dictionary<string, char> DelimiterHints = new() { { ".tsv", '\t' } };
                        
                            private static readonly string[][] KnownSchemas =
                            [
                                ["id", "name", "email"],
                                ["id", "product", "quantity", "price"],
                                ["id", "title", "category", "stock"],
                                ["date", "region", "amount", "currency"]
                            ];
                            private static readonly string[] SchemaNames = ["Users", "Orders", "Products", "Sales"];
                        
                            public Task<PipelineResult> OpenFile(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                        
                                if (!File.Exists(ctx.FilePath))
                                    return Task.FromResult<PipelineResult>(
                                        new FileNotFoundFailure(ctx.FilePath));
                        
                                ctx.DetectedFile = new FileInfo(ctx.FilePath);
                                ctx.Extension = ctx.DetectedFile.Extension.ToLowerInvariant();
                        
                                if (!SupportedExtensions.Contains(ctx.Extension))
                                    return Task.FromResult<PipelineResult>(
                                        new UnsupportedTypeFailure(ctx.Extension));
                        
                                var bytes = File.ReadAllBytes(ctx.FilePath);
                                ctx.Encoding = DetectEncoding(bytes);
                        
                                if (DelimiterHints.TryGetValue(ctx.Extension, out var hint))
                                    ctx.Delimiter = hint;
                                else
                                {
                                    var firstLine = File.ReadLines(ctx.FilePath).FirstOrDefault() ?? "";
                                    var delim = InferDelimiter(firstLine);
                                    if (!delim.HasValue)
                                        return Task.FromResult<PipelineResult>(
                                            new DelimiterFailure());
                                    ctx.Delimiter = delim.Value;
                                }
                        
                                ctx.Header = File.ReadLines(ctx.FilePath)
                                    .FirstOrDefault()?.Split(ctx.Delimiter)
                                    .Select(c => c.Trim().ToLowerInvariant()).ToArray() ?? [];
                        
                                for (var i = 0; i < KnownSchemas.Length; i++)
                                {
                                    if (KnownSchemas[i].All(col => ctx.Header.Contains(col)))
                                    {
                                        ctx.Schema = SchemaNames[i];
                                        sw.Stop();
                                        ctx.AddTrace("OpenFile", $"schema={ctx.Schema}, delim='{ctx.Delimiter}'", sw.Elapsed);
                                        return Task.FromResult<PipelineResult>(ctx);
                                    }
                                }
                        
                                return Task.FromResult<PipelineResult>(
                                    new UnknownSchemaFailure(ctx.Header));
                            }
                        
                            public Task<PipelineResult> ParseCsv(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                var lines = File.ReadAllLines(ctx.FilePath);
                                if (lines.Length < 2)
                                    return Task.FromResult<PipelineResult>(
                                        new EmptyFileFailure());
                        
                                ctx.ParsedRows = lines.Skip(1)
                                    .Select((line, i) => new CsvRow(i + 2, line.Split(ctx.Delimiter))).ToList();
                        
                                sw.Stop();
                                ctx.AddTrace("ParseCsv", $"{ctx.ParsedRows.Count} data rows", sw.Elapsed);
                                return Task.FromResult<PipelineResult>(ctx);
                            }
                        
                            private static string DetectEncoding(byte[] bytes)
                            {
                                if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) return "UTF-8 BOM";
                                if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) return "UTF-16 LE";
                                return bytes.Any(b => b > 0x7F) ? "UTF-8 (extended)" : "UTF-8";
                            }
                        
                            private static char? InferDelimiter(string line)
                            {
                                char[] candidates = [',', ';', '\t', '|'];
                                return candidates.OrderByDescending(c => line.Count(ch => ch == c))
                                    .FirstOrDefault(c => line.Contains(c));
                            }
                        }
                    
                

C# 15 Union: ProcessingCommands

                    
                        using System.Diagnostics;
                        using CsvBatchImporterCSharp15Union.Models;
                        
                        namespace CsvBatchImporterCSharp15Union.Commands;
                        
                        public class ValidationCommands
                        {
                            public Task<PipelineResult> ValidateRows(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                var headerCount = ctx.Header.Length;
                                var errors = new List<RowError>();
                                var valid = new List<CsvRow>();
                        
                                foreach (var row in ctx.ParsedRows)
                                {
                                    var rowErrors = new List<string>();
                                    if (row.Fields.Length != headerCount)
                                        rowErrors.Add($"Expected {headerCount} columns, got {row.Fields.Length}");
                        
                                    var empty = row.Fields.Select((f, i) => (f, i))
                                        .Where(x => string.IsNullOrWhiteSpace(x.f))
                                        .Select(x => x.i + 1).ToList();
                                    if (empty.Count > 0)
                                        rowErrors.Add($"Empty field(s) at columns: {string.Join(", ", empty)}");
                        
                                    if (rowErrors.Count > 0)
                                        errors.AddRange(rowErrors.Select(e => new RowError(row.LineNumber, e)));
                                    else
                                        valid.Add(row);
                                }
                        
                                ctx.ValidRows = valid;
                                ctx.ValidationErrors = errors;
                        
                                sw.Stop();
                                ctx.AddTrace("ValidateRows",
                                    errors.Count > 0 ? $"{valid.Count} ok, {errors.Count} errors" : $"all {valid.Count} valid",
                                    sw.Elapsed);
                        
                                // TUTAJ: jeśli walidacja odrzuciła WSZYSTKIE wiersze → NoItemToProcess
                                if (valid.Count == 0)
                                    return Task.FromResult<PipelineResult>(
                                        new NoItemToProcess("ValidateRows",
                                            $"All {ctx.ParsedRows.Count} rows failed validation ({errors.Count} errors)",
                                            ctx));
                        
                                return Task.FromResult<PipelineResult>(ctx);
                            }
                        
                            public Task<PipelineResult> Deduplicate(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                var before = ctx.ValidRows.Count;
                        
                                ctx.ValidRows = ctx.ValidRows
                                    .GroupBy(r => string.Join("|", r.Fields))
                                    .Select(g => g.First()).ToList();
                        
                                var removed = before - ctx.ValidRows.Count;
                                sw.Stop();
                                ctx.AddTrace("Deduplicate", $"{ctx.ValidRows.Count} unique ({removed} removed)", sw.Elapsed);
                        
                                // Deduplikacja usunęła wszystko → NoItemToProcess
                                if (ctx.ValidRows.Count == 0)
                                    return Task.FromResult<PipelineResult>(
                                        new NoItemToProcess("Deduplicate",
                                            $"All {before} rows were duplicates", ctx));
                        
                                return Task.FromResult<PipelineResult>(ctx);
                            }
                        }
                        
                        public class MappingCommands
                        {
                            public Task<PipelineResult> MapEntities(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                var header = File.ReadLines(ctx.FilePath)
                                    .First().Split(ctx.Delimiter).Select(h => h.Trim()).ToArray();
                        
                                ctx.Entities = ctx.ValidRows.Select(row =>
                                {
                                    var fields = header.Zip(row.Fields.Select(f => f.Trim()))
                                        .ToDictionary(p => p.First, p => p.Second);
                                    return new MappedEntity(
                                        fields.GetValueOrDefault("id", Guid.NewGuid().ToString()), fields);
                                }).ToList();
                        
                                sw.Stop();
                                ctx.AddTrace("MapEntities", $"{ctx.Entities.Count} entities → '{ctx.Schema}'", sw.Elapsed);
                                return Task.FromResult<PipelineResult>(ctx);
                            }
                        }
                        
                        public class PersistenceCommands
                        {
                            public async Task<PipelineResult> SaveBatch(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                await Task.Delay(30);
                        
                                ctx.Result = new ImportResult(ctx.FilePath, ctx.Entities.Count, ctx.ValidationErrors.Count);
                                sw.Stop();
                                ctx.AddTrace("SaveBatch", $"{ctx.Result.RowsSaved} saved, {ctx.Result.RowsSkipped} skipped", sw.Elapsed);
                                return ctx;
                            }
                        }
                    
                

C# 15 Union: SchemaCommands

                    
                        using System.Diagnostics;
                        using CsvBatchImporterCSharp15Union.Models;
                        
                        namespace CsvBatchImporterCSharp15Union.Commands;
                        
                        public class OrderCommands
                        {
                            public Task<PipelineResult> CalcTotals(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                var qtyIdx = Array.IndexOf(ctx.Header, "quantity");
                                var priceIdx = Array.IndexOf(ctx.Header, "price");
                        
                                var total = 0m;
                                foreach (var row in ctx.ValidRows)
                                    if (decimal.TryParse(row.Fields[qtyIdx], out var qty) &&
                                        decimal.TryParse(row.Fields[priceIdx], out var price))
                                        total += qty * price;
                        
                                ctx.Metadata["OrdersTotal"] = total.ToString("F2");
                                sw.Stop();
                                ctx.AddTrace("CalcOrderTotals", $"total={total:F2}", sw.Elapsed);
                                return Task.FromResult<PipelineResult>(ctx);
                            }
                        }
                        
                        public class SalesCommands
                        {
                            private static readonly Dictionary<string, decimal> Rates = new()
                                { { "USD", 1.0m }, { "EUR", 1.08m }, { "GBP", 1.27m }, { "PLN", 0.25m } };
                        
                            public Task<PipelineResult> ConvertCurrency(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                var amountIdx = Array.IndexOf(ctx.Header, "amount");
                                var currencyIdx = Array.IndexOf(ctx.Header, "currency");
                                var converted = 0;
                        
                                foreach (var row in ctx.ValidRows)
                                {
                                    var currency = row.Fields[currencyIdx].Trim().ToUpperInvariant();
                                    if (currency != "USD" && decimal.TryParse(row.Fields[amountIdx], out var amount) &&
                                        Rates.TryGetValue(currency, out var rate))
                                    {
                                        var fields = row.Fields.ToArray();
                                        fields[amountIdx] = (amount * rate).ToString("F2");
                                        fields[currencyIdx] = "USD";
                                        ctx.ValidRows[ctx.ValidRows.IndexOf(row)] = new CsvRow(row.LineNumber, fields);
                                        converted++;
                                    }
                                }
                        
                                ctx.Metadata["CurrencyConverted"] = converted.ToString();
                                sw.Stop();
                                ctx.AddTrace("ConvertCurrency", $"{converted} rows → USD", sw.Elapsed);
                                return Task.FromResult<PipelineResult>(ctx);
                            }
                        }
                        
                        public class UserCommands
                        {
                            public Task<PipelineResult> ValidateEmails(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                var emailIdx = Array.IndexOf(ctx.Header, "email");
                                var valid = new List<CsvRow>();
                        
                                foreach (var row in ctx.ValidRows)
                                {
                                    var email = row.Fields[emailIdx].Trim();
                                    if (!email.Contains('@') || !email.Contains('.'))
                                        ctx.ValidationErrors.Add(new RowError(row.LineNumber, $"Invalid email: '{email}'"));
                                    else
                                        valid.Add(row);
                                }
                        
                                ctx.ValidRows = valid;
                                sw.Stop();
                                ctx.AddTrace("ValidateEmails", $"{valid.Count} valid emails", sw.Elapsed);
                        
                                // Wszystkie emaile niepoprawne → NoItemToProcess
                                if (valid.Count == 0)
                                    return Task.FromResult<PipelineResult>(
                                        new NoItemToProcess("ValidateEmails", "All emails invalid", ctx));
                        
                                return Task.FromResult<PipelineResult>(ctx);
                            }
                        }
                        
                        public class ProductCommands
                        {
                            public Task<PipelineResult> ValidateStock(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                var stockIdx = Array.IndexOf(ctx.Header, "stock");
                                var corrected = 0;
                        
                                foreach (var row in ctx.ValidRows)
                                    if (int.TryParse(row.Fields[stockIdx].Trim(), out var stock) && stock < 0)
                                    {
                                        var fields = row.Fields.ToArray();
                                        fields[stockIdx] = "0";
                                        ctx.ValidRows[ctx.ValidRows.IndexOf(row)] = new CsvRow(row.LineNumber, fields);
                                        corrected++;
                                    }
                        
                                ctx.Metadata["StockCorrected"] = corrected.ToString();
                                sw.Stop();
                                ctx.AddTrace("ValidateStock", $"{corrected} negative stocks → 0", sw.Elapsed);
                                return Task.FromResult<PipelineResult>(ctx);
                            }
                        }
                        
                        public class EnrichmentCommands
                        {
                            public async Task<PipelineResult> GeoLookup(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                var regions = new Dictionary<string, string>
                                    { { "North", "52.23°N" }, { "South", "50.06°N" }, { "East", "51.25°N" }, { "West", "51.10°N" } };
                        
                                await Task.Delay(20);
                                var matched = 0;
                                foreach (var e in ctx.Entities)
                                    if (e.Fields.TryGetValue("region", out var r) && regions.TryGetValue(r, out var c))
                                        { e.Fields["geo_coords"] = c; matched++; }
                        
                                ctx.Metadata["GeoMatched"] = matched.ToString();
                                sw.Stop();
                                ctx.AddTrace("GeoLookup", $"{matched} geocoded", sw.Elapsed);
                                return ctx;
                            }
                        
                            public Task<PipelineResult> AuditStamp(ImportContext ctx)
                            {
                                var sw = Stopwatch.StartNew();
                                var stamp = DateTime.UtcNow.ToString("O");
                                foreach (var e in ctx.Entities)
                                    { e.Fields["_imported_at"] = stamp; e.Fields["_source_file"] = Path.GetFileName(ctx.FilePath); }
                        
                                ctx.Metadata["AuditStamp"] = stamp;
                                sw.Stop();
                                ctx.AddTrace("AuditStamp", $"stamped {ctx.Entities.Count} entities", sw.Elapsed);
                                return Task.FromResult<PipelineResult>(ctx);
                            }
                        }
                    
                

C# 15 Union: PrintItemResult

                    
                        using CsvBatchImporterCSharp15Union.Models;
                        
                        namespace CsvBatchImporterCSharp15Union;
                        
                        public static class ConsoleBatchReporter
                        {
                            public static void PrintStartup(BatchInput input)
                            {
                                Console.WriteLine("╔══════════════════════════════════════════════╗");
                                Console.WriteLine("║   CSV Batch Importer  [C# 15 Union]         ║");
                                Console.WriteLine("╚══════════════════════════════════════════════╝");
                        
                                if (input.MissingPaths.Length > 0)
                                {
                                    Console.ForegroundColor = ConsoleColor.Yellow;
                                    Console.WriteLine("\nPominięte (nie znaleziono):");
                                    foreach (var missing in input.MissingPaths)
                                        Console.WriteLine($"  ✗ {Path.GetFileName(missing)}");
                                    Console.ResetColor();
                                }
                        
                                Console.WriteLine($"\nBatch: {input.ExistingPaths.Length} plików\n");
                                Console.WriteLine("─────────────────────────────────────────────");
                            }
                        
                            public static void PrintItemResult(string path, PipelineResult result)
                            {
                                switch (result)
                                {
                                    case ImportContext ctx:
                                    {
                                        PrintTrace(ctx.Trace, ConsoleColor.Green);
                        
                                        var skip = ctx.Result!.RowsSkipped > 0 ? $" ({ctx.Result.RowsSkipped} pominięto)" : "";
                                        Console.ForegroundColor = ConsoleColor.Green;
                                        Console.WriteLine($"    + OK: {ctx.Result.RowsSaved} wierszy{skip}");
                                        Console.ResetColor();
                                        break;
                                    }
                        
                                    case Failure failure:
                                    {
                                        Console.ForegroundColor = ConsoleColor.Red;
                                        Console.WriteLine($"    ✗ {Path.GetFileName(path)}: [{failure.Step}] {failure.Reason}");
                                        Console.ResetColor();
                                        break;
                                    }
                        
                                    case NoItemToProcess noItems:
                                    {
                                        PrintTrace(noItems.Context.Trace, ConsoleColor.DarkGray);
                                        Console.ForegroundColor = ConsoleColor.Yellow;
                                        Console.WriteLine($"    ○ PUSTE: {noItems.Reason}");
                                        Console.ResetColor();
                                        break;
                                    }
                                }
                            }
                        
                            private static void PrintTrace(IEnumerable<TraceEntry> trace, ConsoleColor color)
                            {
                                foreach (var entry in trace)
                                {
                                    Console.ForegroundColor = color;
                                    Console.Write($"    ✓ {entry.Step,-22}");
                                    Console.ResetColor();
                                    Console.WriteLine($" {entry.Duration.TotalMilliseconds,6:F1}ms  {entry.Detail}");
                                }
                            }
                        }
                    
                

Extension methods z Union w C# 15

                    
                        using CsvBatchImporterCSharp15Union.Models;
                        
                        namespace CsvBatchImporterCSharp15Union.Pipeline;
                        
                        /// <summary>
                        /// Extension methods dla PipelineResult union — trzy ścieżki:
                        ///   ImportContext     = sukces → kontynuuj łańcuch
                        ///   Failure           = failure → przerwij (błąd)
                        ///   NoItemToProcess  = empty → przerwij (brak elementów, nie błąd)
                        ///
                        /// Wzorowane na monadycznym chainingu, ale bez zewnętrznego pakietu.
                        /// </summary>
                        public static class UnionPipelineExtensions
                        {
                            /// <summary>
                            /// Then — wykonaj następny krok jeśli sukces, inaczej propaguj failure/empty.
                            /// </summary>
                            public static async Task<PipelineResult> ThenAsync(
                                this Task<PipelineResult> currentResult,
                                Func<ImportContext, Task<PipelineResult>> nextJob)
                            {
                                var result = await currentResult;
                        
                                return result switch
                                {
                                    ImportContext ctx => await nextJob(ctx),
                                    Failure failure => failure,
                                    NoItemToProcess noItems => noItems,
                                    null => throw new InvalidOperationException("PipelineResult cannot be null.")
                                };
                            }
                        
                            /// <summary>
                            /// IfThen — wykonaj krok TYLKO gdy predykat == true i jest sukces.
                            /// </summary>
                            public static async Task<PipelineResult> IfThen(
                                this Task<PipelineResult> currentResult,
                                Func<ImportContext, bool> condition,
                                Func<ImportContext, Task<PipelineResult>> nextJob)
                            {
                                var result = await currentResult;
                        
                                return result switch
                                {
                                    ImportContext ctx when condition(ctx) => await nextJob(ctx),
                                    ImportContext ctx => ctx,
                                    Failure failure => failure,
                                    NoItemToProcess noItems => noItems,
                                    null => throw new InvalidOperationException("PipelineResult cannot be null.")
                                };
                            }
                        
                            /// <summary>
                            /// ThenWaitForAll — wykonaj kilka niezależnych kroków równolegle jeśli bieżący wynik to sukces.
                            /// </summary>
                            public static async Task<PipelineResult> ThenWaitForAll(
                                this Task<PipelineResult> currentResult,
                                string name,
                                params Func<ImportContext, Task<PipelineResult>>[] nextJobs)
                            {
                                var result = await currentResult;
                        
                                return result switch
                                {
                                    ImportContext ctx => await RunAll(ctx, name, nextJobs),
                                    Failure failure => failure,
                                    NoItemToProcess noItems => noItems,
                                    null => throw new InvalidOperationException("PipelineResult cannot be null.")
                                };
                            }
                        
                            /// <summary>
                            /// Sync Then — dla kroków synchronicznych.
                            /// </summary>
                            public static async Task<PipelineResult> Then(
                                this Task<PipelineResult> currentResult,
                                Func<ImportContext, PipelineResult> nextJob)
                            {
                                var result = await currentResult;
                        
                                return result switch
                                {
                                    ImportContext ctx => nextJob(ctx),
                                    Failure failure => failure,
                                    NoItemToProcess noItems => noItems,
                                    null => throw new InvalidOperationException("PipelineResult cannot be null.")
                                };
                            }
                        
                            private static async Task<PipelineResult> RunAll(
                                ImportContext ctx,
                                string name,
                                Func<ImportContext, Task<PipelineResult>>[] nextJobs)
                            {
                                if (nextJobs.Length == 0)
                                    return ctx;
                        
                                var results = await Task.WhenAll(nextJobs.Select(nextJob => nextJob(ctx)));
                        
                                foreach (var result in results)
                                {
                                    switch (result)
                                    {
                                        case Failure failure:
                                            return failure;
                                        case NoItemToProcess noItems:
                                            return noItems;
                                        case ImportContext:
                                            break;
                                        case null:
                                            throw new InvalidOperationException($"{name} returned null PipelineResult.");
                                    }
                                }
                        
                                return ctx;
                            }
                        }
                    

                    
                
  1. Podobne rozwiązania tych problemów widziałem już
  1. MAF ma dwa rodzaje klocków:
  1. MAF a kolejność executorów w workflow

MAF: workflow agentów w kodzie

                    
                        using AiContentTeam;
                        using AiContentTeam.Guardrails;
                        using AiContentTeam.Middleware;
                        using Microsoft.Agents.AI;
                        using Microsoft.Agents.AI.DevUI;
                        using Microsoft.Agents.AI.Hosting;
                        using Microsoft.Agents.AI.Workflows;
                        using Microsoft.Extensions.AI;
                        using ModelContextProtocol.Client;

                        var builder = WebApplication.CreateBuilder(args);

                        // ── IChatClient ───────────────────────────────────────────────────────────────

                        IChatClient chatClient = new Anthropic.AnthropicClient()
                            .AsIChatClient("claude-haiku-4-5");

                        builder.Services.AddChatClient(chatClient);

                        // ── MCP tools ────────────────────────────────────────────────────────────────

                        builder.Services.AddSingleton<IList<McpClientTool>>(sp =>
                        {
                            return Task.Run(async () =>
                            {
                                var mcpClient = await McpClient.CreateAsync(
                                    new HttpClientTransport(new HttpClientTransportOptions
                                    {
                                        Endpoint = new Uri("http://localhost:2001/mcp")
                                    }));
                                return await mcpClient.ListToolsAsync();
                            }).GetAwaiter().GetResult();
                        });

                        // ── Skills provider ───────────────────────────────────────────────────────────

                        #pragma warning disable MAAI001
                        builder.Services.AddSingleton(
                            new AgentSkillsProvider(Path.Combine(AppContext.BaseDirectory, "skills")));
                        #pragma warning restore MAAI001

                        // ── Guardrails ────────────────────────────────────────────────────────────────

                        var contentPolicy = new ContentPolicyGuardrail();
                        var brandSafety = new BrandSafetyGuardrail(
                            competitors: ["CompetitorA", "CompetitorB"],
                            requireCTA: true);
                        var outputLength = new OutputLengthGuardrail(maxChars: 3000);

                        // ── Agenty w DI ───────────────────────────────────────────────────────────────

                        builder.Services.AddKeyedSingleton<AIAgent>("Researcher", (sp, _) =>
                        {
                            var chat = sp.GetRequiredService<IChatClient>();
                            var mcpTools = sp.GetRequiredService<IList<McpClientTool>>();

                            return GuardrailFactory.WrapWithFullPipeline(
                                new ChatClientAgent(chat, new ChatClientAgentOptions
                                {
                                    Name = "Researcher",
                                    ChatOptions = new()
                                    {
                                        Instructions = """
                                            Jesteś research agentem. Twoim JEDYNYM zadaniem jest:
                                            1. UŻYJ NARZĘDZIA GetYouTubeTranscript aby pobrać transkrypcję z podanego URL
                                            2. Na podstawie wyniku wyodrębnij dokładnie 5 kluczowych tez jako bullet pointy

                                            ZASADY:
                                            - ZAWSZE wywołuj narzędzie GetYouTubeTranscript
                                            - Odpowiadaj po polsku
                                            - Każdą tezę pisz w osobnej linii zaczynającej się od "• "
                                            """,
                                        Tools = [.. mcpTools]
                                    }
                                }),
                                enableLogging: true,
                                enableRetry: true);
                        });

                        builder.Services.AddKeyedSingleton<AIAgent>("Copywriter", (sp, _) =>
                        {
                            var chat = sp.GetRequiredService<IChatClient>();
                            var skills = sp.GetRequiredService<AgentSkillsProvider>();

                            return GuardrailFactory.WrapWithFullPipeline(
                                new ChatClientAgent(chat, new ChatClientAgentOptions
                                {
                                    Name = "Copywriter",
                                    AIContextProviders = [skills],
                                    ChatOptions = new()
                                    {
                                        Instructions = """
                                            Jesteś copywriterem specjalizującym się w treściach dla .NET developerów.
                                            Na podstawie tez od Researchera napisz post na LinkedIn.

                                            ZASADY:
                                            - 150-250 słów, max 3 emoji
                                            - Mocny hook w 1. zdaniu
                                            - Zakończ pytaniem do społeczności
                                            - Napisz TYLKO treść posta, bez meta-komentarza
                                            - Odpowiadaj po polsku
                                            """
                                    }
                                }),
                                enableLogging: true,
                                enableRetry: true,
                                contentPolicy, brandSafety, outputLength);
                        });

                        builder.Services.AddKeyedSingleton<AIAgent>("Strateg", (sp, _) =>
                        {
                            var chat = sp.GetRequiredService<IChatClient>();
                            var skills = sp.GetRequiredService<AgentSkillsProvider>();

                            return GuardrailFactory.WrapWithFullPipeline(
                                new ChatClientAgent(chat, new ChatClientAgentOptions
                                {
                                    Name = "Strateg",
                                    AIContextProviders = [skills],
                                    ChatOptions = new()
                                    {
                                        Instructions = """
                                            Jesteś strategiem social media dla społeczności .NET developerów.
                                            Przeanalizuj temat i odpowiedz STRUKTURALNIE:

                                            ANALIZA TRENDÓW: [2-3 zdania]
                                            REKOMENDOWANY HOOK: [jedno zdanie]
                                            SUGEROWANE HASHTAGI: [max 5, format #tag]
                                            UWAGI O ZAANGAŻOWANIU: [1-2 zdania]

                                            Odpowiadaj po polsku.
                                            """,
                                        Tools = [AIFunctionFactory.Create(GetLinkedInTrends)]
                                    }
                                }),
                                enableLogging: true,
                                enableRetry: true,
                                outputLength);
                        });

                        builder.Services.AddKeyedSingleton<AIAgent>("Redaktor", (sp, _) =>
                        {
                            var chat = sp.GetRequiredService<IChatClient>();

                            return GuardrailFactory.WrapWithFullPipeline(
                                new ChatClientAgent(chat, new ChatClientAgentOptions
                                {
                                    Name = "Redaktor",
                                    ChatOptions = new()
                                    {
                                        Instructions = """
                                            Jesteś redaktorem naczelnym. Dostałeś draft i analizę strategiczną.
                                            Zastosuj rekomendacje, popraw błędy językowe.
                                            Napisz TYLKO finalną treść posta po polsku.
                                            """
                                    }
                                }),
                                enableLogging: true,
                                enableRetry: true,
                                contentPolicy, brandSafety, outputLength);
                        });

                        // ══════════════════════════════════════════════════════════════════════════════
                        //  content-pipeline-dev — BEZ HITL, do DevUI
                        // ══════════════════════════════════════════════════════════════════════════════

                        builder.AddWorkflow("content-pipeline-dev", (sp, key) =>
                        {
                            var chat = sp.GetRequiredService<IChatClient>();
                            var researcherAgent = sp.GetRequiredKeyedService<AIAgent>("Researcher");
                            var copywriterAgent = sp.GetRequiredKeyedService<AIAgent>("Copywriter");
                            var strategAgent = sp.GetRequiredKeyedService<AIAgent>("Strateg");
                            var redaktorAgent = sp.GetRequiredKeyedService<AIAgent>("Redaktor");

                            // Entry point: ChatClientAgent (DevUI wymaga tego)
                            var entryAgent = new ChatClientAgent(chat, new ChatClientAgentOptions
                            {
                                Name = "InputParser",
                                ChatOptions = new()
                                {
                                    Instructions = """
                                        Jesteś parserem inputu. Użytkownik poda URL YouTube lub temat.
                                        Wyodrębnij URL i powtórz go dokładnie.
                                        Jeśli nie ma URL, powtórz temat dosłownie.
                                        Nie dodawaj żadnych komentarzy ani wyjaśnień.
                                        Odpowiedz JEDNYM zdaniem lub samym URL-em.
                                        """
                                }
                            });

                            // Bridge: string → ResearchRequest (bo ResearcherExecutor oczekuje ResearchRequest)
                            var inputBridge = new InputBridgeExecutor();

                            // Executory
                            var researcher = new ResearcherExecutor(researcherAgent);
                            var copywriter = new CopywriterExecutor(copywriterAgent);
                            var strateg = new StrategExecutor(strategAgent);
                            var aggregator = new AggregatorExecutor();
                            var redaktor = new RedaktorExecutor(redaktorAgent);
                            var publisher = new PublishExecutor();

                            IEnumerable<ExecutorBinding> fanInSources = [copywriter, strateg];

                            // Graf: prosty, bez HITL
                            // W workflow zamiast:
                            //   entryAgent → inputBridge → researcher(executor)
                            // Daj:
                            //   entryAgent → researcherAgent(ChatClientAgent) → copywriter + strateg

                            return new WorkflowBuilder(entryAgent)
                                .WithName(key)
                                .AddEdge(entryAgent, researcherAgent)    // ← sam agent, nie executor
                                .AddFanOutEdge(researcherAgent, [copywriterAgent, strategAgent])
                                .AddFanInBarrierEdge(
                                    (IEnumerable<ExecutorBinding>)[copywriterAgent, strategAgent],
                                    redaktorAgent)
                                .WithOutputFrom(redaktorAgent)
                                .Build();
                        }).AddAsAIAgent();

                        // ── OpenAI + DevUI ────────────────────────────────────────────────────────────

                        builder.Services.AddOpenAIResponses();
                        builder.Services.AddOpenAIConversations();
                        builder.Logging.SetMinimumLevel(LogLevel.Debug);

                        // ── Build & Map ───────────────────────────────────────────────────────────────

                        var app = builder.Build();

                        // Middleware na wyjątki — PRZED endpointami
                        app.Use(async (context, next) =>
                        {
                            try
                            {
                                await next();
                            }
                            catch (Exception ex)
                            {
                                Console.ForegroundColor = ConsoleColor.Red;
                                Console.WriteLine($"[EXCEPTION] {ex}");
                                Console.ResetColor();
                                throw;
                            }
                        });

                        app.MapOpenAIResponses();
                        app.MapOpenAIConversations();

                        if (app.Environment.IsDevelopment())
                        {
                            app.MapDevUI();
                        }

                        app.MapGet("/health", () => Results.Ok(new
                        {
                            status = "healthy",
                            pipeline = "AiContentTeam — DevUI",
                            devui = "/devui",
                            features = new[] { "No HITL (dev)", "Middleware", "Guardrails", "Fan-out/Fan-in" }
                        }));

                        // ── Start ─────────────────────────────────────────────────────────────────────

                        app.Urls.Add("http://localhost:5050");

                        Console.ForegroundColor = ConsoleColor.Cyan;
                        Console.WriteLine("╔════════════════════════════════════════════════════════════╗");
                        Console.WriteLine("║  🚀 AiContentTeam — DevUI Server                         ║");
                        Console.WriteLine("║                                                           ║");
                        Console.WriteLine("║  DevUI:       http://localhost:5050/devui                 ║");
                        Console.WriteLine("║  Health:      http://localhost:5050/health                ║");
                        Console.WriteLine("║                                                           ║");
                        Console.WriteLine("║  Workflow:    content-pipeline-dev (no HITL)              ║");
                        Console.WriteLine("║                                                           ║");
                        Console.WriteLine("║  Pipeline:                                                ║");
                        Console.WriteLine("║    InputParser → Bridge → Researcher                     ║");
                        Console.WriteLine("║    → Copywriter + Strateg (fan-out)                      ║");
                        Console.WriteLine("║    → Aggregator → Redaktor → Publisher                   ║");
                        Console.WriteLine("║                                                           ║");
                        Console.WriteLine("║  Middleware: Logging, Retry, Guardrails, Function Audit   ║");
                        Console.WriteLine("╚════════════════════════════════════════════════════════════╝");
                        Console.ResetColor();

                        app.Run();

                        // ── Tool helper ───────────────────────────────────────────────────────────────

                        [System.ComponentModel.Description("Sprawdza popularne trendy na LinkedIn w kategorii tech/AI")]
                        static string GetLinkedInTrends(
                            [System.ComponentModel.Description("Kategoria, np. 'AI', '.NET'")] string category)
                        {
                            return $"""
                                Top trendy w kategorii "{category}" (symulacja):
                                1. Microsoft Agent Framework — wysoka aktywność
                                2. .NET 10 Preview — umiarkowane zaangażowanie
                                3. Multi-agent orchestration — rosnący trend
                                """;
                        }

                        // ══════════════════════════════════════════════════════════════════════════════
                        //  InputBridgeExecutor — konwertuje string z ChatClientAgent na ResearchRequest
                        // ══════════════════════════════════════════════════════════════════════════════

                        /// <summary>
                        /// Bridge między ChatClientAgent (zwraca string) a ResearcherExecutor (oczekuje ResearchRequest).
                        /// Potrzebny bo DevUI wymaga ChatClientAgent jako entry point workflow.
                        /// Produkcyjny Program.cs (konsolowy) nie potrzebuje tego — tam InputParserExecutor
                        /// jest entry pointem i bezpośrednio tworzy ResearchRequest.
                        /// </summary>
                        public partial class InputBridgeExecutor() : Executor<string, ResearchRequest>("InputBridge")
                        {
                            public override ValueTask<ResearchRequest> HandleAsync(
                                string message,
                                IWorkflowContext context,
                                CancellationToken cancellationToken = default)
                            {
                                Console.WriteLine($"[InputBridge] Parsing: '{message}'");

                                return ValueTask.FromResult(new ResearchRequest(
                                    YouTubeUrl: message.Trim(),
                                    TargetAudience: "polscy .NET developerzy, seniorzy i architekci"));
                            }
                        }
                    
                
screenshot
A jeśli to za mało?
Zawsze jest też maszyna stanów ze "Stateless"
Albo maszyna stanów na pełnym gazie poprzez rozproszone zdarzenia
  1. Forma rozproszonej maszyny stanów

Ale jeśli chcesz:

widzieć przepływ procesu w jednym miejscu, bez sterowania wyjątkami i bez eventów udających kontrolę flow, to już wiesz, jak to zrobić

Jeśli Twój proces ma:

Widoczny flow, jawne rezultaty, wymienne kroki, eventy tylko jako skutki uboczne, osobny batch/item context, to jesteś blisko czytelnego kodu.

screenshot
  1. Link do tej prezentacji

Dziękuję!