MoreRSS

site iconThe Practical DeveloperModify

A constructive and inclusive social network for software developers.
Please copy the RSS to your reader, or quickly subscribe to:

Inoreader Feedly Follow Feedbin Local Reader

Rss preview of Blog of The Practical Developer

Hello Dev.to!👋 I'm starting my journey as a developer🌱

2026-04-15 10:52:20

👋 Hello Dev.to!

Hi, I'm Umitomo 🐠 from Japan 🇯🇵

I work as an in-house systems engineer, mainly focusing on IT infrastructure and security.

💼 What I do

In my daily work, I handle IT infrastructure and security operations.

Outside of work, I enjoy building things as a hobby and have been learning:

  • SwiftUI (iOS development)
  • Web development (Remix, Cloudflare)
  • Cybersecurity (TryHackMe)

🚀 What I'm building

🌐 Web Development

I built a personal blog using Remix + Cloudflare + microCMS.

Since I plan to publish articles on Dev.to, I'm thinking about turning it into a portfolio site using React Router v7.

📱 iOS Development

I'm currently learning SwiftUI through tutorials.

I'd love to build apps that I can enjoy with my kids.

🎯 Why I'm here

I chose Dev.to because:

  • I want to challenge myself to write in English
  • I like the culture where it's okay to share not only tutorials but also personal learning journeys

I feel it's a great place to grow as a developer while sharing my progress.

✍️ What I’ll write about

  • My journey from infrastructure to development
  • Development logs and technical notes
  • Security learning and experiments

🌱 My goal

I'm starting small, but I want to stay consistent and keep learning step by step.

🙌 Nice to meet you!

Feel free to connect with me!

Zero JavaScript: CRUD Completo com Blazor WASM e Radzen

2026-04-15 10:52:07

Introdução

No artigo anterior desta série, analisei se Blazor WebAssembly está pronto para produção corporativa comparando-o com Angular. A conclusão foi nuançada — Blazor WASM é viável para cenários corporativos internos, mas tem trade-offs que precisam ser avaliados caso a caso. Hoje vou provar na prática construindo um CRUD completo de produtos com DataGrid paginado, formulários com validação, dialogs, notificações e inline editing — tudo em C#, sem escrever uma linha de JavaScript.

O componente de UI que escolhi é o Radzen Blazor, uma biblioteca open source (licença MIT) com mais de 70 componentes gratuitos. A razão principal: Radzen entrega uma experiência visual madura para cenários CRUD corporativos, com DataGrid, formulários, validação, dialogs e notificações prontos para uso. O JavaScript que roda internamente (Radzen.Blazor.js) é da própria biblioteca — o desenvolvedor nunca toca em JS diretamente.

O que vou construir neste tutorial:

  • API REST com Minimal API para Produtos e Categorias (10 endpoints)
  • Blazor WASM Standalone consumindo a API via HttpClient
  • DataGrid paginado com busca, ordering e ações por linha
  • Formulário em dialog reutilizável para criação e edição
  • Inline editing para Categorias (pattern alternativo ao dialog)
  • Exclusão com confirmação e notificações visuais Todo o código está no repositório blog-zocateli-sample no GitHub. A ideia é que você clone, rode e forme sua própria opinião. Este artigo é o 2º da série “Frontend Moderno” — meu objetivo é demonstrar que o ecossistema de componentes Blazor já suporta cenários reais de produção, com uma experiência de desenvolvimento familiar para quem vem do .NET.

ℹ️ Informação: Radzen Blazor é open source (MIT) e inclui 70+ componentes free. O Radzen.Blazor.js é JavaScript interno da biblioteca — o desenvolvedor nunca escreve JavaScript diretamente. A versão usada neste tutorial é a 10.2.0 com .NET 10.

Pré-requisitos

Para acompanhar este tutorial, você vai precisar de:

  • .NET 10 SDK (10.0.201 ou superior) — download oficial
  • IDE: VS Code com extensão C# Dev Kit, ou Visual Studio 2022 17.14+
  • Conhecimento básico de C# e REST APIs
  • Terminal (PowerShell, bash ou zsh) Clone o repositório com todo o código pronto:
git clone https://github.com/lzocateli/blog-zocateli-sample.git
cd blog-zocateli-sample

Verifique se o SDK está instalado:

dotnet --version

Output esperado:

10.0.201

💡 Dica: Se você usa o VS Code com Dev Containers, o .devcontainer/ do repositório já tem o .NET 10 SDK configurado. Basta abrir o projeto no container e tudo estará pronto.

Criando o Projeto Blazor WASM Standalone

O template blazorwasm do .NET cria uma aplicação Blazor WebAssembly Standalone — uma SPA que roda inteiramente no browser via WebAssembly, sem servidor ASP.NET Core hospedando. Diferente do modelo Hosted (que inclui um projeto Server), o Standalone é uma SPA pura que consome APIs externas via HTTP, exatamente como uma aplicação Angular ou React.

Scaffolding do projeto

dotnet new blazorwasm --name BlogSamples.BlazorWasm --output frontend/blazor-wasm --framework net10.0
dotnet sln add frontend/blazor-wasm
dotnet add frontend/blazor-wasm package Radzen.Blazor

O primeiro comando cria o projeto, o segundo adiciona à solution e o terceiro instala o Radzen Blazor — a biblioteca de componentes UI. O .csproj resultante fica enxuto:

<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.5" />
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.5" PrivateAssets="all" />
    <PackageReference Include="Radzen.Blazor" Version="10.2.0" />
  </ItemGroup>
</Project>

Configurando Program.cs

O Program.cs é o entry point da SPA. Aqui configuro o HttpClient com a URL base da API (via appsettings.json), registro os services HTTP tipados e adiciono os componentes Radzen:

using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using BlogSamples.BlazorWasm;
using BlogSamples.BlazorWasm.Services;
using Radzen;

var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

// Configurar HttpClient com URL base da API
var apiBaseUrl = builder.Configuration["ApiBaseUrl"] ?? "http://localhost:5101";
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(apiBaseUrl) });

// Services HTTP tipados
builder.Services.AddScoped<ProdutoApiService>();
builder.Services.AddScoped<CategoriaApiService>();

// Radzen Components (DialogService, NotificationService, etc.)
builder.Services.AddRadzenComponents();

await builder.Build().RunAsync();

A chamada AddRadzenComponents() registra automaticamente DialogService, NotificationService, TooltipService e ContextMenuService no container de DI. Sem ela, os dialogs e notificações não funcionam.

Layout com Radzen

O MainLayout.razor define a estrutura visual da aplicação — header com toggle de sidebar, navegação lateral com RadzenPanelMenu, área de conteúdo e footer:

@inherits LayoutComponentBase

<RadzenLayout>
    <RadzenHeader>
        <RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center"
                     Gap="0.5rem" class="rz-p-2">
            <RadzenSidebarToggle Click="@(() => sidebarExpanded = !sidebarExpanded)" />
            <RadzenText Text="Blog Samples — Blazor WASM" TextStyle="TextStyle.H5"
                        class="rz-m-0" />
        </RadzenStack>
    </RadzenHeader>

    <RadzenSidebar @bind-Expanded="@sidebarExpanded">
        <RadzenPanelMenu>
            <RadzenPanelMenuItem Text="Dashboard" Icon="dashboard" Path="/" />
            <RadzenPanelMenuItem Text="Produtos" Icon="inventory_2" Path="/produtos" />
            <RadzenPanelMenuItem Text="Categorias" Icon="category" Path="/categorias" />
        </RadzenPanelMenu>
    </RadzenSidebar>

    <RadzenBody>
        <div class="rz-p-4">
            @Body
        </div>
    </RadzenBody>

    <RadzenFooter>
        <RadzenText Text="© 2026 Blog Samples — zocate.li" TextStyle="TextStyle.Caption"
                    class="rz-p-2" />
    </RadzenFooter>
</RadzenLayout>

<RadzenComponents />

@code {
    bool sidebarExpanded = true;
}

O componente <RadzenComponents /> no final é obrigatório — ele renderiza os containers para dialogs, notificações e tooltips. Sem ele, DialogService.OpenAsync() e NotificationService.Notify() não exibem nada na tela.

⚠️ Atenção: O <RadzenComponents /> deve estar dentro do layout, não no App.razor. Colocá-lo fora do layout pode causar problemas de renderização com dialogs e notificações.

Para que o tema visual funcione, o App.razor precisa incluir <RadzenTheme Theme="material" />:

<RadzenTheme Theme="material" />
<Router AppAssembly="typeof(Program).Assembly">
    <!-- ... -->
</Router>

O tema material do Radzen inclui toda a estilização necessária — cores, tipografia, espaçamento, ícones Material Design. Não é necessário importar Bootstrap ou qualquer outro framework CSS.

A API REST — Domínio Produtos

Para o Blazor WASM consumir dados, criei um domínio Produtos com Minimal API no projeto principal. São 10 endpoints organizados em dois grupos:

Verbo Rota Descrição
GET /api/produtos?pagina=1&tamanhoPagina=20&filtro= Listar com paginação e filtro
GET /api/produtos/{id} Obter por ID
POST /api/produtos Criar produto
PUT /api/produtos/{id} Atualizar produto
DELETE /api/produtos/{id} Remover produto
GET /api/categorias Listar todas
GET /api/categorias/{id} Obter por ID
POST /api/categorias Criar categoria
PUT /api/categorias/{id} Atualizar categoria
DELETE /api/categorias/{id} Remover categoria

O ProdutoEndpoints.cs usa MapGroup para organizar as rotas e ProducesResponseType para documentar no Swagger:

public static class ProdutoEndpoints
{
    public static void MapProdutoEndpoints(this IEndpointRouteBuilder app)
    {
        var produtos = app.MapGroup("/api/produtos")
            .WithTags("Produtos");

        produtos.MapGet("/", async (
            IProdutoService service,
            int pagina = 1,
            int tamanhoPagina = 20,
            string? filtro = null) =>
        {
            var resultado = await service.ListarProdutosAsync(pagina, tamanhoPagina, filtro);
            return Results.Ok(resultado);
        })
        .WithName("ListarProdutos")
        .Produces<PagedResult<ProdutoDto>>();

        produtos.MapPost("/", async (CriarProdutoRequest request, IProdutoService service) =>
        {
            var produto = await service.CriarProdutoAsync(request);
            return Results.CreatedAtRoute("ObterProduto", new { id = produto.Id }, produto);
        })
        .WithName("CriarProduto")
        .Produces<ProdutoDto>(201)
        .ProducesValidationProblem();

        // ... PUT, DELETE, e endpoints de Categorias seguem o mesmo pattern
    }
}

Os DTOs de request usam DataAnnotations para validação server-side, garantindo que a API valide os dados mesmo que o client-side seja bypassed:

public record CriarProdutoRequest
{
    [Required(ErrorMessage = "Nome é obrigatório")]
    [StringLength(200, MinimumLength = 3)]
    public string Nome { get; init; } = string.Empty;

    public string? Descricao { get; init; }

    [Range(0.01, double.MaxValue, ErrorMessage = "Preço deve ser maior que zero")]
    public decimal Preco { get; init; }

    [Range(0, int.MaxValue)]
    public int QuantidadeEstoque { get; init; }

    [Range(1, int.MaxValue, ErrorMessage = "Selecione uma categoria")]
    public int CategoriaId { get; init; }

    public bool Ativo { get; init; } = true;
}

A implementação do service usa ConcurrentDictionary como storage in-memory (decisão de design para manter o tutorial focado no Blazor WASM, sem dependência de banco de dados). O seed inicial inclui 8 categorias e mais de 50 produtos distribuídos entre elas.

Configuração CORS

Como o Blazor WASM Standalone roda em uma porta diferente da API (5200 vs 5101), é obrigatório configurar CORS no Program.cs da API:

builder.Services.AddCors(options =>
{
    options.AddPolicy("BlazorWasm", policy =>
    {
        policy.WithOrigins("http://localhost:5200", "https://localhost:7200")
              .AllowAnyHeader()
              .AllowAnyMethod();
    });
});

// No pipeline:
app.UseCors("BlazorWasm");

💡 Dica: Configurar CORS é obrigatório para Blazor WASM Standalone. Sem configuração explícita, o browser bloqueará as requisições cross-origin. Em produção, substitua os origins por domínios reais.

Para testar a API isoladamente, rode dotnet run --project src/BlogSamples e acesse http://localhost:5101/docs — o Swagger mostra todos os endpoints de Produtos e Categorias.

O diagrama abaixo mostra a arquitetura completa — o Blazor WASM no browser se comunica com a API REST via HttpClient (JSON) atravessando a barreira de CORS:

Services HTTP — Consumindo a API

O pattern que uso para consumir a API é service tipado com HttpClient injetado via primary constructor. Cada service encapsula as chamadas HTTP para um domínio específico, usando os métodos de extensão do System.Net.Http.JsonGetFromJsonAsync, PostAsJsonAsync e PutAsJsonAsync:

using System.Net.Http.Json;
using BlogSamples.BlazorWasm.Models;

namespace BlogSamples.BlazorWasm.Services;

public class ProdutoApiService(HttpClient http)
{
    public async Task<PagedResult<ProdutoDto>> ListarAsync(
        int pagina = 1, int tamanhoPagina = 20, string? filtro = null)
    {
        var url = $"api/produtos?pagina={pagina}&tamanhoPagina={tamanhoPagina}";
        if (!string.IsNullOrWhiteSpace(filtro))
            url += $"&filtro={Uri.EscapeDataString(filtro)}";

        return await http.GetFromJsonAsync<PagedResult<ProdutoDto>>(url)
            ?? new PagedResult<ProdutoDto>();
    }

    public async Task<ProdutoDto?> ObterPorIdAsync(int id)
        => await http.GetFromJsonAsync<ProdutoDto>($"api/produtos/{id}");

    public async Task<ProdutoDto?> CriarAsync(CriarProdutoRequest request)
    {
        var response = await http.PostAsJsonAsync("api/produtos", request);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<ProdutoDto>();
    }

    public async Task<ProdutoDto?> AtualizarAsync(int id, AtualizarProdutoRequest request)
    {
        var response = await http.PutAsJsonAsync($"api/produtos/{id}", request);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<ProdutoDto>();
    }

    public async Task RemoverAsync(int id)
    {
        var response = await http.DeleteAsync($"api/produtos/{id}");
        response.EnsureSuccessStatusCode();
    }
}

Alguns pontos importantes sobre este pattern:

  • Uri.EscapeDataString no filtro previne injeção de parâmetros na query string. Nunca concatene strings diretamente em URLs sem encoding.
  • EnsureSuccessStatusCode() lança HttpRequestException se a API retornar erro (4xx, 5xx). No componente Blazor, capturo essa exceção para exibir notificação de erro ao usuário.
  • Primary constructor (HttpClient http) evita o boilerplate de campo + construtor. O HttpClient é resolvido pelo container de DI com a BaseAddress configurada no Program.cs. O CategoriaApiService segue exatamente o mesmo pattern, com métodos ListarAsync, CriarAsync, AtualizarAsync e RemoverAsync.

No Angular, HttpClient com interceptors e operadores RxJS oferece ergonomia similar. Em Blazor, a experiência é equivalente — DelegatingHandler serve como interceptor para autenticação, logging ou retry. A diferença principal é que Blazor usa async/await nativo do C# em vez de Observable do RxJS.

ℹ️ Informação: Os models do Blazor WASM são classes (não records). Radzen Blazor usa two-way binding (@bind-Value) que requer setters mutáveis. Records com init não funcionam para edição em formulários Radzen.

DataGrid de Produtos com Radzen

O RadzenDataGrid é o componente central deste tutorial. Ele suporta paginação server-side, sorting, filtering, templates customizados por coluna e integração direta com o pattern de LoadData — um callback que o grid chama toda vez que precisa de dados novos (ao mudar de página, ordenar ou filtrar).

Aqui está o Produtos.razor completo — vou explicar cada parte:

@page "/produtos"

<PageTitle>Produtos — Blog Samples</PageTitle>

<RadzenText TextStyle="TextStyle.H3" class="rz-mb-4">Produtos</RadzenText>

<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center"
             Gap="1rem" class="rz-mb-4">
    <RadzenTextBox Placeholder="Buscar produtos..." @bind-Value="filtro"
                   Change="@OnFiltroChanged" Style="width: 300px;" />
    <RadzenButton Text="Novo Produto" Icon="add" ButtonStyle="ButtonStyle.Primary"
                  Click="@(() => AbrirFormulario(null))" />
</RadzenStack>

<RadzenDataGrid @ref="grid" TItem="ProdutoDto"
                Data="@produtos" Count="@totalRegistros"
                LoadData="@CarregarDados"
                AllowPaging="true" PageSize="20"
                AllowSorting="true"
                PagerHorizontalAlign="HorizontalAlign.Center"
                IsLoading="@isLoading"
                Style="width: 100%;">
    <Columns>
        <RadzenDataGridColumn TItem="ProdutoDto" Property="Id" Title="ID"
                              Width="70px" TextAlign="TextAlign.Center" Sortable="false" />

        <RadzenDataGridColumn TItem="ProdutoDto" Property="Nome" Title="Nome"
                              MinWidth="200px" />

        <RadzenDataGridColumn TItem="ProdutoDto" Property="CategoriaNome" Title="Categoria"
                              Width="150px" />

        <RadzenDataGridColumn TItem="ProdutoDto" Property="Preco" Title="Preço"
                              Width="130px" TextAlign="TextAlign.End"
                              FormatString="{0:C2}" />

        <RadzenDataGridColumn TItem="ProdutoDto" Property="QuantidadeEstoque" Title="Estoque"
                              Width="100px" TextAlign="TextAlign.Center" />

        <RadzenDataGridColumn TItem="ProdutoDto" Property="Ativo" Title="Status"
                              Width="100px" TextAlign="TextAlign.Center" Sortable="false">
            <Template Context="produto">
                <RadzenBadge BadgeStyle="@(produto.Ativo ? BadgeStyle.Success : BadgeStyle.Light)"
                             Text="@(produto.Ativo ? "Ativo" : "Inativo")" />
            </Template>
        </RadzenDataGridColumn>

        <RadzenDataGridColumn TItem="ProdutoDto" Title="Ações" Width="140px"
                              TextAlign="TextAlign.Center" Sortable="false">
            <Template Context="produto">
                <RadzenButton Icon="edit" ButtonStyle="ButtonStyle.Light"
                              Size="ButtonSize.Small"
                              Click="@(() => AbrirFormulario(produto.Id))"
                              class="rz-mr-1" />
                <RadzenButton Icon="delete" ButtonStyle="ButtonStyle.Danger"
                              Size="ButtonSize.Small"
                              Click="@(() => ConfirmarExclusao(produto))" />
            </Template>
        </RadzenDataGridColumn>
    </Columns>
</RadzenDataGrid>

Vou detalhar os pontos-chave:

  • LoadData="@CarregarDados" — o grid chama este callback ao inicializar, ao mudar de página e ao ordenar. Recebe LoadDataArgs com Skip, Top e informações de ordering. Essa é a chave para paginação server-side.
  • Data + CountData recebe a página atual de itens; Count informa o total de registros. O grid calcula o número de páginas automaticamente.
  • FormatString="{0:C2}" — formata o preço como moeda. O Blazor WASM usa a cultura configurada no browser, então em pt-BR exibe “R$ 1.299,00”.
  • Template — colunas customizadas. Uso RadzenBadge para exibir o status como badge verde/cinza e botões de ação (Editar, Excluir) com ícones Material Design.
  • IsLoading — exibe um spinner enquanto a API está sendo chamada. Melhora significativamente a UX em conexões lentas. O @code block contém a lógica:
@code {
    RadzenDataGrid<ProdutoDto> grid = default!;
    IEnumerable<ProdutoDto> produtos = [];
    int totalRegistros;
    string? filtro;
    bool isLoading;

    [Inject] ProdutoApiService ProdutoService { get; set; } = default!;
    [Inject] DialogService DialogService { get; set; } = default!;
    [Inject] NotificationService NotificationService { get; set; } = default!;

    async Task CarregarDados(LoadDataArgs args)
    {
        isLoading = true;

        var pagina = (args.Skip ?? 0) / (args.Top ?? 20) + 1;
        var tamanhoPagina = args.Top ?? 20;

        var resultado = await ProdutoService.ListarAsync(pagina, tamanhoPagina, filtro);

        produtos = resultado.Itens;
        totalRegistros = resultado.TotalRegistros;

        isLoading = false;
    }

    async Task OnFiltroChanged()
    {
        await grid.FirstPage(true);
    }
}

O método CarregarDados converte Skip/Top (pattern do Radzen) para pagina/tamanhoPagina (pattern da minha API). Quando o usuário digita no campo de busca, OnFiltroChanged volta para a primeira página com grid.FirstPage(true) — o true força o reload que chama CarregarDados novamente com o filtro atualizado.

⚠️ Atenção: O evento LoadData do RadzenDataGrid é chamado toda vez que o grid precisa de dados — paging, sorting, filtering. Não confunda com Data binding direto, que é para dados client-side. Se você usar Data com uma lista completa e AllowPaging, a paginação será client-side (todos os dados carregados de uma vez). Com LoadData + Count, a paginação é server-side (apenas a página atual é carregada).

Formulário de Criação e Edição

O ProdutoForm.razor é um componente reutilizável que serve tanto para criar quanto para editar produtos. A distinção é feita pelo Parameter ProdutoId: se for null, é criação; se tiver valor, é edição. O formulário é aberto em um dialog modal via DialogService.OpenAsync<ProdutoForm>().

<RadzenTemplateForm TItem="CriarProdutoRequest" Data="@model" Submit="@OnSubmit">
    <RadzenStack Gap="1rem">
        <RadzenFormField Text="Nome" Variant="Variant.Outlined">
            <ChildContent>
                <RadzenTextBox @bind-Value="model.Nome" MaxLength="200" />
            </ChildContent>
            <Helper>
                <RadzenRequiredValidator Component="Nome"
                                         Text="Nome é obrigatório" />
            </Helper>
        </RadzenFormField>

        <RadzenFormField Text="Descrição" Variant="Variant.Outlined">
            <ChildContent>
                <RadzenTextArea @bind-Value="model.Descricao" Rows="3" />
            </ChildContent>
        </RadzenFormField>

        <RadzenRow Gap="1rem">
            <RadzenColumn Size="6">
                <RadzenFormField Text="Preço (R$)" Variant="Variant.Outlined"
                                Style="width: 100%;">
                    <ChildContent>
                        <RadzenNumeric TValue="decimal" @bind-Value="model.Preco"
                                       Min="0.01m" Format="N2" />
                    </ChildContent>
                    <Helper>
                        <RadzenNumericRangeValidator Component="Preco" Min="0.01"
                                                     Text="Preço deve ser maior que zero" />
                    </Helper>
                </RadzenFormField>
            </RadzenColumn>
            <RadzenColumn Size="6">
                <RadzenFormField Text="Estoque" Variant="Variant.Outlined"
                                Style="width: 100%;">
                    <ChildContent>
                        <RadzenNumeric TValue="int" @bind-Value="model.QuantidadeEstoque"
                                       Min="0" />
                    </ChildContent>
                </RadzenFormField>
            </RadzenColumn>
        </RadzenRow>

        <RadzenFormField Text="Categoria" Variant="Variant.Outlined">
            <ChildContent>
                <RadzenDropDown TValue="int" @bind-Value="model.CategoriaId"
                                Data="@categorias" TextProperty="Nome"
                                ValueProperty="Id"
                                Placeholder="Selecione uma categoria..."
                                AllowFiltering="true" />
            </ChildContent>
            <Helper>
                <RadzenRequiredValidator Component="CategoriaId"
                                         Text="Selecione uma categoria"
                                         DefaultValue="0" />
            </Helper>
        </RadzenFormField>

        <RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center"
                     Gap="0.5rem">
            <RadzenSwitch @bind-Value="model.Ativo" />
            <RadzenText Text="@(model.Ativo ? "Ativo" : "Inativo")" />
        </RadzenStack>

        <RadzenStack Orientation="Orientation.Horizontal"
                     JustifyContent="JustifyContent.End" Gap="0.5rem">
            <RadzenButton Text="Cancelar" ButtonStyle="ButtonStyle.Light"
                          Click="@(() => DialogService.Close(false))"
                          ButtonType="ButtonType.Button" />
            <RadzenButton Text="Salvar" ButtonStyle="ButtonStyle.Primary"
                          ButtonType="ButtonType.Submit" Icon="save" />
        </RadzenStack>
    </RadzenStack>
</RadzenTemplateForm>

Vou destacar os pontos mais importantes do formulário:

  • RadzenTemplateForm<CriarProdutoRequest> — encapsula todo o formulário com validação. O Submit event só é disparado se todos os validators passarem.
  • RadzenRequiredValidator e RadzenNumericRangeValidator — validação client-side com feedback visual automático (bordas vermelhas, mensagem de erro abaixo do campo). A validação server-side via DataAnnotations na API é a segunda camada de proteção.
  • RadzenDropDown<int> para categorias — Data recebe a lista, TextProperty e ValueProperty mapeiam as propriedades do DTO. AllowFiltering="true" habilita busca inline no dropdown (útil quando há muitas categorias).
  • RadzenSwitch com label dinâmico — exibe “Ativo” ou “Inativo” conforme o estado do toggle.
  • Botão Cancelar com ButtonType="ButtonType.Button" — sem isso, o click do Cancelar dispara o submit do formulário. O @code block contém a lógica de inicialização e submit:
@code {
    [Parameter] public int? ProdutoId { get; set; }

    [Inject] ProdutoApiService ProdutoService { get; set; } = default!;
    [Inject] CategoriaApiService CategoriaService { get; set; } = default!;
    [Inject] DialogService DialogService { get; set; } = default!;
    [Inject] NotificationService NotificationService { get; set; } = default!;

    CriarProdutoRequest model = new();
    IReadOnlyList<CategoriaDto> categorias = [];

    protected override async Task OnInitializedAsync()
    {
        categorias = await CategoriaService.ListarAsync();

        if (ProdutoId.HasValue)
        {
            var produto = await ProdutoService.ObterPorIdAsync(ProdutoId.Value);
            if (produto is not null)
            {
                model = new CriarProdutoRequest
                {
                    Nome = produto.Nome,
                    Descricao = produto.Descricao,
                    Preco = produto.Preco,
                    QuantidadeEstoque = produto.QuantidadeEstoque,
                    CategoriaId = produto.CategoriaId,
                    Ativo = produto.Ativo
                };
            }
        }
    }

    async Task OnSubmit()
    {
        try
        {
            if (ProdutoId.HasValue)
            {
                var request = new AtualizarProdutoRequest
                {
                    Nome = model.Nome,
                    Descricao = model.Descricao,
                    Preco = model.Preco,
                    QuantidadeEstoque = model.QuantidadeEstoque,
                    CategoriaId = model.CategoriaId,
                    Ativo = model.Ativo
                };
                await ProdutoService.AtualizarAsync(ProdutoId.Value, request);
            }
            else
            {
                await ProdutoService.CriarAsync(model);
            }

            DialogService.Close(true);
        }
        catch (HttpRequestException)
        {
            NotificationService.Notify(new NotificationMessage
            {
                Severity = NotificationSeverity.Error,
                Summary = "Erro ao salvar",
                Detail = "Não foi possível salvar o produto.",
                Duration = 6000
            });
        }
    }
}

O fluxo completo é: usuário clica “Novo Produto” → DialogService.OpenAsync<ProdutoForm>() abre o dialog → o form carrega categorias e, se for edição, carrega o produto → usuário preenche/edita → validators verificam → OnSubmit chama a API → DialogService.Close(true) fecha o dialog → o grid detecta resultado is true e chama grid.Reload() → dados atualizados.

Para abrir o dialog a partir de Produtos.razor:

async Task AbrirFormulario(int? produtoId)
{
    var titulo = produtoId.HasValue ? "Editar Produto" : "Novo Produto";

    var resultado = await DialogService.OpenAsync<ProdutoForm>(titulo,
        new Dictionary<string, object?> { { "ProdutoId", produtoId } },
        new DialogOptions
        {
            Width = "600px",
            CloseDialogOnOverlayClick = false,
            CloseDialogOnEsc = true
        });

    if (resultado is true)
    {
        await grid.Reload();
    }
}

O dicionário { "ProdutoId", produtoId } passa o parâmetro para o componente ProdutoForm. O DialogOptions controla a largura do dialog e se ele fecha ao clicar fora ou pressionar Escape.

📝 Exemplo: Fluxo de edição — clique no botão “Editar” de um produto → dialog abre com título “Editar Produto” → campos preenchidos com dados atuais → altere o preço → clique Salvar → notificação verde “Produto atualizado” → grid recarrega com preço novo.

Exclusão com Confirmação e Notificações

Toda operação destrutiva deve ter uma etapa de confirmação. O DialogService.Confirm() do Radzen renderiza um dialog nativo com botões customizáveis:

async Task ConfirmarExclusao(ProdutoDto produto)
{
    var confirmado = await DialogService.Confirm(
        $"Deseja excluir o produto \"{produto.Nome}\"?",
        "Confirmar Exclusão",
        new ConfirmOptions
        {
            OkButtonText = "Excluir",
            CancelButtonText = "Cancelar"
        });

    if (confirmado == true)
    {
        try
        {
            await ProdutoService.RemoverAsync(produto.Id);
            NotificationService.Notify(new NotificationMessage
            {
                Severity = NotificationSeverity.Success,
                Summary = "Produto excluído",
                Detail = $"\"{produto.Nome}\" foi removido com sucesso.",
                Duration = 4000
            });
            await grid.Reload();
        }
        catch (HttpRequestException)
        {
            NotificationService.Notify(new NotificationMessage
            {
                Severity = NotificationSeverity.Error,
                Summary = "Erro ao excluir",
                Detail = "Não foi possível excluir o produto. Tente novamente.",
                Duration = 6000
            });
        }
    }
}

As notificações do Radzen (NotificationService.Notify) aparecem como toasts no canto da tela. Uso NotificationSeverity.Success com duração de 4 segundos para operações bem-sucedidas e NotificationSeverity.Error com 6 segundos para erros — mais tempo para o usuário ler a mensagem. O pattern de try/catch com HttpRequestException é simples mas eficaz: se a API retornar erro (ex: produto não encontrado), o catch exibe feedback imediato ao usuário.

Gerenciamento de Categorias — Inline Editing

Para demonstrar um pattern alternativo ao dialog, a tela de Categorias usa inline editing — o usuário edita diretamente na grid, sem abrir modal. Esse approach funciona bem para entidades simples com poucos campos.

@page "/categorias"

<RadzenDataGrid @ref="grid" TItem="CategoriaDto"
                Data="@categorias" AllowSorting="true"
                EditMode="DataGridEditMode.Single"
                RowUpdate="@OnRowUpdate" RowCreate="@OnRowCreate"
                Style="width: 100%;">
    <Columns>
        <RadzenDataGridColumn TItem="CategoriaDto" Property="Id" Title="ID"
                              Width="70px" TextAlign="TextAlign.Center" />

        <RadzenDataGridColumn TItem="CategoriaDto" Property="Nome" Title="Nome"
                              MinWidth="200px">
            <EditTemplate Context="cat">
                <RadzenTextBox @bind-Value="cat.Nome" Style="width: 100%;" />
            </EditTemplate>
        </RadzenDataGridColumn>

        <RadzenDataGridColumn TItem="CategoriaDto" Property="Descricao"
                              Title="Descrição" MinWidth="250px">
            <EditTemplate Context="cat">
                <RadzenTextBox @bind-Value="cat.Descricao" Style="width: 100%;" />
            </EditTemplate>
        </RadzenDataGridColumn>

        <RadzenDataGridColumn TItem="CategoriaDto" Property="Ativo" Title="Status"
                              Width="100px" TextAlign="TextAlign.Center">
            <Template Context="cat">
                <RadzenBadge BadgeStyle="@(cat.Ativo ? BadgeStyle.Success : BadgeStyle.Light)"
                             Text="@(cat.Ativo ? "Ativo" : "Inativo")" />
            </Template>
            <EditTemplate Context="cat">
                <RadzenSwitch @bind-Value="cat.Ativo" />
            </EditTemplate>
        </RadzenDataGridColumn>

        <RadzenDataGridColumn TItem="CategoriaDto" Title="Ações" Width="180px"
                              TextAlign="TextAlign.Center" Sortable="false">
            <Template Context="cat">
                <RadzenButton Icon="edit" ButtonStyle="ButtonStyle.Light"
                              Size="ButtonSize.Small"
                              Click="@(() => EditarLinha(cat))" class="rz-mr-1" />
                <RadzenButton Icon="delete" ButtonStyle="ButtonStyle.Danger"
                              Size="ButtonSize.Small"
                              Click="@(() => ConfirmarExclusao(cat))" />
            </Template>
            <EditTemplate Context="cat">
                <RadzenButton Icon="check" ButtonStyle="ButtonStyle.Success"
                              Size="ButtonSize.Small"
                              Click="@(() => SalvarLinha(cat))" class="rz-mr-1" />
                <RadzenButton Icon="close" ButtonStyle="ButtonStyle.Light"
                              Size="ButtonSize.Small"
                              Click="@CancelarEdicao" />
            </EditTemplate>
        </RadzenDataGridColumn>
    </Columns>
</RadzenDataGrid>

A diferença principal está no EditMode="DataGridEditMode.Single": quando o usuário clica em “Editar”, a linha entra em modo de edição — os campos de texto e o switch aparecem no lugar dos valores estáticos. Os botões mudam de “Editar/Excluir” para “Salvar/Cancelar”.

A lógica de edição inline:

@code {
    RadzenDataGrid<CategoriaDto> grid = default!;
    IList<CategoriaDto> categorias = [];
    CategoriaDto? categoriaEditando;

    async Task InserirNova()
    {
        var nova = new CategoriaDto { Ativo = true };
        categorias.Insert(0, nova);
        categoriaEditando = nova;
        await grid.EditRow(nova);
    }

    async Task EditarLinha(CategoriaDto cat)
    {
        categoriaEditando = cat;
        await grid.EditRow(cat);
    }

    async Task SalvarLinha(CategoriaDto cat)
    {
        await grid.UpdateRow(cat);
    }

    async Task OnRowUpdate(CategoriaDto cat)
    {
        var request = new AtualizarCategoriaRequest
        {
            Nome = cat.Nome,
            Descricao = cat.Descricao,
            Ativo = cat.Ativo
        };
        await CategoriaService.AtualizarAsync(cat.Id, request);
        categoriaEditando = null;
        await CarregarDados();
    }

    async Task OnRowCreate(CategoriaDto cat)
    {
        var request = new CriarCategoriaRequest
        {
            Nome = cat.Nome,
            Descricao = cat.Descricao,
            Ativo = cat.Ativo
        };
        await CategoriaService.CriarAsync(request);
        categoriaEditando = null;
        await CarregarDados();
    }
}

O fluxo para “Nova Categoria” é: inserir um objeto vazio na posição 0 da lista → grid.EditRow() coloca essa linha em modo de edição → usuário preenche → SalvarLinha chama grid.UpdateRow()OnRowCreate é disparado (porque Id == 0) → API chamada → dados recarregados.

Quando usar inline editing vs dialog:

Cenário Inline Editing Dialog
Entidades simples (2-4 campos) ✅ Ideal Overkill
Entidades complexas (5+ campos, dropdowns) Confuso ✅ Ideal
Campos com validação elaborada Limitado ✅ Mais espaço visual
Edição rápida e frequente ✅ Menos cliques Mais cliques
UX mobile ⚠️ Pode ficar apertado ✅ Melhor em telas pequenas

Dicas e Boas Práticas

  • Centralize chamadas HTTP em services tipados — nunca injete HttpClient diretamente no componente .razor. Isso viola separação de responsabilidades e dificulta testes. Services como ProdutoApiService encapsulam URLs, serialização e tratamento de erros em um único lugar reutilizável.

  • Use DataAnnotations + validators Radzen para validação duplaRadzenRequiredValidator e RadzenNumericRangeValidator validam no client; DataAnnotations no DTO de request validam no server. Se alguém bypassar o UI e chamar a API diretamente, a validação server-side ainda protege os dados.

  • RadzenNotification para TODA ação — feedback visual consistente em sucesso (“Produto criado”) e erro (“Não foi possível salvar”). Defina duração diferente: 4 segundos para sucesso, 6+ para erros (o usuário precisa de mais tempo para ler a mensagem de erro).

  • LoadData event para paginação server-side — nunca carregue todos os dados no client com uma lista completa. Com LoadData, apenas a página atual é transferida pela rede. Para um catálogo com 10.000 produtos, carregar tudo na memória do browser é inviável; com paginação server-side, cada request traz apenas 20 registros.

  • Componentize forms reutilizáveisProdutoForm serve para criação E edição. A distinção é um Parameter nullable (int? ProdutoId). Esse pattern elimina duplicação de código e garante consistência entre os fluxos de criação e edição.

  • Configure appsettings.json para URL da API — nunca faça hardcode de URLs. O wwwroot/appsettings.json no Blazor WASM funciona como arquivo de configuração por ambiente. Em produção, substitua por appsettings.Production.json com a URL real da API.

  • Implemente loading state no DataGrid — a propriedade IsLoading do RadzenDataGrid exibe um spinner enquanto a API é chamada. Sem feedback visual, o usuário não sabe se a ação foi disparada ou se a aplicação travou. Defina isLoading = true antes da chamada e false depois.

  • Configure AOT + Trimming para produção — o bundle size do Blazor WASM é uma preocupação real. Para produção, habilite AOT compilation e trimming no .csproj:

<PropertyGroup>
  <RunAOTCompilation>true</RunAOTCompilation>
  <PublishTrimmed>true</PublishTrimmed>
</PropertyGroup>

AOT compila o IL para WebAssembly nativo (execução mais rápida), e trimming remove código não utilizado (bundle menor). O trade-off é tempo de build significativamente maior.

Conclusão

Neste tutorial, construí um CRUD completo com Blazor WebAssembly .NET 10 e Radzen Blazor: DataGrid com paginação server-side e busca, formulários com validação client-side e server-side, dialogs modais para criação e edição, inline editing para entidades simples, exclusão com confirmação e notificações visuais para feedback — tudo em C#, sem escrever uma linha de JavaScript pelo desenvolvedor.

Radzen Blazor entrega componentes visuais maduros para o cenário CRUD corporativo. O RadzenDataGrid com LoadData resolve paginação server-side de forma elegante. Os validators (RadzenRequiredValidator, RadzenNumericRangeValidator) funcionam bem para validação básica. O DialogService e NotificationService cobrem o fluxo completo de interação com o usuário.

Mas é importante ser transparente sobre as limitações que encontrei durante o desenvolvimento:

  • Radzen.Blazor.js é necessário — apesar do slogan “zero JavaScript”, a biblioteca depende de JS interno para renderizar componentes complexos. Não é JavaScript escrito pelo desenvolvedor, mas é JS rodando no browser.
  • Two-way binding requer classes, não records@bind-Value do Radzen precisa de setters mutáveis. Records com init não funcionam para formulários de edição. Isso força o uso de classes para DTOs no Blazor WASM.
  • O bundle size continua significativo — o runtime do .NET + Radzen + a aplicação resultam em um download inicial considerável. AOT e trimming ajudam, mas não resolvem completamente. Como analisei no artigo anterior, Blazor WASM é viável para contextos corporativos — e este tutorial demonstra que o ecossistema de componentes suporta cenários reais. A decisão entre Blazor WASM e frameworks JavaScript como Angular ou React depende do perfil da equipe, requisitos de SEO/SSR e tolerância ao bundle size, conforme discuti no comparativo.

Clone o repositório, rode a API e o Blazor WASM, e forme sua própria opinião. O código completo está em frontend/blazor-wasm/ e src/BlogSamples/Produtos/.

Leia Também

  • Seu Próximo Frontend Será C#? A Verdade Sobre Blazor WASM — comparativo teórico Blazor vs Angular (1º artigo da série “Frontend Moderno”)
  • Design de APIs REST: Verbos HTTP e Parameter Binding — a estrutura de Minimal API que o Blazor WASM consome neste tutorial
  • Log Sem Contexto é Ruído: Logging Estruturado no .NET 8 — ecossistema .NET maduro com logging, métricas e tracing
  • Autenticação e Autorização: JWT, OAuth2 e OpenID Connect — próximo passo: proteger a SPA Blazor com Entra ID

Referências

  • Blazor WebAssembly — Documentação oficial — Microsoft Learn, referência completa sobre Blazor
  • ASP.NET Core 10.0 Release Notes — novidades do .NET 10 para web
  • Radzen Blazor Components — Get Started — setup e guia inicial da biblioteca
  • Radzen Blazor — GitHub — código fonte (MIT) com exemplos e issues
  • Radzen DataGrid — documentação e demos interativos do componente central
  • blog-zocateli-sample — GitHub — repositório com todo o código deste artigo
  • WebAssembly — especificação oficial do padrão W3C
  • Blazor WASM Standalone Deployment — guia de deploy para produção 📬

👉 Artigo completo com todos os exemplos de código: Zero JavaScript: CRUD Completo com Blazor WASM e Radzen

Paginação em APIs REST com C# e EF Core: Guia Prático

2026-04-15 10:45:56

Introdução

Paginar resultados parece simples: adicione .Skip(offset).Take(pageSize) e pronto. Mas quando a tabela tem 10 milhões de registros, o usuário está na página 5.000, o banco é Oracle 11g, ou o cliente exige scroll infinito sem duplicatas — essa simplicidade desaparece rapidamente.

Paginação é uma das decisões arquiteturais mais impactantes em APIs REST, e a escolha errada pode significar queries de 8 segundos, resultados inconsistentes ou um backend que trava sob carga. Existem pelo menos cinco estratégias distintas, cada uma com trade-offs claros: Offset, Keyset (Seek), Cursor de banco, Token opaco e Time-based. Cada banco de dados — SQL Server, Oracle e PostgreSQL — implementa essas estratégias com sintaxes e comportamentos ligeiramente diferentes.

Neste artigo você vai entender em profundidade quando usar cada estratégia, como cada banco a implementa, e como codificar tudo com EF Core 8.0+ em C#. Se você quer uma visão mais ampla sobre gargalos de leitura e escrita em banco de dados, leia também o artigo Gargalo em Banco de Dados com C# e EF Core: Mensageria e Paginação.

Pré-requisitos: C# intermediário, EF Core básico, familiaridade com SQL. Recomenda-se também o artigo sobre programação assíncrona com C#.

📦 Código-fonte: A implementação completa deste artigo está no repositório blog-zocateli-sample no GitHub. Clone, explore e adapte ao seu contexto.

Por Que Paginação Errada Destrói a Performance

Antes de ver as estratégias, vale entender o que acontece internamente em cada banco quando você pagina:

O Problema do OFFSET Profundo

-- SQL Server: página 5000 com 20 itens por página
SELECT Id, Nome, Valor FROM Pedidos
ORDER BY DataCriacao
OFFSET 99980 ROWS FETCH NEXT 20 ROWS ONLY;

O banco não “pula” para a linha 99.981 magicamente. Ele precisa:

  • Ordenar (ou usar o índice de ordenação)
  • Varrer 99.980 linhas e descartá-las
  • Retornar as próximas 20 O custo cresce linearmente com o offset. Para OFFSET 0 o custo é quase zero; para OFFSET 1.000.000 o custo pode ser segundos. Isso é o problema do late pagination, e se manifesta em qualquer banco.

Por Que Ordering Estável é Mandatório

// ❌ SEM OrderBy — resultado não determinístico
var dados = await context.Pedidos.Skip(40).Take(20).ToListAsync();

// ✅ COM OrderBy — resultado determinístico + índice pode ser usado
var dados = await context.Pedidos
    .OrderBy(p => p.DataCriacao)
    .ThenBy(p => p.Id)        // Desempate pelo campo único garante estabilidade
    .Skip(40).Take(20)
    .ToListAsync();

Sem OrderBy, o banco pode retornar os 20 itens em qualquer ordem, e você corre o risco de ver os mesmos registros em páginas diferentes ou pular registros. Isso é especialmente crítico no Oracle, que tem comportamento de ordenação menos previsível que o SQL Server por padrão.

Estratégia 1: Offset Pagination (SKIP / TAKE)

Quando Usar

  • UIs com número de páginas visível (página 1, 2, 3&mldr;)
  • Relatórios com totais exatos necessários
  • Volumes pequenos ou médios (até ~100k registros na tabela)
  • Quando o usuário precisa saltar diretamente para qualquer página

O SQL por Banco

-- SQL Server (2012+)
SELECT Id, ClienteId, Valor, DataCriacao
FROM Pedidos
ORDER BY DataCriacao, Id
OFFSET 40 ROWS FETCH NEXT 20 ROWS ONLY;

-- Oracle 12c+
SELECT Id, ClienteId, Valor, DataCriacao
FROM Pedidos
ORDER BY DataCriacao, Id
OFFSET 40 ROWS FETCH NEXT 20 ROWS ONLY;

-- Oracle 11g (sem suporte nativo — ROW_NUMBER workaround)
SELECT * FROM (
    SELECT p.*, ROWNUM AS rn
    FROM (
        SELECT Id, ClienteId, Valor, DataCriacao
        FROM Pedidos
        ORDER BY DataCriacao, Id
    ) p
    WHERE ROWNUM <= 60
)
WHERE rn > 40;

-- PostgreSQL
SELECT Id, ClienteId, Valor, DataCriacao
FROM Pedidos
ORDER BY DataCriacao, Id
LIMIT 20 OFFSET 40;

💡 Dica: O provider Oracle.EntityFrameworkCore detecta automaticamente a versão do Oracle e gera a sintaxe correta (com FETCH FIRST para 12c+ ou ROWNUM para 11g). Não precisa escrever SQL raw para isso.

Implementação com EF Core 8

// Models reutilizáveis para toda a API
public record PaginacaoRequest(
    int Pagina      = 1,
    int TamanhoPagina = 20)
{
    public int TamanhoSeguro => Math.Clamp(TamanhoPagina, 1, 100);
    public int OffsetSeguro  => (Math.Max(Pagina, 1) - 1) * TamanhoSeguro;
}

public record PaginaResultado<T>(
    IReadOnlyList<T>  Dados,
    int               PaginaAtual,
    int               TamanhoPagina,
    long              TotalRegistros,
    int               TotalPaginas,
    bool              TemProxima,
    bool              TemAnterior);

public class PedidoOffsetService(AppDbContext context)
{
    public async Task<PaginaResultado<PedidoDto>> ListarAsync(
        PaginacaoRequest req,
        CancellationToken ct = default)
    {
        var query = context.Pedidos
            .AsNoTracking()
            .OrderBy(p => p.DataCriacao)
            .ThenBy(p => p.Id);

        // EF Core 8: ExecuteCount() separado sem materializar a coleção
        var total = await query.LongCountAsync(ct);

        var dados = await query
            .Skip(req.OffsetSeguro)
            .Take(req.TamanhoSeguro)
            .Select(p => new PedidoDto(p.Id, p.ClienteId, p.Valor, p.DataCriacao, p.Status))
            .ToListAsync(ct);

        var totalPaginas = (int)Math.Ceiling(total / (double)req.TamanhoSeguro);

        return new PaginaResultado<PedidoDto>(
            Dados:          dados,
            PaginaAtual:    req.Pagina,
            TamanhoPagina:  req.TamanhoSeguro,
            TotalRegistros: total,
            TotalPaginas:   totalPaginas,
            TemProxima:     req.Pagina < totalPaginas,
            TemAnterior:    req.Pagina > 1);
    }
}

⚠️ Atenção: Para tabelas com mais de 500k linhas, o LongCountAsync() por si só pode ser lento (table scan). Uma estratégia comum é cachear o total por 30–60 segundos, ou usar uma coluna de contagem materializada.

Estratégia 2: Keyset Pagination (Seek / WHERE-based)

Quando Usar

  • APIs REST com scroll infinito ou “next page” token
  • Tabelas com milhões de registros onde o OFFSET degrada
  • Feeds de dados em tempo real onde novos itens são inseridos constantemente
  • Exportação assíncrona de grandes volumes

Como Funciona

Em vez de dizer “pule N linhas”, você diz “me dê os registros após este ponto de referência”. O banco usa o índice diretamente para posicionar-se no cursor, sem varrer os registros anteriores.

-- SQL Server / Oracle 12c+ / PostgreSQL — keyset com chave composta
SELECT TOP(21) Id, ClienteId, Valor, DataCriacao
FROM Pedidos
WHERE DataCriacao > '2025-06-01T12:00:00'
   OR (DataCriacao = '2025-06-01T12:00:00' AND Id > 'abc-def-123')
ORDER BY DataCriacao ASC, Id ASC;

O TOP 21 (ou LIMIT 21) é intencional: busca-se um registro a mais para saber se há próxima página, sem fazer um COUNT.

Índice Obrigatório para Keyset

-- SQL Server — índice covering para a query de keyset
CREATE INDEX IX_Pedidos_Keyset
    ON Pedidos (DataCriacao ASC, Id ASC)
    INCLUDE (ClienteId, Valor, Status);

-- Oracle
CREATE INDEX IX_Pedidos_Keyset ON Pedidos (DataCriacao ASC, Id ASC);

-- PostgreSQL
CREATE INDEX IX_Pedidos_Keyset ON Pedidos (DataCriacao ASC, Id ASC)
    INCLUDE (ClienteId, Valor, Status);

Sem esse índice, o banco recai em um full scan mesmo com a cláusula WHERE do keyset.

Implementação com EF Core 8

// Token de cursor — serializado em Base64 para o cliente (opaco)
public record KeysetCursor(Guid UltimoId, DateTime UltimaData)
{
    public string Encode() =>
        Convert.ToBase64String(
            JsonSerializer.SerializeToUtf8Bytes(this));

    public static KeysetCursor? Decode(string? token)
    {
        if (string.IsNullOrEmpty(token)) return null;
        try
        {
            return JsonSerializer.Deserialize<KeysetCursor>(
                Convert.FromBase64String(token));
        }
        catch { return null; }
    }
}

public record KeysetResultado<T>(
    IReadOnlyList<T> Dados,
    bool             TemProximaPagina,
    string?          ProximoToken);   // Token opaco para o cliente

public class PedidoKeysetService(AppDbContext context)
{
    public async Task<KeysetResultado<PedidoDto>> ListarAsync(
        string?           token,
        int               limite   = 20,
        CancellationToken ct       = default)
    {
        limite = Math.Clamp(limite, 1, 100);
        var cursor = KeysetCursor.Decode(token);

        // Expressão de keyset — funciona sem cursor (primeira página)
        // e com cursor (páginas seguintes)
        var query = context.Pedidos
            .AsNoTracking()
            .Where(p =>
                cursor == null ||
                p.DataCriacao > cursor.UltimaData ||
                (p.DataCriacao == cursor.UltimaData &&
                 p.Id.CompareTo(cursor.UltimoId) > 0))
            .OrderBy(p => p.DataCriacao)
            .ThenBy(p => p.Id)
            .Select(p => new PedidoDto(p.Id, p.ClienteId, p.Valor, p.DataCriacao, p.Status));

        // Busca limite+1 para detectar se há próxima página
        var dados = await query.Take(limite + 1).ToListAsync(ct);

        var temProxima = dados.Count > limite;
        if (temProxima) dados.RemoveAt(dados.Count - 1);

        string? proximoToken = null;
        if (temProxima && dados.Count > 0)
        {
            var ultimo = dados[^1];
            proximoToken = new KeysetCursor(ultimo.Id, ultimo.DataCriacao).Encode();
        }

        return new KeysetResultado<PedidoDto>(dados, temProxima, proximoToken);
    }
}

O token gerado é opaco para o cliente — ele não sabe o que está dentro, apenas passou para a próxima chamada. Isso permite mudar a implementação interna sem quebrar os clientes.

Estratégia 3: Cursor de Banco de Dados (Server-Side Cursor)

O Que é um Cursor de Banco e Quando Usar

Um cursor de banco de dados é diferente do keyset cursor. Aqui, o próprio banco mantém uma posição de leitura aberta entre as chamadas. O servidor reserva recursos (memória, locks ou um snapshot) para aquele conjunto de resultados enquanto o cliente vai buscando blocos.

É a estratégia ideal para:

  • Exportações longas onde o cliente consome os dados em blocos ao longo de segundos ou minutos
  • Processamento ETL linha por linha sem carregar tudo na memória
  • Streaming de dados via WebSocket ou Server-Sent Events
  • Situações onde o conjunto de resultados não pode mudar durante a leitura (snapshot consistency) > ⚠️ Atenção: Cursores de banco consomem recursos do servidor enquanto estão abertos. Em SQL Server e Oracle, cursores mal gerenciados (esquecidos abertos) causam memory pressure e até bloqueios. PostgreSQL lida melhor com cursores em transações, mas ainda exige cuidado.

PostgreSQL: DECLARE CURSOR (o mais completo)

O PostgreSQL tem o suporte mais rico a cursores server-side. Para usá-los com EF Core, é necessário executar SQL raw dentro de uma transação, pois os cursores do PostgreSQL existem apenas no escopo de uma transação:

// dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
public class PedidoCursorService(AppDbContext context)
{
    public async IAsyncEnumerable<PedidoDto> StreamAsync(
        int blocoPorFetch = 500,
        [EnumeratorCancellation] CancellationToken ct = default)
    {
        // Cursor PG exige transação ativa durante toda a leitura
        await using var transaction = await context.Database
            .BeginTransactionAsync(ct);

        // DECLARE abre o cursor no servidor
        await context.Database.ExecuteSqlRawAsync(
            "DECLARE pedidos_cursor NO SCROLL CURSOR FOR " +
            "SELECT p.\"Id\", p.\"ClienteId\", p.\"Valor\", p.\"DataCriacao\", p.\"Status\" " +
            "FROM \"Pedidos\" p ORDER BY p.\"DataCriacao\", p.\"Id\"",
            ct);

        try
        {
            while (!ct.IsCancellationRequested)
            {
                // FETCH busca um bloco por vez — nunca tudo na memória
                var bloco = await context.Database
                    .SqlQueryRaw<PedidoDto>(
                        $"FETCH {blocoPorFetch} FROM pedidos_cursor")
                    .ToListAsync(ct);

                if (bloco.Count == 0) break;   // Fim do cursor

                foreach (var item in bloco)
                    yield return item;

                if (bloco.Count < blocoPorFetch) break; // Último bloco parcial
            }
        }
        finally
        {
            // CLOSE libera recursos no servidor — SEMPRE executar
            await context.Database.ExecuteSqlRawAsync(
                "CLOSE pedidos_cursor", CancellationToken.None);

            await transaction.CommitAsync(CancellationToken.None);
        }
    }
}

SQL Server: FAST_FORWARD Cursor via Raw SQL

O SQL Server suporta cursores T-SQL, mas para APIs REST o padrão mais eficiente é usar IAsyncEnumerable com AsAsyncEnumerable() do EF Core ou um cursor via ADO.NET direto:

// SQL Server: streaming com IAsyncEnumerable do EF Core 8
// Internamente usa DataReader que consome linha por linha
public class PedidoStreamService(AppDbContext context)
{
    public async IAsyncEnumerable<PedidoDto> StreamComEfCoreAsync(
        DateTime? dataInicio = null,
        [EnumeratorCancellation] CancellationToken ct = default)
    {
        // AsAsyncEnumerable() não materializa a coleção —
        // mantém o DataReader aberto e lê sob demanda
        var query = context.Pedidos
            .AsNoTracking()
            .Where(p => dataInicio == null || p.DataCriacao >= dataInicio)
            .OrderBy(p => p.DataCriacao)
            .ThenBy(p => p.Id)
            .Select(p => new PedidoDto(p.Id, p.ClienteId, p.Valor, p.DataCriacao, p.Status));

        await foreach (var item in query.AsAsyncEnumerable().WithCancellation(ct))
        {
            yield return item;
        }
    }
}

💡 Dica: AsAsyncEnumerable() no EF Core é implementado como um DataReader que permanece aberto enquanto o IAsyncEnumerable está sendo consumido. Internamente, ele funciona como um cursor de banco implicit. A diferença para um cursor SQL explícito é que o DataReader não permite pausar a leitura e retomar mais tarde em uma nova conexão.

Oracle: REF CURSOR e SYS_REFCURSOR

No Oracle, o equivalente é o REF CURSOR, geralmente exposto via stored procedure. Com o provider Oracle.EntityFrameworkCore, você pode executá-lo assim:

// Oracle: REF CURSOR via stored procedure
// A procedure retorna um SYS_REFCURSOR como parâmetro OUT
public class PedidoOracleService(AppDbContext context)
{
    public async Task<List<PedidoDto>> BuscarViaCursorAsync(
        DateTime dataInicio,
        int quantidade,
        CancellationToken ct = default)
    {
        var param_dataInicio = new OracleParameter("p_data",   OracleDbType.Date)
            { Value = dataInicio };
        var param_qtd        = new OracleParameter("p_qtd",    OracleDbType.Int32)
            { Value = quantidade };
        var param_cursor     = new OracleParameter("p_cursor", OracleDbType.RefCursor)
            { Direction = ParameterDirection.Output };

        // Chama a stored procedure que faz OPEN cursor FOR SELECT ...
        await context.Database.ExecuteSqlRawAsync(
            "BEGIN PKG_PEDIDOS.BUSCAR_PAGINADO(:p_data, :p_qtd, :p_cursor); END;",
            param_dataInicio, param_qtd, param_cursor, ct);

        // Lê o REF CURSOR retornado pelo Oracle
        var refCursor = (OracleRefCursor)param_cursor.Value;
        using var reader = refCursor.GetDataReader();

        var resultado = new List<PedidoDto>();
        while (await reader.ReadAsync(ct))
        {
            resultado.Add(new PedidoDto(
                Id:          reader.GetGuid(0),
                ClienteId:   reader.GetString(1),
                Valor:       reader.GetDecimal(2),
                DataCriacao: reader.GetDateTime(3),
                Status:      reader.GetString(4)));
        }
        return resultado;
    }
}

Estratégia 4: Time-based Pagination

Quando Usar

  • Dados que têm dimensão temporal natural (logs, eventos, transações)
  • APIs de auditoria ou histórico com filtros por janela de tempo
  • Quando o cliente quer dados de uma hora específica (e.g. “pedidos de ontem”)
  • Integração com sistemas de streaming (Kafka, Event Sourcing)

Implementação

A paginação time-based é uma forma especializada de keyset, com a coluna de data como cursor primário. A diferença é que o cliente escolhe a janela de tempo explicitamente:

public record TimePaginacaoRequest(
    DateTime  DataInicio,
    DateTime  DataFim,
    DateTime? UltimaDataVista = null,  // Cursor dentro da janela
    Guid?     UltimoIdVisto   = null,
    int       Limite          = 50);

public class PedidoTimeService(AppDbContext context)
{
    public async Task<KeysetResultado<PedidoDto>> ListarPorJanelaAsync(
        TimePaginacaoRequest req,
        CancellationToken    ct = default)
    {
        var limite = Math.Clamp(req.Limite, 1, 200);

        var query = context.Pedidos
            .AsNoTracking()
            // Janela de tempo fixa — mantém o conjunto estável
            .Where(p => p.DataCriacao >= req.DataInicio &&
                        p.DataCriacao <  req.DataFim)
            // Cursor dentro da janela (para paginação sequencial)
            .Where(p =>
                req.UltimaDataVista == null ||
                p.DataCriacao > req.UltimaDataVista ||
                (p.DataCriacao == req.UltimaDataVista &&
                 p.Id.CompareTo(req.UltimoIdVisto!.Value) > 0))
            .OrderBy(p => p.DataCriacao)
            .ThenBy(p => p.Id)
            .Select(p => new PedidoDto(p.Id, p.ClienteId, p.Valor, p.DataCriacao, p.Status));

        var dados = await query.Take(limite + 1).ToListAsync(ct);
        var temProxima = dados.Count > limite;
        if (temProxima) dados.RemoveAt(dados.Count - 1);

        string? proximoToken = null;
        if (temProxima && dados.Count > 0)
        {
            var ultimo = dados[^1];
            proximoToken = new KeysetCursor(ultimo.Id, ultimo.DataCriacao).Encode();
        }

        return new KeysetResultado<PedidoDto>(dados, temProxima, proximoToken);
    }
}

💡 Dica: A grande vantagem da paginação time-based sobre keyset puro é que o cliente pode escolher janelas imutáveis (e.g. “todo o dia 2025-01-01”), o que permite cache agressivo dessas janelas no servidor, já que o conteúdo não muda depois que a janela fecha.

Estratégia 5: Token Opaco e Link HATEOAS

Quando Usar

  • APIs públicas onde você não quer expor detalhes de implementação ao cliente
  • Quando a estratégia de paginação pode mudar sem quebrar os clientes
  • APIs que precisam de HATEOAS (links de próxima/anterior página na resposta) O token opaco encapsula qualquer estratégia de cursor internamente:
// Resposta no padrão HATEOAS com links de navegação
public record PaginacaoHateoasResultado<T>(
    IReadOnlyList<T> Dados,
    PaginacaoLinks   Links,
    PaginacaoMeta    Meta);

public record PaginacaoLinks(
    string?  Primeiro,
    string?  Anterior,
    string?  Proximo,
    string?  Ultimo);

public record PaginacaoMeta(
    int  Limite,
    long TotalRegistros,
    bool TemProxima);

// Endpoint que monta a resposta HATEOAS
app.MapGet("/api/v1/pedidos", async (
    HttpContext          httpCtx,
    [FromQuery] string?  cursor,
    [FromQuery] int      limite = 20,
    PedidoKeysetService  service,
    CancellationToken    ct) =>
{
    var resultado = await service.ListarAsync(cursor, limite, ct);
    var baseUrl   = $"{httpCtx.Request.Scheme}://{httpCtx.Request.Host}/api/v1/pedidos";

    return Results.Ok(new PaginacaoHateoasResultado<PedidoDto>(
        Dados:  resultado.Dados,
        Links:  new PaginacaoLinks(
            Primeiro:  $"{baseUrl}?limite={limite}",
            Anterior:  null,  // Keyset não suporta voltar sem histórico
            Proximo:   resultado.TemProximaPagina
                           ? $"{baseUrl}?cursor={resultado.ProximoToken}&limite={limite}"
                           : null,
            Ultimo:    null),
        Meta: new PaginacaoMeta(
            Limite:          limite,
            TotalRegistros:  -1,  // Keyset não calcula total
            TemProxima:      resultado.TemProximaPagina)));
});

Comparativo: Qual Estratégia Usar em Cada Situação

Critério Offset Keyset / Seek Cursor Server-Side Time-based Token Opaco
Total de registros ✅ Sim ❌ Não ❌ Não ⚠️ Por janela ❌ Não
Salto de página ✅ Direto ❌ Apenas sequencial ❌ Apenas sequencial ✅ Por janela ❌ Apenas sequencial
Performance em pg. tardias ❌ Degrada ✅ Constante ✅ Constante ✅ Constante ✅ Constante
Registros novos entre páginas ❌ Drift ✅ Consistente ✅ Snapshot ✅ Janela fixa ✅ Consistente
Complexidade de impl. ⭐ Simples ⭐⭐ Média ⭐⭐⭐ Alta ⭐⭐ Média ⭐⭐ Média
Recursos no servidor Baixo Baixo 🔴 Alto (cursor aberto) Baixo Baixo
Suporte EF Core nativo ✅ Completo ✅ Com Where custom ⚠️ Raw SQL / ADO ✅ Com Where custom ✅ Sobre keyset
SQL Server ✅ OFFSET FETCH ✅ WHERE seek ✅ FAST_FORWARD cursor ✅ WHERE range
Oracle ✅ 12c+ / ROWNUM 11g ✅ WHERE seek ✅ REF CURSOR ✅ WHERE range
PostgreSQL ✅ LIMIT OFFSET ✅ WHERE seek ✅ DECLARE CURSOR ✅ WHERE range
Melhor para UI clássica APIs / mobile ETL / streaming Auditoria / logs APIs públicas

Índices: A Fundação de Toda Paginação

Nenhuma estratégia de paginação funciona bem sem os índices certos. Para cada banco:

-- ============================================
-- SQL Server — Índices para paginação
-- ============================================

-- Para Offset por DataCriacao
CREATE INDEX IX_Pedidos_DataCriacao
    ON Pedidos (DataCriacao ASC, Id ASC)
    INCLUDE (ClienteId, Valor, Status);

-- Para filtros frequentes + paginação
CREATE INDEX IX_Pedidos_ClienteId_Data
    ON Pedidos (ClienteId ASC, DataCriacao ASC, Id ASC)
    INCLUDE (Valor, Status);

-- ============================================
-- Oracle — Equivalentes
-- ============================================
CREATE INDEX IX_Pedidos_DataCriacao
    ON Pedidos (DataCriacao ASC, Id ASC);

-- Oracle: statistics importantes para o otimizador
EXECUTE DBMS_STATS.GATHER_TABLE_STATS('SCHEMA', 'PEDIDOS');

-- ============================================
-- PostgreSQL — Com partial index opcional
-- ============================================
CREATE INDEX IX_Pedidos_DataCriacao
    ON Pedidos (DataCriacao ASC, Id ASC)
    INCLUDE (ClienteId, Valor, Status);

-- Partial index para status frequente (ex.: apenas pedidos ativos)
CREATE INDEX IX_Pedidos_Ativos
    ON Pedidos (DataCriacao ASC, Id ASC)
    WHERE Status = 'Ativo';

⚠️ Atenção Oracle: O Oracle não suporta INCLUDE columns em índices regulares (diferente de SQL Server e PostgreSQL). Para covering indexes no Oracle, use Composite Indexes ou Index-Organized Tables (IOT) para tabelas de alto volume de leitura.

Middleware de Paginação Reutilizável para ASP.NET Core

Para APIs REST com múltiplos endpoints, criar um middleware de paginação evita duplicação:

// Extensions para registrar os serviços e configurar resposta padrão
public static class PaginacaoExtensions
{
    // Header padrão de paginação (common em APIs REST)
    public static IEndpointConventionBuilder ComPaginacao(
        this IEndpointConventionBuilder builder)
    {
        // Adiciona headers de documentação no OpenAPI
        builder.WithOpenApi(op =>
        {
            op.Parameters.Add(new OpenApiParameter
            {
                Name     = "cursor",
                In       = ParameterLocation.Query,
                Required = false,
                Schema   = new OpenApiSchema { Type = "string" },
                Description = "Token de cursor para próxima página (keyset pagination)"
            });
            op.Parameters.Add(new OpenApiParameter
            {
                Name     = "limite",
                In       = ParameterLocation.Query,
                Required = false,
                Schema   = new OpenApiSchema { Type = "integer", Default = new OpenApiInteger(20) }
            });
            return op;
        });
        return builder;
    }

    // Adiciona Link header HTTP padrão RFC 5988
    public static void AdicionarLinkHeader<T>(
        this HttpContext ctx,
        KeysetResultado<T> resultado,
        string baseUrl)
    {
        if (resultado.ProximoToken != null)
            ctx.Response.Headers.Append(
                "Link",
                $"<{baseUrl}?cursor={resultado.ProximoToken}>; rel=\"next\"");
    }
}

Dicas e Boas Práticas

  • Nunca retorne sem Take/Limit: Sempre aplique um limite máximo na camada de serviço, independente do que o cliente enviou. Exponha isso via validação de request.
  • Keyset exige chave composta estável: Use sempre (coluna_ordenacao, id_unico) como cursor composto. Apenas a coluna de ordenação pode ter duplicatas, o Id garante unicidade e estabilidade.
  • Cursor de banco: sempre feche: Para cursores server-side (PostgreSQL DECLARE, Oracle REF CURSOR), use try/finally para garantir o CLOSE. Um cursor esquecido aberto no Oracle ou SQL Server consome recursos do servidor indefinidamente.
  • Documente o tipo de paginação na OpenAPI: Indique no Swagger qual estratégia cada endpoint usa — clientes precisam saber se podem usar salto de página ou apenas avançar sequencialmente.
  • Versione a estratégia de paginação: Se você mudar de Offset para Keyset em um endpoint existente, versione a API (/v2/pedidos) para não quebrar clientes que dependem do campo totalPaginas.
  • Monitor de queries lentas: Ative o Query Store (SQL Server), AWR (Oracle) ou pg_stat_statements (PostgreSQL) para capturar queries de paginação que ultrapassam SLAs.
  • Prefira projeções com Select(): Nunca retorne a entidade completa em endpoints de listagem. Selecione apenas os campos necessários para reduzir I/O e alocação de memória.

Conclusão

Paginar dados em APIs REST com C# e EF Core 8 vai muito além de .Skip().Take(). Cada estratégia — Offset, Keyset, Cursor server-side, Time-based e Token opaco — existe por um motivo e serve a cenários distintos. A escolha errada resulta em queries lentas, inconsistências de dados ou consumo desnecessário de recursos no banco.

O caminho mais seguro é: comece com Offset para UIs simples com volumes controlados, migre para Keyset conforme a tabela cresce e o offset começa a degradar, use Cursor server-side apenas para streaming e ETL com cuidado no gerenciamento do ciclo de vida, e Time-based para dados com dimensão temporal natural.

SQL Server, Oracle e PostgreSQL suportam todas essas estratégias — as diferenças estão na sintaxe e nos detalhes de implementação, mas o EF Core 8 abstrai a maior parte delas. O que o EF Core não faz por você é criar os índices certos: essa é sempre sua responsabilidade.

Se você chegou aqui buscando entender como gargalos de banco afetam não só a leitura, mas também a escrita em massa, continue com o artigo Gargalo em Banco de Dados com C# e EF Core: Mensageria e Paginação, que aborda a solução via mensageria (RabbitMQ e Azure Service Bus) para o lado da escrita.

Leia Também

  • EF Core 8 com Fluent API: Mapeamento, ORM e Desacoplamento Total
  • EF Core Migrations em Multi-Projeto: Secrets, Scaffolding e Gestão em Times
  • Full-Text Search em APIs REST com C#: SQL Server, PostgreSQL e Oracle
  • Arquitetura de Software e os Padrões GoF: do Código à Nuvem, do Monólito ao Microserviço
  • Design de APIs REST: Sem Verbos na URL, Métodos HTTP e Binding de Parâmetros no ASP.NET Core
  • Gargalo em Banco de Dados com C# e EF Core: Mensageria e Paginação
  • Programação Assíncrona em C#: async/await do Fundamento à Produção
  • Paralelismo em C#: Parallel, PLINQ e Tasks do Fundamento à Produção
  • .NET Worker e Background Service para Alto Volume

Referências

  • EF Core — Querying Data (Microsoft Docs) — Documentação oficial de consultas com EF Core
  • EF Core — Pagination — Guia oficial de paginação com EF Core (Offset e Keyset)
  • PostgreSQL — Cursors — Documentação oficial de cursores no PostgreSQL
  • SQL Server — Cursor T-SQL — Referência de cursores T-SQL no SQL Server
  • Oracle — REF CURSOR — Documentação oficial de REF CURSOR no Oracle PL/SQL
  • Use the PostgreSQL pg_stat_statements — Monitoramento de queries lentas no PostgreSQL
  • Repositório blog-zocateli-sample — Pagination — Código-fonte completo dos exemplos deste artigo 📬

👉 Artigo completo com todos os exemplos de código: Paginação em APIs REST com C# e EF Core: Guia Prático

Is Railway a Good Fit for Teams Without DevOps in 2026?

2026-04-15 10:42:00

You can launch fast on Railway. That is the easy part.

The harder question is whether Railway is a good home for a production app when your team does not have DevOps support, no dedicated SRE, and no one whose full-time job is platform reliability.

Based on Railway’s own product constraints, support model, and a visible pattern of production issue threads in its community, the answer is usually no for serious production use. Railway can still be a solid place to test, prototype, and ship low-stakes services quickly. But if your reason for choosing a platform is “we need the platform to absorb operations work for us,” Railway leaves too much of that burden with your team.

Verdict

For prototypes, previews, internal tools, and simple stateless apps, Railway is attractive and often perfectly fine.

For teams without DevOps running customer-facing production systems, Railway is a risky default. The problem is not that deployment is hard on day one. The problem is that once you depend on background jobs, storage, reliable hotfixes, clean debugging, and fast recovery, your app team still ends up doing ops work. That defeats the point of choosing a managed PaaS in the first place.

Why this question matters more for teams without DevOps

A team without DevOps evaluates platforms differently from an infrastructure-heavy engineering org.

You are not shopping for maximum flexibility. You are shopping for a system that makes routine production work stay routine. You want deploys to work without babysitting, scheduled jobs to run without mystery failures, stateful services to be boring, recovery to be straightforward, and support to be responsive enough when the platform itself is the problem. Railway’s own production-readiness checklist centers performance, observability, security, and disaster recovery. That is sensible. The issue is that many of those responsibilities still remain heavily user-owned on Railway.

The appeal is real, and that is exactly why teams choose it

Railway gets shortlisted for good reasons.

The platform has a polished UI, fast onboarding, Git-based deploys, and a low-friction path from repo to running service. Railway’s philosophy and use-case docs are explicitly built around helping developers move from development to deployment quickly, and the pricing model still makes it easy to try the platform with a free trial and a low-cost Hobby plan.

That first impression matters, but it is also where lean teams can make the wrong decision.

An easy first deploy does not tell you whether the platform will keep work off your plate once the app matters. For teams without DevOps, the right test is not “Can I get this online quickly?” The right test is “When production becomes annoying, will the platform absorb that pain, or hand it back to my app team?” Railway often does the latter.

The main problem, Railway does not remove enough ops work

For this audience, there are five operational jobs a managed platform should simplify:

  1. Shipping changes reliably
  2. Running cron and background work
  3. Handling state, backups, and recovery
  4. Debugging incidents quickly
  5. Keeping support, scaling, and cost understandable

Railway gives you tooling in each of these areas. But for teams without DevOps, the question is whether the defaults are strong enough that your product engineers do not become the operations team by accident. In too many cases, the answer is no.

Shipping changes without an ops specialist

A small team can tolerate a lot of things. It cannot tolerate a platform where hotfixes are unreliable.

Railway has repeated community reports of deployments getting stuck in “Creating containers…”, timing out, or requiring manual redeploy attempts while production is already impacted. In one thread, a user said a hotfix was needed while users in the field were already affected. That is not just a bad deploy experience. For a no-DevOps team, that means product engineers stop doing product work and start trying to diagnose platform behavior under pressure.

Railway does support health checks and deployment controls, which helps in normal operation. But a team without DevOps is not choosing a managed PaaS because it wants more knobs. It is choosing it because it wants fewer operational emergencies. When deployments stall at the platform layer, the absence of a dedicated infrastructure owner becomes painfully visible.

Cron jobs and background work are the hidden test

This is one of the most important sections for the title.

Teams without DevOps often depend on cron and async work more than they realize. Invoice generation, emails, retries, imports, cleanup jobs, webhook backfills, daily reports, syncs, and low-volume background processing often sit in the same app team’s hands.

Railway’s cron job docs are clear about an important behavior. Scheduled services are expected to finish and exit cleanly, and if a previous execution is still running, Railway skips the next scheduled run. That may be acceptable for some jobs. It is much less comforting when those jobs are tied to business workflows a small team cannot afford to babysit.

The community record makes this more concerning. Users have reported cron jobs triggering but not actually starting, failing alongside broader deployment issues, and doing so with missing logs. For a team without DevOps, that is a nasty combination. Now the people who wrote the app also have to reason about scheduler behavior, container lifecycle, and platform observability gaps. A mature managed PaaS is supposed to reduce that burden, not sharpen it.

Stateful workloads are where the burden comes back hardest

The clearest structural issue for teams without DevOps is storage.

Railway’s volume reference states several limitations plainly. Each service can have only one volume. Replicas cannot be used with volumes. Services with attached volumes have redeploy downtime because Railway prevents multiple active deployments from mounting the same service volume at once. Those are not minor details. They shape how safely a small team can grow a production app.

Railway has improved this area by adding scheduled backups, with daily, weekly, and monthly retention options. That is a meaningful improvement and should be acknowledged. But it does not remove the deeper concern for no-DevOps teams, which is how much stateful architecture and recovery thinking they still have to own themselves.

You can see that operational burden in issue threads. In one production volume resize thread, a user described hours of downtime, a crashing Postgres instance, and manual cleanup steps just to recover from a resize operation. Their complaint was not simply that the issue happened. It was that they were paying a premium specifically not to fix these things themselves as a rookie. That is exactly the reader for this article.

For a team without DevOps, a good managed platform should make state feel boring. Railway still makes it feel like something you need to plan around carefully.

When something breaks, recovery is too user-heavy

Support quality matters more when you do not have infrastructure specialists internally.

Railway’s support page says Pro users get direct help “usually within 72 hours,” while Trial, Free, and Hobby users rely on community support with no guaranteed response. It also explicitly says Railway does not provide application-level support. That is fair as a policy. But for a small team in a live production issue, “usually within 72 hours” is not strong reassurance, especially when the problem is a platform issue rather than an app bug.

That weakness is not theoretical. The community threads show users escalating production-impacting deployment, networking, and logging failures in public, often while already down. If you do not have DevOps, you need the platform to shorten recovery time. Railway’s support model does not clearly do that unless you are at a higher tier and even then it does not promise fast incident-style handling.

Observability is usable, but not strong enough for this audience

Railway does provide centralized logs and service metrics. Its docs also include guides to send data to tools like Datadog, and the troubleshooting guides recommend deeper APM tooling when built-in metrics are not enough. That is all useful.

But this is where the no-DevOps lens matters again.

A team without DevOps is not choosing a managed PaaS because it wants to wire together extra observability services under stress. It is choosing it because it wants first-party visibility that stays dependable when things are broken. Community threads about logs not populating, logs stopping, and cron failures without usable traces cut directly against that need. When the logs disappear during the incident that matters, the platform is adding debugging work, not removing it.

Networking and request limits add more edge cases than lean teams want

Railway’s public networking docs list a 15-minute maximum duration for HTTP requests. That is more generous than older shorter limits, and for many apps it is enough. But it is still a hard platform ceiling, which matters if a no-DevOps team is relying on the platform for large uploads, synchronous processing, or long-running endpoints.

More broadly, Railway’s community has continued to surface networking issues that are hard for generalist teams to diagnose, including sudden ECONNREFUSED failures on private networking and domain or certificate issues that can sit in validation loops. Even when these can be fixed, they still create operational work that small teams were trying to avoid by choosing a managed platform.

Pricing is simple to start with, but not especially predictable

Railway’s pricing remains usage-based. The Hobby plan is $5 per month, and Railway’s pricing FAQs explain that subscription cost and resource usage are separate, with charges continuing when usage exceeds included amounts. That model is flexible, and for low-volume projects it is often fine.

The issue for teams without DevOps is not only cost. It is planning overhead.

Variable resource pricing is easier to live with when someone on the team is already thinking about capacity, spend, and workload shape. Lean teams often want a platform that is not just affordable, but easy to reason about. Railway’s pricing is workable, but it is not especially forgiving of teams that want to think about infrastructure as little as possible.

Criterion Railway for teams without DevOps Why it matters
Ease of first deploy Strong The first-run experience is genuinely good and is why Railway gets shortlisted.
Deployment reliability Weak Small teams need routine deploys and hotfixes to work without manual rescue.
Cron and background jobs Weak Lean teams often depend on scheduled jobs for real business workflows.
Stateful growth path High risk Volume limits, no replicas with volumes, and redeploy downtime create extra ops work.
Incident recovery Weak Support is limited by tier, and Pro support is only “usually within 72 hours.”
Observability Mixed Native logs exist, but issue threads show missing or degraded visibility during incidents.
Cost predictability Mixed Entry cost is low, but usage-based billing adds planning overhead.
Overall fit Not recommended for serious production Better for prototypes and low-stakes services than for production apps that need the platform to carry operations work.

Good fit vs not a good fit

Railway is a good fit when:

  • you are building a prototype or MVP
  • the app is mostly stateless
  • downtime is inconvenient, not costly
  • cron and background jobs are non-critical
  • you value fast setup more than operational safety

Railway is not a good fit when:

  • your team has no DevOps support
  • production hotfixes need to be boring and dependable
  • your app depends on cron, workers, or scheduled business workflows
  • you are introducing stateful services or attached volumes
  • your app team cannot afford to become its own infrastructure team

A better path forward

If your team does not have DevOps, the safer direction is usually a more mature managed PaaS category that puts stronger production defaults, state handling, and recovery expectations ahead of pure launch speed.

The alternative is not necessarily “run everything yourself.” It is to choose a platform whose main value is that it removes more operational ownership from the app team. If you do have the appetite to own infrastructure explicitly, then a Docker-first cloud path can make sense. But if your whole reason for using Railway is to avoid operations work, then you should choose a platform that is better at absorbing that work than Railway currently is.

Decision checklist

Before choosing Railway, ask:

  • Do we need deploys and hotfixes to work without platform babysitting?
  • Are cron jobs, workers, or async tasks tied to customer-facing workflows?
  • Will we run anything stateful that depends on attached storage?
  • Do we have anyone who can own backups, debugging, and incident recovery?
  • Can we tolerate support that is tiered and not incident-fast?
  • Are we optimizing for fast setup this month, or low operational drag over the next two years?

If your honest answers point toward reliability, state, recovery, and minimal platform babysitting, Railway is the wrong default.

Final take

Railway is still appealing in 2026 because it makes getting started feel easy. That part is real.

But for teams without DevOps, the real product is not “deployment.” It is operational relief. Railway does not provide enough of that once the application matters. Between deployment incidents, cron caveats, volume constraints, support limits, and the amount of debugging work that can still land on the app team, Railway is usually not a good fit for serious production use when no one on your side owns infrastructure full-time.

FAQs

Is Railway good for teams without DevOps in 2026?

Usually no, not for serious production systems. It is strong for fast setup and low-stakes apps, but teams without DevOps need the platform to remove operational work, not just delay it. Railway still leaves too much responsibility with the app team.

Is Railway okay for startups with no infrastructure team?

It can be okay for prototypes, MVPs, preview environments, and simple stateless services. It becomes much less attractive once deploy reliability, cron behavior, stateful workloads, and recovery speed start to matter.

What is the biggest risk of choosing Railway without DevOps?

The biggest risk is that your product engineers end up doing operations work anyway. That tends to show up around failed deploys, scheduled jobs, storage and backups, incident debugging, and recovery.

Can a non-DevOps team safely rely on Railway cron jobs?

Only with caution. Railway’s cron model expects jobs to finish and exit cleanly, and it skips the next run if the previous execution is still running. That may be fine for low-stakes tasks, but it is a weak fit for critical workflows when the team does not have strong operational oversight.

Is Railway fine for prototypes but risky for production?

Yes. That is the cleanest summary. Railway remains attractive for fast early deployments, but the production burden rises quickly once the app becomes stateful, scheduled, or operationally important.

What type of platform should a small team consider instead?

A mature managed PaaS is usually the better category for small teams that want stronger production defaults and less operational ownership. If the team is ready to own infrastructure deliberately, a more explicit cloud path can also work. The wrong move is choosing Railway because you want less ops work, then discovering your app team is doing ops anyway.

Confessions of a Dev #1:I Went Blank When Asked "What's an API?"

2026-04-15 10:39:43

Last week, a friend asked me something so basic, so fundamental that i should have been able to answer it in my sleep.

"Hey, quick question...what exactly is an API?"

Not a trick question. Not an interview. Just two devs talking.

And I went blank.

I’ve built REST APIs. I’ve authenticated with JWT.

But in that moment? Nothing.

So I did the thing we all do when our brain short-circuits:

"I mean… I know how they work? Or what they do? You know."

We both laughed. He nodded like he understood. But I knew: I had failed the simplest test of all.
The Problem (It’s Worse Than You Think)

When a non-technical person asks “what’s an API?”, you can use a waiter or a mailman analogy. They’ll be happy.

But when a technical person asks, they’re not looking for a metaphor. They already know what a server is, what HTTP is, what JSON is. They’re asking for the essential definition – the crisp, precise, almost philosophical answer.

And that’s way harder.
Because APIs are so obvious to us that we’ve never actually articulated them. We just… use them. The definition lives in our fingertips, not our tongue.

So after that humiliating moment, I went home and forced myself to answer properly. Here’s what I came up with.
What I Should Have Said (Technical Edition)
The One-Sentence Definition (Crisp & Precise)

“An API is a defined interface that specifies how one software component can interact with another – including valid requests, expected responses, and the underlying protocol (usually HTTP).”

That’s the technical answer. No fluff. No waiter.
The Slightly More Technical Answer

“An API is a contract between a client and a server. It says: ‘If you send me a request in this shape, to this URL, with these headers, I’ll send you back a response in this shape – and here are the status codes to tell you what happened.’”

The “Ah, Right” Analogy (Still Technical)

“Think of a database query language like SQL. You send a SELECT statement, you get a result set. An API is the same idea, but over HTTP – and instead of tables, you work with resources (users, orders, products).”

The Shortest Possible Answer

“An API is a layer that abstracts an implementation behind a set of publicly accessible operations.”

That one would have made me sound smart. But I didn’t say it. I said “uh… you know.”
Why Technical People Go Blank on Basic Definitions

It’s not because we don’t know. It’s because:

  1. We think in use cases, not definitions – “An API is what I call to get user data” is not a definition, it’s an example. But that’s how our brains store it.
  2. We confuse “how it works” with “what it is” – I could have explained the HTTP request cycle, JSON serialization, status codes, rate limiting, and authentication. But that’s not what an API is. That’s how you use one.

3.We’ve never had to define it – In school, they teach you syntax, not definitions. In work, you just build things. No one ever says “define API” in a sprint planning meeting.

The Real Lesson (For Devs, By a Dev)

Knowing how to build something is not the same as knowing how to define it.

The best developers can do both. They can talk about implementation and they can zoom out to the 10,000-foot definition without stumbling.

So here’s my challenge to you, fellow dev:

Without looking it up, define these terms in one sentence – out loud, right now:

    HTTP

    JSON

    JWT

    DOM

    TCP

If you can’t, welcome to the club. Let’s practice together.
Call to Action (Your Confession)

Now it’s your turn.

What basic technical question made you go blank in front of another developer?

Drop your confession in the comments. No judgment. Just devs being honest.

This is Confessions of a Dev. We’ve all been there.

How I Used Bit Manipulation to Speed Up Float-to-Int Conversion in a Storage Engine

2026-04-15 10:36:14

In a columnar time-series database, one of the most effective compression tricks is
deceptively simple: if a float value is actually an integer, store it as one.

Why Integers Compress Better Than Floats

Integer compression algorithms like Delta-of-Delta, ZigZag, and Simple8b work by
exploiting predictable bit patterns — small deltas between adjacent values, values
that fit in fewer than 64 bits, and so on. They can pack multiple values into a
single 64-bit word.

Floats don't cooperate with these schemes. Even 1.0 and 2.0 have completely
different IEEE 754 bit representations (0x3FF0000000000000 and 0x4000000000000000).
Their XOR is large, their delta is meaningless as an integer, and bit-packing is useless.

So when a column is declared as FLOAT but actually contains values like 12.0,
18.0, 25.0 — which happens more often than you'd expect, either because the schema
was designed generically or because the upstream system always emits .0 values — you're
leaving significant compression headroom on the table.

The fix: detect these integer-valued floats at encode time, convert them losslessly
to integers, and route them through the integer compression path.

A temperature sensor that reports 21.0, 21.5, 22.0 is a good example. Multiply
by 10 and you get 210, 215, 220 — plain integers with small, predictable deltas.
Delta-of-Delta or Simple8b will compress these far more efficiently than any
float-specific scheme.

The challenge: before converting, you need to check whether the scaled value can be
losslessly represented as an integer. The naive check — std::isnan + range comparison —
works but it's slower than it needs to be on the hot encoding path.

Here's the faster approach I implemented, using nothing but bit manipulation.

The Setup: Scaling Floats to Integers

The encoding scheme works in two steps:

  1. Scale: multiply the float by 10^scale (configurable per column)
  2. Convert: cast the scaled value to integer using std::lround

For example, with scale = 2:

  • 1.231.23 * 100 = 123.0123
  • 45.67845.678 * 100 = 4567.8 → overflow risk or precision loss

Step 2 only makes sense if the scaled value actually fits in the target integer type.
That's the overflow check.

The Overflow Check

The function takes a pointer to the raw float bytes and the target integer width in bytes.
It returns non-zero if the value would overflow.

Called before every conversion — if it fires, skip the integer path and fall back to
float encoding.

The key insight: you can determine whether a float overflows a given integer type
purely from the float's exponent bits, without doing any arithmetic.

Here's why.

IEEE 754 in One Paragraph

A double-precision float is stored as 64 bits:

[ sign: 1 bit ][ exponent: 11 bits ][ fraction: 52 bits ]

The value is: 1.fraction × 2^(exponent − 1023)

The 1023 is the bias — it allows the 11-bit exponent field to represent negative
exponents. The real exponent is stored_exponent − 1023.

For 32-bit floats: 8 exponent bits, bias 127, fraction 23 bits.

Extracting the Exponent

For a double:

uint64_t bits;
memcpy(&bits, src, 8);                        /* safe type-pun, no UB */
int16_t real_exp = (int16_t)((bits >> 52) & 0x07ff) - 1023;

Step by step:

  1. memcpy into a uint64_t — reinterpret the 8 bytes as a 64-bit integer (no arithmetic, just bits)
  2. >> 52 — shift right past the 52 fraction bits, bringing the exponent to the low end
  3. & 0x07ff — mask off the sign bit, keep only the 11 exponent bits
  4. - 1023 — subtract the bias to get the real exponent

For a float:

uint32_t bits;
memcpy(&bits, src, 4);
int16_t real_exp = (int16_t)((bits >> 23) & 0xff) - 127;

Same logic: shift past 23 fraction bits, mask 8 exponent bits, subtract bias 127.

The Overflow Condition

Once you have the real exponent, the overflow check is one comparison:

is_overflow = real_exp > int_typewidth * 8 - 2;

Where does - 2 come from?

  • −1 for the sign bit: a signed integer of N bits can hold values up to 2^(N-1) - 1
  • −1 for the implicit leading 1: in IEEE 754, the fraction is 1.fraction, not 0.fraction

So a float with real exponent E represents a value with E + 1 significant bits
(the implicit 1 plus E fraction bits). For it to fit in a signed N-bit integer, you
need E + 1 ≤ N - 1, which simplifies to E ≤ N - 2.

Full implementation in C:

#include <stdint.h>
#include <string.h>

/* Returns 1 if the double at src overflows a signed integer of int_bytes bytes. */
static inline int double_overflow_check(const char *src, int int_bytes)
{
    uint64_t bits;
    memcpy(&bits, src, 8);
    int16_t real_exp = (int16_t)((bits >> 52) & 0x07ff) - 1023;
    return real_exp > int_bytes * 8 - 2;
}

/* Returns 1 if the float at src overflows a signed integer of int_bytes bytes. */
static inline int float_overflow_check(const char *src, int int_bytes)
{
    uint32_t bits;
    memcpy(&bits, src, 4);
    int16_t real_exp = (int16_t)((bits >> 23) & 0xff) - 127;
    return real_exp > int_bytes * 8 - 2;
}

Total cost: one memcpy, one shift, one AND, one subtract, one compare.
No floating-point arithmetic, no branches on the value itself.

How It Fits into the Encoder

The encoder scales the value first, then calls the overflow check on the scaled result:

double scaled = orig * scaler;               /* scale: e.g. orig * 100.0 */
if (double_overflow_check((char *)&scaled, sizeof(int64_t)))
    return ENCODE_OVERFLOW;                  /* fall back to float encoding */

int64_t result = llround(scaled);            /* safe: overflow already ruled out */

The scale factor is stored in the column header so the decoder can reverse the
operation: decoded = (double)stored_integer / pow(10, scale).

Why Not Just Use std::isnan + Range Check?

The conventional approach:

if (std::isnan(value)) return false;
if (value > INT64_MAX || value < INT64_MIN) return false;
return true;

This involves floating-point comparisons, which on many architectures require the
value to be loaded into a float register before comparison. On a hot encoding path
processing millions of values, the difference adds up.

The bit manipulation approach operates entirely on integer registers. The float's
bytes are reinterpreted as an integer — no floating-point unit involved until the
final std::lround conversion, which only happens when you've already confirmed
no overflow.

What This Enables

This check is the entry gate for the full encoding chain:

float column
    ↓
check_float_overflow   ← this article
    ↓ (passes)
float → integer cast
    ↓
Delta+ZigZag encoding
    ↓
Simple8b bit-packing

Without a cheap overflow gate, the chain can't run on untrusted float data. With it,
each value costs one check before entering the integer compression path — which can
achieve far better compression ratios than float-specific schemes on "integer-like"
time-series data.

What's Next

This article is part of a series on compression engineering in time-series databases:

  • Part 1: Runtime adaptive compression — how the system selects the best algorithm without scanning all data (published)
  • Part 3: Chained encoding — the full float-to-integer → Delta+ZigZag → Simple8b pipeline
  • Part 4: An improved floating-point compression algorithm based on ELF

I'm currently available for freelance work on backend systems, storage engineering,
and systems integration. Feel free to reach out.