Pular para conteúdo

Admin Portal - Arquitetura

🏗️ Visão Geral Arquitetural

O Admin Portal segue uma arquitetura Flux/Redux implementada com Fluxor, garantindo state management previsível e unidirecional.

🔄 Padrão Flux

Fluxo de Dados Unidirecional

graph LR
    A[User Action] --> B[Dispatch Action]
    B --> C[Reducer]
    C --> D[New State]
    D --> E[UI Update]
    E -.User Interaction.-> A

    B --> F[Effect]
    F --> G[API Call]
    G --> H[Dispatch Success/Failure]
    H --> C

Componentes do Padrão

Componente Responsabilidade Exemplo
Action Descreve o que aconteceu LoadProvidersAction
Reducer Atualiza o state baseado na action ProvidersReducer
Effect Side-effects (API calls, logging) ProvidersEffects
State Single source of truth ProvidersState
Selector Derivar dados do state GetActiveProviders

📁 Estrutura de Features

Cada feature segue a estrutura:

Features/
└── Modules/
    └── Providers/
        ├── ProvidersState.cs        # State definition
        ├── ProvidersActions.cs      # All actions
        ├── ProvidersReducers.cs     # State mutations
        ├── ProvidersEffects.cs      # Side-effects
        └── ProvidersSelectors.cs    # (opcional) Derived state

Exemplo Completo: Providers Feature

1. State

[FeatureState]
public record ProvidersState
{
    public IReadOnlyList<ModuleProviderDto> Providers { get; init; } = [];
    public bool IsLoading { get; init; }
    public string? ErrorMessage { get; init; }
    public int CurrentPage { get; init; } = 1;
    public int PageSize { get; init; } = 20;
    public int TotalCount { get; init; }

    // Computed properties
    public int TotalPages => TotalCount > 0 
        ? (int)Math.Ceiling(TotalCount / (double)PageSize) 
        : 0;
    public bool HasPreviousPage => CurrentPage > 1;
    public bool HasNextPage => CurrentPage < TotalPages;
}

2. Actions

// Load
public record LoadProvidersAction(int PageNumber = 1, int PageSize = 20);
public record LoadProvidersSuccessAction(
    IReadOnlyList<ModuleProviderDto> Providers,
    int TotalCount,
    int PageNumber,
    int PageSize);
public record LoadProvidersFailureAction(string ErrorMessage);

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

// Pagination
public record NextPageAction;
public record PreviousPageAction;
public record GoToPageAction(int PageNumber);

3. Reducers

public class ProvidersReducers
{
    [ReducerMethod]
    public static ProvidersState ReduceLoadProvidersAction(
        ProvidersState state,
        LoadProvidersAction action) =>
        state with { IsLoading = true, ErrorMessage = null };

    [ReducerMethod]
    public static ProvidersState ReduceLoadProvidersSuccessAction(
        ProvidersState state,
        LoadProvidersSuccessAction action) =>
        state with
        {
            Providers = action.Providers,
            TotalCount = action.TotalCount,
            CurrentPage = action.PageNumber,
            PageSize = action.PageSize,
            IsLoading = false,
            ErrorMessage = null
        };

    [ReducerMethod]
    public static ProvidersState ReduceLoadProvidersFailureAction(
        ProvidersState state,
        LoadProvidersFailureAction action) =>
        state with
        {
            IsLoading = false,
            ErrorMessage = action.ErrorMessage
        };
}

4. Effects

public class ProvidersEffects
{
    private readonly IProvidersApi _providersApi;
    private readonly ErrorHandlingService _errorHandler;
    private readonly ISnackbar _snackbar;

    [EffectMethod]
    public async Task HandleLoadProvidersAction(
        LoadProvidersAction action,
        IDispatcher dispatcher)
    {
        var result = await _errorHandler.ExecuteWithErrorHandlingAsync(
            ct => _providersApi.GetProvidersAsync(action.PageNumber, action.PageSize, ct),
            "Load providers");

        if (result.IsSuccess)
        {
            dispatcher.Dispatch(new LoadProvidersSuccessAction(
                result.Value.Items,
                result.Value.TotalItems,
                result.Value.PageNumber,
                result.Value.PageSize));
        }
        else
        {
            var errorMessage = _errorHandler.HandleApiError(result, "load providers");
            _snackbar.Add(errorMessage, Severity.Error);
            dispatcher.Dispatch(new LoadProvidersFailureAction(errorMessage));
        }
    }
}

5. Uso em Componentes

@inherits FluxorComponent
@inject IState<ProvidersState> ProvidersState
@inject IDispatcher Dispatcher

<MudDataGrid Items="@ProvidersState.Value.Providers"
             Loading="@ProvidersState.Value.IsLoading">
    <!-- Columns -->
</MudDataGrid>

@code {
    protected override void OnInitialized()
    {
        base.OnInitialized();
        Dispatcher.Dispatch(new LoadProvidersAction());
    }
}

🎨 Componentes e Dialogs

Decisão Arquitetural: Pragmatic Approach

Dialogs NÃO usam Fluxor - mantêm direct API calls por serem componentes efêmeros.

Justificativa: - Lifecycle curto (abrir → submit → fechar) - Sem necessidade de compartilhar estado - Single Responsibility: apenas submit de formulário - Simplicidade > Consistência neste caso (YAGNI)

Exemplo de Dialog

@inject IProvidersApi ProvidersApi
@inject ISnackbar Snackbar

<MudDialog>
    <DialogContent>
        <MudForm @ref="form" @bind-IsValid="@isValid">
            <MudTextField @bind-Value="model.Name" Label="Nome" Required />
            <MudTextField @bind-Value="model.Email" Label="E-mail" Required />
        </MudForm>
    </DialogContent>
    <DialogActions>
        <MudButton OnClick="Cancel">Cancelar</MudButton>
        <MudButton Color="Color.Primary" OnClick="Submit" Disabled="@(!isValid)">
            Salvar
        </MudButton>
    </DialogActions>
</MudDialog>

@code {
    [CascadingParameter] MudDialogInstance MudDialog { get; set; }

    private async Task Submit()
    {
        try
        {
            var result = await ProvidersApi.UpdateProviderAsync(model);
            if (result.IsSuccess)
            {
                Snackbar.Add("Provedor atualizado com sucesso!", Severity.Success);
                MudDialog.Close(DialogResult.Ok(true));
            }
            else
            {
                Snackbar.Add(result.Error?.Message ?? "Erro ao atualizar", Severity.Error);
            }
        }
        catch (Exception ex)
        {
            Snackbar.Add($"Erro: {ex.Message}", Severity.Error);
        }
    }
}

🔌 API Integration

Refit Clients

Todos os módulos têm interfaces Refit tipadas:

public interface IProvidersApi
{
    [Get("/api/providers")]
    Task<ApiResult<PagedResponse<ModuleProviderDto>>> GetProvidersAsync(
        [Query] int pageNumber = 1,
        [Query] int pageSize = 20,
        CancellationToken cancellationToken = default);

    [Get("/api/providers/{id}")]
    Task<ApiResult<ModuleProviderDto>> GetProviderByIdAsync(
        Guid id,
        CancellationToken cancellationToken = default);

    [Put("/api/providers/{id}")]
    Task<ApiResult> UpdateProviderAsync(
        Guid id,
        [Body] UpdateProviderRequest request,
        CancellationToken cancellationToken = default);

    [Delete("/api/providers/{id}")]
    Task<ApiResult> DeleteProviderAsync(
        Guid id,
        CancellationToken cancellationToken = default);
}

Registro de Serviços

// Program.cs
builder.Services.AddRefitClient<IProvidersApi>()
    .ConfigureHttpClient(c => c.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"]))
    .AddStandardResilienceHandler(options =>
    {
        options.Retry.MaxRetryAttempts = 3;
        options.Retry.Delay = TimeSpan.FromSeconds(2);
        options.Retry.BackoffType = DelayBackoffType.Exponential;
    });

🛡️ Error Handling

ErrorHandlingService

Centraliza tratamento de erros com retry automático:

public class ErrorHandlingService
{
    public async Task<Result<T>> ExecuteWithErrorHandlingAsync<T>(
        Func<CancellationToken, Task<Result<T>>> operation,
        string operationName,
        int maxRetries = 3)
    {
        for (int attempt = 1; attempt <= maxRetries; attempt++)
        {
            try
            {
                var result = await operation(CancellationToken.None);

                if (result.IsSuccess || !ShouldRetry(result.Error?.StatusCode))
                    return result;

                if (attempt < maxRetries)
                {
                    await Task.Delay(GetRetryDelay(attempt));
                }
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error in {Operation}, attempt {Attempt}", 
                    operationName, attempt);

                if (attempt == maxRetries)
                    return Result.Failure<T>(new Error(500, ex.Message));
            }
        }

        return Result.Failure<T>(new Error(500, "Max retries exceeded"));
    }

    private bool ShouldRetry(int? statusCode) =>
        statusCode >= 500 || statusCode == 408; // Server errors + Timeout

    private TimeSpan GetRetryDelay(int attempt) =>
        TimeSpan.FromSeconds(Math.Pow(2, attempt)); // Exponential backoff
}

🌐 Localização

LocalizationService

Dictionary-based translations com suporte a múltiplos idiomas:

public class LocalizationService
{
    private readonly Dictionary<string, Dictionary<string, string>> _translations = new()
    {
        ["pt-BR"] = new()
        {
            ["Common.Save"] = "Salvar",
            ["Common.Cancel"] = "Cancelar",
            ["Providers.Active"] = "Ativo",
            // ...
        },
        ["en-US"] = new()
        {
            ["Common.Save"] = "Save",
            ["Common.Cancel"] = "Cancel",
            ["Providers.Active"] = "Active",
            // ...
        }
    };

    public string GetString(string key, params object[] args)
    {
        var culture = CultureInfo.CurrentUICulture.Name;

        if (_translations.TryGetValue(culture, out var cultureDictionary) &&
            cultureDictionary.TryGetValue(key, out var value))
        {
            return args.Length > 0 ? string.Format(value, args) : value;
        }

        // Fallback to en-US
        return _translations["en-US"].GetValueOrDefault(key, $"[{key}]");
    }

    public void SetCulture(string cultureName)
    {
        var culture = new CultureInfo(cultureName);
        CultureInfo.CurrentCulture = culture;
        CultureInfo.CurrentUICulture = culture;
        OnCultureChanged?.Invoke();
    }

    public event Action? OnCultureChanged;
}

⚡ Performance Optimizations

1. Virtualization

<MudDataGrid Items="@providers"
             Virtualize="true"
             FixedHeader="true"
             Height="600px">
    <!-- Renderiza apenas ~20-30 linhas visíveis -->
</MudDataGrid>

2. Debouncing

public class DebounceHelper
{
    private CancellationTokenSource? _cts;

    public async Task<T> DebounceAsync<T>(
        Func<Task<T>> operation,
        int millisecondsDelay = 300)
    {
        _cts?.Cancel();
        _cts = new CancellationTokenSource();

        try
        {
            await Task.Delay(millisecondsDelay, _cts.Token);
            return await operation();
        }
        catch (TaskCanceledException)
        {
            return default!;
        }
    }
}

3. Memoization

public class PerformanceHelper
{
    private static readonly Dictionary<string, (object Value, DateTime Expiry)> _cache = new();

    public static T Memoize<T>(string key, Func<T> factory, TimeSpan? ttl = null)
    {
        if (_cache.TryGetValue(key, out var cached) && DateTime.UtcNow < cached.Expiry)
        {
            return (T)cached.Value;
        }

        var value = factory();
        var expiry = DateTime.UtcNow + (ttl ?? TimeSpan.FromSeconds(30));
        _cache[key] = (value!, expiry);
        return value;
    }
}

🧪 Testing

bUnit Tests

[Fact]
public void ProvidersPage_ShouldLoadProviders_OnInitialized()
{
    // Arrange
    var mockState = new Mock<IState<ProvidersState>>();
    mockState.Setup(x => x.Value).Returns(new ProvidersState
    {
        Providers = new List<ModuleProviderDto> { /* test data */ },
        IsLoading = false
    });

    Services.AddSingleton(mockState.Object);
    Services.AddSingleton<IDispatcher>(new MockDispatcher());

    // Act
    var cut = RenderComponent<Providers>();

    // Assert
    cut.Find("table").Should().NotBeNull();
    cut.FindAll("tr").Count.Should().BeGreaterThan(1);
}

📊 Métricas de Arquitetura

Code Reduction (Flux Refactoring)

Página Antes (LOC) Depois (LOC) Redução
Providers.razor 95 18 81%
Documents.razor 87 12 86%
Categories.razor 103 18 83%
Services.razor 98 18 82%
AllowedCities.razor 92 14 85%
TOTAL 475 80 83%

🔗 Referências