Pular para conteúdo

Flux Pattern Implementation - Web Admin

Overview

Este documento descreve a implementação do padrão Flux (Redux) na aplicação Blazor WebAssembly Admin, utilizando a biblioteca Fluxor.

Objetivo

Eliminar mixed concerns (preocupações misturadas) nos componentes Blazor, separando claramente: - Components: Apenas renderização e dispatching de actions - Actions: Comandos/eventos imutáveis - Effects: Side effects (chamadas de API, I/O) - Reducers: Transformações puras de estado - State: Estado global imutável da aplicação

Implementação Completa

Páginas Refatoradas (5/5 - 100%)

Todas as páginas principais foram refatoradas seguindo o padrão Flux estrito:

  1. Providers (Commit: b98bac98)
  2. Delete operation com resiliência
  3. Estado: IsDeleting, DeletingProviderId
  4. Simplificação: 30+ linhas → 3 linhas

  5. Documents (Commit: 152a22ca)

  6. Delete e RequestVerification operations
  7. Estado: IsDeleting, DeletingDocumentId, IsRequestingVerification, VerifyingDocumentId
  8. Simplificação: Delete 20+ linhas → 3 linhas, Verify 15+ linhas → 3 linhas

  9. Categories (Commit: 1afa2daa)

  10. Delete e Toggle activation operations
  11. Estado: IsDeletingCategory, DeletingCategoryId, IsTogglingCategory, TogglingCategoryId
  12. Simplificação: Delete 15+ linhas → 3 linhas, Toggle 12+ linhas → 1 linha

  13. Services (Commit: 399ee25b)

  14. Delete e Toggle activation operations
  15. Estado: IsDeletingService, DeletingServiceId, IsTogglingService, TogglingServiceId
  16. Simplificação: Delete 15+ linhas → 3 linhas, Toggle 12+ linhas → 1 linha

  17. AllowedCities (Commit: 9ee405e0)

  18. Delete e Toggle activation operations
  19. Estado: IsDeletingCity, DeletingCityId, IsTogglingCity, TogglingCityId
  20. Simplificação: Delete 15+ linhas → 3 linhas, Toggle 20+ linhas → 3 linhas

Dialogs (Decisão Arquitetural)

Os dialogs de Create/Edit foram intencionalmente mantidos com chamadas diretas de API por razões pragmáticas:

Justificativa: - Dialogs são componentes efêmeros (abrem e fecham) - Não precisam de estado global persistente - Complexidade de formulários (validações, múltiplos campos) - Princípio YAGNI (You Aren't Gonna Need It)

Dialogs afetados: - CreateProviderDialog, EditProviderDialog, VerifyProviderDialog - CreateCategoryDialog, EditCategoryDialog - CreateServiceDialog, EditServiceDialog - CreateAllowedCityDialog, EditAllowedCityDialog - UploadDocumentDialog

Padrão atual (funcional): 1. Dialog faz validação e chamada de API localmente 2. Dialog fecha com DialogResult.Ok(true) 3. Página principal dispara Dispatcher.Dispatch(new Load...Action()) para recarregar

Este padrão é aceitável pois: - ✅ Separação clara entre dialog (formulário) e página (listagem) - ✅ Página principal mantém controle do fluxo - ✅ Estado global não é poluído com estados de formulários temporários

Padrão Flux - Fluxo de Dados

┌─────────────┐
│  Component  │ ← Renderiza estado
└──────┬──────┘
       │ Dispatch Action
┌─────────────┐
│   Action    │ (Comando imutável)
└──────┬──────┘
┌─────────────┐
│   Effect    │ → API Call (com resiliência)
└──────┬──────┘
       │ Dispatch Success/Failure
┌─────────────┐
│   Reducer   │ (Função pura)
└──────┬──────┘
       │ Retorna novo estado
┌─────────────┐
│    State    │ (Imutável)
└──────┬──────┘
       │ Notifica componentes
       └──────────────────────┐
                       ┌─────────────┐
                       │  Component  │ (Re-renderiza)
                       └─────────────┘

Anatomia de uma Feature

Exemplo: Providers Delete

1. Actions (ProvidersActions.cs)

public record DeleteProviderAction(Guid ProviderId);
public record DeleteProviderSuccessAction(Guid ProviderId);
public record DeleteProviderFailureAction(Guid ProviderId, string ErrorMessage);

2. State (ProvidersState.cs)

[FeatureState]
public sealed record ProvidersState
{
    public bool IsDeleting { get; init; }
    public Guid? DeletingProviderId { get; init; }
    // ... outros campos
}

3. Effects (ProvidersEffects.cs)

[EffectMethod]
public async Task HandleDeleteProviderAction(DeleteProviderAction action, IDispatcher dispatcher)
{
    await dispatcher.ExecuteApiCallAsync(
        apiCall: () => _providersApi.DeleteProviderAsync(action.ProviderId),
        snackbar: _snackbar,
        operationName: "Deletar provedor",
        onSuccess: _ => {
            dispatcher.Dispatch(new DeleteProviderSuccessAction(action.ProviderId));
            _snackbar.Add("Provedor excluído com sucesso!", Severity.Success);
            dispatcher.Dispatch(new LoadProvidersAction());
        },
        onError: ex => {
            dispatcher.Dispatch(new DeleteProviderFailureAction(action.ProviderId, ex.Message));
        });
}

Nota: ExecuteApiCallAsync é uma extension que adiciona automaticamente: - Retry (3 tentativas com backoff exponencial) - Circuit Breaker (5 falhas em 30s abre circuito por 30s) - Logging centralizado - Tratamento de erros consistente

4. Reducers (ProvidersReducers.cs)

[ReducerMethod]
public static ProvidersState ReduceDeleteProviderAction(ProvidersState state, DeleteProviderAction action)
    => state with 
    { 
        IsDeleting = true, 
        DeletingProviderId = action.ProviderId,
        ErrorMessage = null 
    };

[ReducerMethod]
public static ProvidersState ReduceDeleteProviderSuccessAction(ProvidersState state, DeleteProviderSuccessAction _)
    => state with 
    { 
        IsDeleting = false, 
        DeletingProviderId = null,
        ErrorMessage = null 
    };

[ReducerMethod]
public static ProvidersState ReduceDeleteProviderFailureAction(ProvidersState state, DeleteProviderFailureAction action)
    => state with 
    { 
        IsDeleting = false, 
        DeletingProviderId = null,
        ErrorMessage = action.ErrorMessage 
    };

5. Component (Providers.razor)

ANTES (Anti-pattern):

@inject IProvidersApi ProvidersApi
@inject ISnackbar Snackbar

private async Task DeleteProvider(Guid providerId)
{
    try 
    {
        var result = await ProvidersApi.DeleteProviderAsync(providerId);
        if (result.IsSuccess) 
        {
            Snackbar.Add("Sucesso!", Severity.Success);
            Dispatcher.Dispatch(new LoadProvidersAction());
        } 
        else 
        {
            Logger.LogError("Failed: {Error}", result.Error);
            Snackbar.Add("Erro ao deletar", Severity.Error);
        }
    } 
    catch (Exception ex) 
    {
        Logger.LogError(ex, "Exception deleting provider");
        Snackbar.Add("Erro inesperado", Severity.Error);
    }
}

DEPOIS (Flux pattern):

@inject IDispatcher Dispatcher

private async Task OpenDeleteDialog(Guid providerId)
{
    var result = await DialogService.ShowMessageBox(
        "Confirmar Exclusão",
        "Tem certeza que deseja excluir este provedor?",
        yesText: "Excluir", cancelText: "Cancelar");

    if (result == true)
    {
        Dispatcher.Dispatch(new DeleteProviderAction(providerId));
    }
}

Template com estado disabled:

<MudIconButton Icon="@Icons.Material.Filled.Delete" 
               Color="Color.Error" 
               OnClick="@(() => OpenDeleteDialog(context.Item.Id))" 
               Disabled="@(ProvidersState.Value.IsDeleting && 
                          ProvidersState.Value.DeletingProviderId == context.Item.Id)" />

Benefícios Alcançados

1. Separation of Concerns

  • ✅ Components apenas renderizam e dispatcham
  • ✅ Effects isolam side effects
  • ✅ Reducers são funções puras e testáveis
  • ✅ State é imutável e previsível

2. Resiliência Centralizada

  • ✅ Retry automático em todos os Effects
  • ✅ Circuit Breaker para proteção contra falhas em cascata
  • ✅ Logging consistente
  • ✅ Tratamento de erros padronizado

3. UI/UX Melhorado

  • ✅ Botões desabilitados durante operações (previne duplicação)
  • ✅ Loading states visuais
  • ✅ Feedback consistente via Snackbar
  • ✅ Estado da UI sempre sincronizado

4. Testabilidade

  • ✅ Reducers puros são 100% testáveis
  • ✅ Effects podem ser mockados facilmente
  • ✅ Actions são imutáveis e serializáveis
  • ✅ State é previsível

5. Manutenibilidade

  • ✅ Redução de código: média de 85% menos linhas
  • ✅ Lógica centralizada
  • ✅ Fácil adicionar novas operações
  • ✅ Padrão consistente em toda aplicação

Métricas de Impacto

Feature Antes Depois Redução
Delete Provider 30+ linhas 3 linhas 90%
Delete Document 20+ linhas 3 linhas 85%
Verify Document 15+ linhas 3 linhas 80%
Toggle Category 12+ linhas 1 linha 92%
Toggle Service 12+ linhas 1 linha 92%
Toggle City 20+ linhas 3 linhas 85%

Total: Aproximadamente 87% de redução no código dos componentes.

Guia Rápido: Adicionar Nova Operação

Passo 1: Criar Actions

// Features/MeuModulo/MeuModuloActions.cs
public record MinhaOperacaoAction(Guid Id);
public record MinhaOperacaoSuccessAction(Guid Id);
public record MinhaOperacaoFailureAction(Guid Id, string ErrorMessage);

Passo 2: Atualizar State

// Features/MeuModulo/MeuModuloState.cs
public bool IsExecutingOperacao { get; init; }
public Guid? OperacaoItemId { get; init; }

Passo 3: Criar Effect

// Features/MeuModulo/MeuModuloEffects.cs
[EffectMethod]
public async Task HandleMinhaOperacaoAction(MinhaOperacaoAction action, IDispatcher dispatcher)
{
    await dispatcher.ExecuteApiCallAsync(
        apiCall: () => _api.MinhaOperacaoAsync(action.Id),
        snackbar: _snackbar,
        operationName: "Minha Operação",
        onSuccess: _ => {
            dispatcher.Dispatch(new MinhaOperacaoSuccessAction(action.Id));
            _snackbar.Add("Sucesso!", Severity.Success);
        },
        onError: ex => {
            dispatcher.Dispatch(new MinhaOperacaoFailureAction(action.Id, ex.Message));
        });
}

Passo 4: Criar Reducers

// Features/MeuModulo/MeuModuloReducers.cs
[ReducerMethod]
public static MeuModuloState ReduceMinhaOperacaoAction(MeuModuloState state, MinhaOperacaoAction action)
    => state with { IsExecutingOperacao = true, OperacaoItemId = action.Id };

[ReducerMethod]
public static MeuModuloState ReduceMinhaOperacaoSuccessAction(MeuModuloState state, MinhaOperacaoSuccessAction _)
    => state with { IsExecutingOperacao = false, OperacaoItemId = null };

[ReducerMethod]
public static MeuModuloState ReduceMinhaOperacaoFailureAction(MeuModuloState state, MinhaOperacaoFailureAction action)
    => state with { IsExecutingOperacao = false, OperacaoItemId = null, ErrorMessage = action.ErrorMessage };

Passo 5: Usar no Component

// Pages/MeuModulo.razor
<MudIconButton 
    OnClick="@(() => Dispatcher.Dispatch(new MinhaOperacaoAction(item.Id)))"
    Disabled="@(MeuModuloState.Value.IsExecutingOperacao && 
               MeuModuloState.Value.OperacaoItemId == item.Id)" />

Padrões e Convenções

Nomenclatura

  • Actions: Verbos no presente: DeleteProviderAction, ToggleCategoryActivationAction
  • Success: Adicionar Success ao final: DeleteProviderSuccessAction
  • Failure: Adicionar Failure + ErrorMessage: DeleteProviderFailureAction
  • State fields: Usar Is + Verbo+Gerundio: IsDeleting, IsToggling
  • ID tracking: Usar Verbo+Gerundio + ItemId: DeletingProviderId, TogglingCategoryId

Estrutura de Arquivos

Features/
├── MeuModulo/
│   ├── MeuModuloActions.cs      # Todas as actions
│   ├── MeuModuloState.cs        # Estado imutável
│   ├── MeuModuloEffects.cs      # Side effects (API calls)
│   └── MeuModuloReducers.cs     # Transformações puras

Imutabilidade

Sempre usar record com init:

public sealed record MeuState
{
    public bool IsLoading { get; init; }  // ✅ Correto
    public int Counter { get; set; }      // ❌ Errado!
}

Effects com Resiliência

Sempre usar ExecuteApiCallAsync para chamadas de API:

await dispatcher.ExecuteApiCallAsync(
    apiCall: () => _api.Operation(),
    snackbar: _snackbar,
    operationName: "Nome da Operação",
    onSuccess: _ => { /* ... */ },
    onError: ex => { /* ... */ }
);

Referências

Histórico de Implementação

Data Commit Descrição
2026-01-16 b98bac98 Providers Delete operation
2026-01-16 152a22ca Documents Delete & Verify operations
2026-01-16 1afa2daa Categories Delete & Toggle operations
2026-01-16 399ee25b Services Delete & Toggle operations
2026-01-16 9ee405e0 AllowedCities Delete & Toggle operations

Status: ✅ Implementação completa para todas as páginas principais
Cobertura: 5/5 páginas (100%)
Decisão: Dialogs mantidos com padrão pragmático
Próximos passos: Adicionar unit tests para Effects e Reducers