2026-04-15 10:52:20
Hi, I'm Umitomo 🐠 from Japan 🇯🇵
I work as an in-house systems engineer, mainly focusing on IT infrastructure and security.
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:
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.
I'm currently learning SwiftUI through tutorials.
I'd love to build apps that I can enjoy with my kids.
I chose Dev.to because:
I feel it's a great place to grow as a developer while sharing my progress.
I'm starting small, but I want to stay consistent and keep learning step by step.
Feel free to connect with me!
2026-04-15 10:52:07
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:
HttpClient
ℹ️ 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.
Para acompanhar este tutorial, você vai precisar de:
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.
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.
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>
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.
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 noApp.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.
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.
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:
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.Json — GetFromJsonAsync, 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.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 cominitnão funcionam para edição em formulários 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 + Count — Data 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
LoadDatadoRadzenDataGridé chamado toda vez que o grid precisa de dados — paging, sorting, filtering. Não confunda comDatabinding direto, que é para dados client-side. Se você usarDatacom uma lista completa eAllowPaging, a paginação será client-side (todos os dados carregados de uma vez). ComLoadData+Count, a paginação é server-side (apenas a página atual é carregada).
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.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.
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.
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 |
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 dupla — RadzenRequiredValidator 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áveis — ProdutoForm 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.
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.@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.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/.
👉 Artigo completo com todos os exemplos de código: Zero JavaScript: CRUD Completo com Blazor WASM e Radzen
2026-04-15 10:45:56
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.
Antes de ver as estratégias, vale entender o que acontece internamente em cada banco quando você pagina:
-- 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:
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.// ❌ 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.
-- 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.
// 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.
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.
-- 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.
// 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.
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:
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);
}
}
}
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.
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;
}
}
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.
// 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)));
});
| 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 |
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.
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\"");
}
}
(coluna_ordenacao, id_unico) como cursor composto. Apenas a coluna de ordenação pode ter duplicatas, o Id garante unicidade e estabilidade.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./v2/pedidos) para não quebrar clientes que dependem do campo totalPaginas.pg_stat_statements (PostgreSQL) para capturar queries de paginação que ultrapassam SLAs.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.
👉 Artigo completo com todos os exemplos de código: Paginação em APIs REST com C# e EF Core: Guia Prático
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.
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.
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.
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.
For this audience, there are five operational jobs a managed platform should simplify:
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.
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.
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.
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.
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.
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.
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.
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. |
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.
Before choosing Railway, ask:
If your honest answers point toward reliability, state, recovery, and minimal platform babysitting, Railway is the wrong default.
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.
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.
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.
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.
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.
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.
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.
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:
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.
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.
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 encoding scheme works in two steps:
10^scale (configurable per column)std::lround
For example, with scale = 2:
1.23 → 1.23 * 100 = 123.0 → 123
45.678 → 45.678 * 100 = 4567.8 → overflow risk or precision lossStep 2 only makes sense if the scaled value actually fits in the target integer type.
That's 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.
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.
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:
memcpy into a uint64_t — reinterpret the 8 bytes as a 64-bit integer (no arithmetic, just bits)>> 52 — shift right past the 52 fraction bits, bringing the exponent to the low end& 0x07ff — mask off the sign bit, keep only the 11 exponent bits- 1023 — subtract the bias to get the real exponentFor 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.
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?
2^(N-1) - 1
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.
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).
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.
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.
This article is part of a series on compression engineering in time-series databases:
I'm currently available for freelance work on backend systems, storage engineering,
and systems integration. Feel free to reach out.