Pular para conteúdo

Pipeline Pattern

Padrão para fluxos complexos com múltiplas etapas sequenciais.

Quando Usar

  • Fluxos com 3+ etapas que modificam estado compartilhado
  • Lógica complexa que precisa ser testada em partes
  • Operações que podem crescer (novos steps no futuro)

Não usar para: operações simples, CRUD básico, queries.


Estrutura Base

Localização: Ciba.Api/_Shared/Pipelines/

// IPipelineContext.cs
public interface IPipelineContext { }

// IPipelineStep.cs
public interface IPipelineStep<TContext> where TContext : IPipelineContext
{
    Task ExecuteAsync(TContext context, CancellationToken ct = default);
}

// IPipeline.cs
public interface IPipeline<TContext> where TContext : IPipelineContext
{
    IPipeline<TContext> AddStep<TStep>() where TStep : IPipelineStep<TContext>;
    Task ExecuteAsync(TContext context, CancellationToken ct = default);
}

// Pipeline.cs
public class Pipeline<TContext> : IPipeline<TContext> where TContext : IPipelineContext
{
    private readonly IServiceProvider _sp;
    private readonly List<Type> _steps = [];

    public Pipeline(IServiceProvider sp) => _sp = sp;

    public IPipeline<TContext> AddStep<TStep>() where TStep : IPipelineStep<TContext>
    {
        _steps.Add(typeof(TStep));
        return this;
    }

    public async Task ExecuteAsync(TContext context, CancellationToken ct = default)
    {
        foreach (var stepType in _steps)
        {
            var step = (IPipelineStep<TContext>)_sp.GetRequiredService(stepType);
            await step.ExecuteAsync(context, ct);
        }
    }
}

Organização de Pastas

Ciba.Api/
└── Features/
    └── {Recurso}/
        └── {Ação}/
            └── Pipelines/
                └── {NomePipeline}/
                    ├── {NomePipeline}Context.cs
                    ├── Step1.cs
                    ├── Step2.cs
                    └── Step3.cs

Exemplo Prático

Contexto

public class ProcessarMensagemContext : IPipelineContext
{
    // Inputs (imutáveis)
    public required MensagemRecebida Mensagem { get; init; }
    public required Guid TenantId { get; init; }

    // Outputs (modificados pelos steps)
    public Conversa? Conversa { get; set; }
    public string? RespostaIA { get; set; }
    public bool MensagemEnviada { get; set; }

    // Erros
    public List<string> Erros { get; } = [];
    public bool TemErros => Erros.Count > 0;
}

Step

public class ValidarConversaStep(IConversaRepository repo)
    : IPipelineStep<ProcessarMensagemContext>
{
    public async Task ExecuteAsync(ProcessarMensagemContext ctx, CancellationToken ct)
    {
        var conversa = await repo.ObterPorContatoAsync(ctx.Mensagem.ContatoId, ct);

        if (conversa is null)
        {
            ctx.Erros.Add("Conversa não encontrada");
            return;
        }

        ctx.Conversa = conversa;
    }
}

Handler

public class ProcessarMensagemHandler(IPipeline<ProcessarMensagemContext> pipeline)
    : IRequestHandler<ProcessarMensagemCommand, Result>
{
    public async Task<Result> Handle(ProcessarMensagemCommand cmd, CancellationToken ct)
    {
        var ctx = new ProcessarMensagemContext
        {
            Mensagem = cmd.Mensagem,
            TenantId = cmd.TenantId
        };

        await pipeline
            .AddStep<ValidarConversaStep>()
            .AddStep<EnriquecerContextoStep>()
            .AddStep<GerarRespostaIAStep>()
            .AddStep<EnviarMensagemStep>()
            .ExecuteAsync(ctx, ct);

        return ctx.TemErros
            ? Result.Failure(ctx.Erros)
            : Result.Success();
    }
}

Registro de DI

Em PipelineServiceCollectionExtensions.cs:

// Pipeline genérico
services.AddTransient(typeof(IPipeline<>), typeof(Pipeline<>));

// Steps (via Scrutor)
services.Scan(scan => scan
    .FromAssemblyOf<Program>()
    .AddClasses(c => c.AssignableTo(typeof(IPipelineStep<>)))
    .AsSelf()
    .WithTransientLifetime());

Requer: dotnet add package Scrutor


Regras

  1. Um step = uma responsabilidade — não misture validação com envio de email
  2. Early return — se condição não se aplica, retorne sem fazer nada
  3. Context carrega tudo — inputs são init, outputs são set
  4. Steps não conhecem outros steps — comunicam-se apenas via contexto
  5. Testável isoladamente — cada step pode ser testado unitariamente