A loja física precisa de um programa de caixa que rode tanto nos computadores Windows do balcão quanto nas máquinas Linux mais novas, que leia código de barras e que continue vendendo quando a internet cai. Neste artigo o construímos em Avalonia com MVVM, e a lição mais importante acaba sendo de domínio: um sistema offline muda as regras do negócio.
Os requisitos do balcão
O caixa é um aplicativo diferente do site e do app do cliente:
- Roda o dia inteiro, em máquinas modestas, operado por funcionários que precisam de velocidade: teclado, leitor de código de barras e atalhos, quase sem mouse.
- Precisa rodar em Windows e em Linux. O artigo anterior mostrou que essa exigência, sozinha, já aponta para Avalonia.
- Não pode parar quando a internet cai. Uma fila de clientes no balcão não espera o provedor voltar. A venda precisa ser registrada localmente e enviada depois.
- Registra quem vendeu: cada operador faz login, e cada venda carrega sua identidade.
O projeto
dotnet new install Avalonia.Templates
dotnet new avalonia.mvvm -n Livraria.Caixa -o src/Livraria.Caixa
dotnet add src/Livraria.Caixa reference src/Livraria.Contratos
dotnet add src/Livraria.Caixa package Microsoft.Extensions.DependencyInjection
dotnet add src/Livraria.Caixa package Microsoft.Extensions.Http.Resilience
dotnet add src/Livraria.Caixa package Microsoft.Data.Sqlite
O template já traz o CommunityToolkit.Mvvm e esta estrutura:
Livraria.Caixa/
├── App.axaml / App.axaml.cs ← aplicação, estilos, inicialização
├── ViewLocator.cs ← encontra a View de cada ViewModel
├── ViewModels/
│ ├── ViewModelBase.cs
│ └── MainWindowViewModel.cs
├── Views/
│ ├── MainWindow.axaml
│ └── MainWindow.axaml.cs
└── Program.cs
Os arquivos de marcação do Avalonia usam a extensão .axaml, para não serem confundidos com o XAML do WPF pelas ferramentas. O dialeto, porém, é quase igual.
O template também liga os bindings compilados (AvaloniaUseCompiledBindingsByDefault no .csproj). Com x:DataType declarado na View, cada {Binding Total} é verificado em tempo de compilação contra o tipo do ViewModel. Um erro de digitação quebra o build, em vez de gerar um binding silenciosamente vazio em tempo de execução. É a mesma proteção que as Razor Pages dão com @model. De quebra, o binding compilado não usa reflexão, o que o torna mais rápido e compatível com trimming e Native AOT.
Injeção de dependência numa aplicação de desktop
Um aplicativo Avalonia não tem o host do ASP.NET Core, mas nada impede usar o mesmo contêiner de "Um repositório, três bancos":
public partial class App : Application
{
public static IServiceProvider Servicos { get; private set; } = default!;
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
{
CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("pt-BR");
CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo("pt-BR");
var servicos = new ServiceCollection();
servicos.AddSingleton(Configuracao.Carregar());
servicos.AddSingleton<SessaoDoOperador>();
servicos.AddSingleton<CatalogoLocal>();
servicos.AddSingleton<FilaDeVendas>();
servicos.AddSingleton<SincronizadorDeVendas>();
servicos.AddHttpClient<ClienteDaApi>(c => c.BaseAddress = new Uri(Configuracao.UrlDaApi))
.AddStandardResilienceHandler(o => o.Retry.DisableForUnsafeHttpMethods());
servicos.AddSingleton<MainWindowViewModel>();
servicos.AddTransient<LoginViewModel>();
servicos.AddTransient<VendaViewModel>();
Servicos = servicos.BuildServiceProvider(validateScopes: true);
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow { DataContext = Servicos.GetRequiredService<MainWindowViewModel>() };
Servicos.GetRequiredService<SincronizadorDeVendas>().Iniciar();
}
base.OnFrameworkInitializationCompleted();
}
}
Numa aplicação de desktop, a maior parte dos serviços é singleton: há um usuário, uma janela, uma fila. Não existe o "escopo por requisição" da API. A cultura pt-BR é fixada na inicialização para que {0:C} mostre "R$" em qualquer máquina, independentemente do idioma instalado no sistema do balcão.
Navegação: o ViewModel decide a tela
O MVVM levado a sério faz a navegação acontecer nos ViewModels. A janela principal mostra "a página atual", e essa página atual é um ViewModel:
public sealed partial class MainWindowViewModel(IServiceProvider servicos, SessaoDoOperador sessao)
: ViewModelBase
{
[ObservableProperty]
private ViewModelBase paginaAtual = default!;
public FilaDeVendas Fila { get; } = servicos.GetRequiredService<FilaDeVendas>();
public void IrPara<T>() where T : ViewModelBase =>
PaginaAtual = servicos.GetRequiredService<T>();
public void Iniciar() => IrPara<LoginViewModel>();
}
<!-- Views/MainWindow.axaml -->
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Livraria.Caixa.ViewModels"
x:Class="Livraria.Caixa.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="Caixa · Livraria" WindowState="Maximized">
<DockPanel>
<Border DockPanel.Dock="Bottom" Classes="barra-de-status">
<TextBlock Text="{Binding Fila.Resumo}" />
</Border>
<ContentControl Content="{Binding PaginaAtual}" />
</DockPanel>
</Window>
O ContentControl recebe um ViewModel como conteúdo e precisa saber como exibi-lo. Quem resolve é o ViewLocator do template, um IDataTemplate registrado no App.axaml que troca "ViewModel" por "View" no nome do tipo e instancia a View correspondente: VendaViewModel vira VendaView. A convenção por nome é simples, mas usa reflexão. Para publicar com Native AOT, o ViewLocator vira um switch explícito ou um dicionário de fábricas, e o compilador passa a saber de todas as Views usadas.
O ViewModel de login, quando termina, chama principal.IrPara<VendaViewModel>(). Nenhum ViewModel conhece nenhuma View, e a navegação inteira pode ser testada sem abrir uma janela.
Login do operador
O operador entra com a mesma identidade que usaria no site, pelo Keycloak de "Login, área administrativa, upload de capas e notificações em tempo real". Para aplicações nativas, o RFC 8252 (OAuth 2.0 for Native Apps) define o caminho: o aplicativo abre o navegador do sistema, e não um formulário próprio, e recebe o retorno numa porta local (loopback). O aplicativo nunca vê a senha, e o operador pode usar o gerenciador de senhas e o segundo fator do navegador.
A biblioteca Duende.IdentityModel.OidcClient (antes chamada IdentityModel.OidcClient) implementa o fluxo com PKCE:
var cliente = new OidcClient(new OidcClientOptions
{
Authority = config.Autoridade,
ClientId = "livraria-caixa", // cliente público: sem segredo
Scope = "openid profile offline_access livraria-api",
RedirectUri = "http://127.0.0.1:7890/",
Browser = new NavegadorDoSistema(porta: 7890) // abre o navegador e escuta a porta
});
var resultado = await cliente.LoginAsync(cancellationToken: ct);
if (!resultado.IsError)
sessao.Iniciar(resultado.User, resultado.AccessToken, resultado.RefreshToken,
resultado.AccessTokenExpiration);
O cliente livraria-caixa é público: um aplicativo instalado em dezenas de máquinas não consegue guardar um segredo, porque qualquer um pode extraí-lo do executável. O PKCE é o que garante que o código de autorização só sirva a quem iniciou o fluxo.
O refresh token fica guardado criptografado no disco da máquina, com a API de proteção de dados do sistema (DPAPI no Windows, o chaveiro do sistema no Linux), para que um reinício do computador não obrigue a novo login no meio do expediente.
A tela de venda
public sealed partial class VendaViewModel : ViewModelBase
{
private readonly CatalogoLocal catalogo;
private readonly FilaDeVendas fila;
private readonly SessaoDoOperador sessao;
public VendaViewModel(CatalogoLocal catalogo, FilaDeVendas fila, SessaoDoOperador sessao)
{
(this.catalogo, this.fila, this.sessao) = (catalogo, fila, sessao);
Itens.CollectionChanged += (_, e) =>
{
foreach (ItemDaVendaViewModel novo in e.NewItems ?? Array.Empty<object>())
novo.PropertyChanged += (_, _) => AtualizarTotais();
AtualizarTotais();
};
}
public ObservableCollection<ItemDaVendaViewModel> Itens { get; } = [];
public decimal Total => Itens.Sum(i => i.Subtotal);
public int QuantidadeDeItens => Itens.Sum(i => i.Quantidade);
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AdicionarCommand))]
private string codigo = "";
[ObservableProperty]
private string? mensagem;
[ObservableProperty]
private FormaDePagamento formaDePagamento = FormaDePagamento.Cartao;
[RelayCommand(CanExecute = nameof(PodeAdicionar))]
private void Adicionar()
{
var isbn = new string(Codigo.Where(char.IsAsciiDigit).ToArray());
Codigo = "";
var produto = catalogo.BuscarPorIsbn(isbn);
if (produto is null)
{
Mensagem = $"ISBN {isbn} não encontrado no catálogo.";
return;
}
var existente = Itens.FirstOrDefault(i => i.ProdutoId == produto.Id);
if (existente is not null) existente.Quantidade++;
else Itens.Add(new ItemDaVendaViewModel(produto));
Mensagem = null;
}
private bool PodeAdicionar() => Codigo.Count(char.IsAsciiDigit) >= 10;
[RelayCommand(CanExecute = nameof(PodeFinalizar))]
private async Task FinalizarAsync(CancellationToken ct)
{
var venda = new VendaDeBalcao(
Id: Guid.NewGuid(),
RealizadaEm: DateTimeOffset.UtcNow,
OperadorId: sessao.OperadorId,
Terminal: sessao.Terminal,
Itens: Itens.Select(i => new ItemDeBalcao(i.ProdutoId, i.Quantidade, i.PrecoUnitario)).ToList(),
FormaDePagamento: FormaDePagamento);
await fila.EnfileirarAsync(venda, ct); // grava localmente: nunca falha por rede
Itens.Clear();
Mensagem = $"Venda registrada: {venda.Itens.Sum(i => i.PrecoUnitario * i.Quantidade):C}";
}
private bool PodeFinalizar() => Itens.Count > 0;
private void AtualizarTotais()
{
OnPropertyChanged(nameof(Total));
OnPropertyChanged(nameof(QuantidadeDeItens));
FinalizarCommand.NotifyCanExecuteChanged();
}
}
public sealed partial class ItemDaVendaViewModel(ProdutoLocal produto) : ObservableObject
{
public int ProdutoId { get; } = produto.Id;
public string Titulo { get; } = produto.Titulo;
public decimal PrecoUnitario { get; } = produto.Preco;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Subtotal))]
private int quantidade = 1;
public decimal Subtotal => PrecoUnitario * Quantidade;
[RelayCommand] private void Aumentar() => Quantidade++;
[RelayCommand] private void Diminuir() { if (Quantidade > 1) Quantidade--; }
}
Alguns detalhes do MVVM que aparecem aqui:
Propriedades calculadas precisam de aviso. Total não tem campo próprio. Ela é derivada dos itens, e a View só a relê quando alguém dispara PropertyChanged(nameof(Total)). Dentro de um item, [NotifyPropertyChangedFor(nameof(Subtotal))] resolve. Para o total da venda, que depende de uma coleção e de propriedades de cada elemento, é preciso escutar as duas coisas: CollectionChanged para itens que entram e saem, e PropertyChanged de cada item para quantidades que mudam. Esquecer um dos dois é o bug mais comum do MVVM: o total não acompanha uma das alterações.
ObservableCollection<T> avisa a View quando itens entram ou saem, e o ListBox acrescenta ou remove só a linha afetada. Ela precisa ser alterada na thread de interface. Comandos disparados pela View rodam nela, e os await dentro deles voltam a ela pelo contexto de sincronização. Código em segundo plano, como o sincronizador que vamos ver, usa Dispatcher.UIThread.Post(...) para tocar em coleções ligadas à tela.
Comandos assíncronos. [RelayCommand] num método async Task com CancellationToken gera um AsyncRelayCommand, que enquanto executa expõe IsRunning e, por padrão, impede nova execução. Um duplo clique em "Finalizar" não registra duas vendas. É o salvando de "Blazor: componentes", pronto.
A venda nasce com seu próprio Id, gerado no caixa. Esse identificador vai servir de chave de idempotência no envio.
A View
<!-- Views/VendaView.axaml -->
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Livraria.Caixa.ViewModels"
x:Class="Livraria.Caixa.Views.VendaView"
x:DataType="vm:VendaViewModel">
<DockPanel Margin="16">
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8">
<TextBox x:Name="CampoCodigo" Width="360" FontSize="20"
Watermark="Leia o código de barras ou digite o ISBN"
Text="{Binding Codigo}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AdicionarCommand}" />
</TextBox.KeyBindings>
</TextBox>
<TextBlock Text="{Binding Mensagem}" Classes="alerta" VerticalAlignment="Center" />
</StackPanel>
<Border DockPanel.Dock="Bottom" Classes="rodape-da-venda">
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Text="{Binding QuantidadeDeItens, StringFormat='{}{0} itens'}" />
<TextBlock Grid.Column="1" Text="{Binding Total, StringFormat='{}{0:C}'}" FontSize="32" />
<Button Grid.Column="2" Content="Finalizar (F12)" HotKey="F12"
Command="{Binding FinalizarCommand}" />
</Grid>
</Border>
<ListBox ItemsSource="{Binding Itens}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ItemDaVendaViewModel">
<Grid ColumnDefinitions="*,Auto,Auto,Auto,Auto">
<TextBlock Text="{Binding Titulo}" />
<TextBlock Grid.Column="1" Text="{Binding PrecoUnitario, StringFormat='{}{0:C}'}" />
<Button Grid.Column="2" Content="−" Command="{Binding DiminuirCommand}" />
<TextBlock Grid.Column="3" Text="{Binding Quantidade}" />
<Button Grid.Column="4" Content="+" Command="{Binding AumentarCommand}" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</UserControl>
Cada DataTemplate declara seu próprio x:DataType, e dentro do template o binding é contra o item, não contra a tela. O {} no início de StringFormat é um escape do XAML, necessário porque a string começa com chave.
O leitor de código de barras
Um leitor de código de barras USB comum se apresenta ao sistema como um teclado. Ele "digita" os dígitos lidos e um Enter, muito rápido. Isso simplifica a integração: o campo de código recebe os dígitos, e o KeyBinding de Enter chama AdicionarCommand. Não há driver nem biblioteca.
A consequência é que o foco precisa estar no campo o tempo todo. Se o operador clicar no botão "+" de um item, o foco sai do campo, e a próxima leitura vai parar em outro lugar. A solução é devolver o foco depois de cada ação. Isso é comportamento puramente visual, e por isso fica no code-behind da View, onde o MVVM permite o que não é lógica:
public partial class VendaView : UserControl
{
public VendaView()
{
InitializeComponent();
AttachedToVisualTree += (_, _) => CampoCodigo.Focus();
AddHandler(Button.ClickEvent, (_, _) => CampoCodigo.Focus(), RoutingStrategies.Bubble);
}
}
O MVVM não proíbe code-behind. Ele pede que a lógica fique fora dele. Foco, animações e rolagem são responsabilidade da View, e escrevê-los no ViewModel exigiria abstrações artificiais só para evitar três linhas.
Vendendo sem internet
O FinalizarAsync não chama a API. Ele grava a venda numa fila local, e um serviço em segundo plano a envia. É o padrão outbox de "Worker Services e mensageria com RabbitMQ", agora do lado do cliente: a gravação local é a transação, e o envio é o retransmissor.
public sealed class FilaDeVendas : ObservableObject
{
private readonly string conexao = $"Data Source={Caminhos.BancoLocal}";
public FilaDeVendas()
{
using var db = new SqliteConnection(conexao);
db.Open();
using var cmd = db.CreateCommand();
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS vendas_pendentes (
id TEXT PRIMARY KEY,
conteudo TEXT NOT NULL,
criada_em TEXT NOT NULL,
tentativas INTEGER NOT NULL DEFAULT 0,
ultimo_erro TEXT,
rejeitada INTEGER NOT NULL DEFAULT 0);
PRAGMA journal_mode = WAL;
""";
cmd.ExecuteNonQuery();
}
public async Task EnfileirarAsync(VendaDeBalcao venda, CancellationToken ct)
{
await using var db = new SqliteConnection(conexao);
await db.OpenAsync(ct);
await using var cmd = db.CreateCommand();
cmd.CommandText = "INSERT INTO vendas_pendentes (id, conteudo, criada_em) VALUES ($id, $conteudo, $criada)";
cmd.Parameters.AddWithValue("$id", venda.Id.ToString());
cmd.Parameters.AddWithValue("$conteudo", JsonSerializer.Serialize(venda));
cmd.Parameters.AddWithValue("$criada", venda.RealizadaEm.ToString("O"));
await cmd.ExecuteNonQueryAsync(ct);
await AtualizarResumoAsync(ct);
}
public string Resumo { get; private set; } = "";
// PendentesAsync, ConcluirAsync, RegistrarFalhaAsync, RejeitarAsync e AtualizarResumoAsync
}
É o ADO.NET com SQLite de "ADO.NET: Conexões, Comandos Parametrizados e o Perigo do SQL Injection", agora num lugar em que ele é a escolha óbvia: um banco em arquivo, sem servidor, que sobrevive a quedas de energia. O modo WAL deixa leitura e escrita acontecerem ao mesmo tempo sem bloqueio, útil porque o sincronizador lê enquanto o caixa grava.
public sealed class SincronizadorDeVendas(FilaDeVendas fila, ClienteDaApi api, ILogger<SincronizadorDeVendas> log)
{
public void Iniciar() => _ = LaçoAsync();
private async Task LaçoAsync()
{
using var relogio = new PeriodicTimer(TimeSpan.FromSeconds(10));
do
{
foreach (var pendente in await fila.PendentesAsync(lote: 20, CancellationToken.None))
{
var resultado = await api.EnviarVendaAsync(pendente.Venda, CancellationToken.None);
switch (resultado)
{
case Resultado<VendaRegistrada>.Ok:
await fila.ConcluirAsync(pendente.Id);
break;
case Resultado<VendaRegistrada>.Invalido or Resultado<VendaRegistrada>.Conflito:
await fila.RejeitarAsync(pendente.Id, resultado.ToString()); // revisão humana
break;
default:
await fila.RegistrarFalhaAsync(pendente.Id, "API indisponível");
goto proximoCiclo; // sem rede: tenta depois
}
}
proximoCiclo:;
} while (await relogio.WaitForNextTickAsync());
}
}
O ClienteDaApi.EnviarVendaAsync envia POST /v1/vendas-balcao com o cabeçalho Idempotency-Key: {venda.Id}. Se a conexão cair depois de a API gravar e antes de a resposta chegar, o caixa reenvia a mesma venda no próximo ciclo, e a API reconhece a chave e devolve a resposta original sem registrar de novo. Todo o protocolo do exercício 5 de "A API da Livraria", implementado com Redis em "Cache com Redis, HybridCache e output caching", existe para este momento.
As respostas são tratadas de três formas, pelo Resultado de "O site consumindo a API": sucesso remove da fila; rejeição definitiva (400, 409) marca a venda como rejeitada, para revisão humana, sem novas tentativas; falha transitória mantém a venda na fila e interrompe o ciclo, porque se a API está fora, insistir nas outras vendas agora só gera ruído. A barra de status mostra "3 vendas aguardando envio" ou "1 venda precisa de revisão", e o operador sabe que algo está pendente sem ler logs.
O catálogo que viaja com o caixa
Para vender offline, o caixa precisa encontrar o livro pelo ISBN sem consultar a API. O CatalogoLocal mantém uma cópia do catálogo (id, ISBN, título, preço) numa tabela do mesmo SQLite. Ela é atualizada a cada poucos minutos quando há rede, com a paginação por keyset do exercício 2 de "A API da Livraria", e em tempo real por uma conexão com o hub, com o ProdutoAlterado de "Tempo real com SignalR". O preço mostrado no balcão é o da última sincronização.
O que o offline muda no domínio
Aqui está a decisão mais interessante do artigo, e ela não é técnica. Na venda online, a regra é absoluta: não se vende o que não há em estoque, e o update condicional do artigo de ADO.NET (estoque >= $quantidade no WHERE) garante isso. No balcão, o livro está na mão do cliente. Se o caixa ficou offline por uma hora e nesse meio-tempo o site vendeu o "último" exemplar que o sistema achava existir, o balcão não pode desfazer uma venda que já aconteceu fisicamente.
Por isso a venda de balcão tem um endpoint e uma regra próprios:
vendas.MapPost("/", RegistrarVendaDeBalcao)
.RequireAuthorization("OperarCaixa")
.RequireIdempotencyKey(); // filtro de endpoint com a lógica do artigo de cache com Redis
- Ela registra sempre, mesmo que o estoque do sistema fique negativo. Um estoque negativo é um fato a investigar: a contagem estava errada, ou houve venda duplicada entre canais. Não é um motivo para recusar a venda.
- Estoque negativo publica um evento
estoque.divergente, que aparece no painel dos administradores de "Login, área administrativa, upload de capas e notificações em tempo real" e no qual um consumidor pode, por exemplo, suspender a venda online daquele livro até a conferência. - O preço enviado pelo caixa é aceito, porque é o preço cobrado de fato. Se ele divergir do preço atual por mais que uma tolerância configurada, a venda é registrada com uma marca para auditoria. Um terminal autenticado, operado por um funcionário identificado, recebe uma confiança que um navegador anônimo não recebe, e em troca tudo o que ele faz fica registrado.
O invariante "o estoque nunca fica negativo" era, na verdade, "a loja online não vende o que não tem". Com um canal que opera desconectado, a regra precisou ser formulada de novo, com mais precisão. Sistemas distribuídos e offline costumam forçar esse tipo de conversa com o negócio, e ela é melhor quando acontece antes do primeiro dia de loja cheia com a internet caída.
Testes e distribuição
Os ViewModels são testados com xUnit como no artigo anterior. Para testar a interface, o pacote Avalonia.Headless.XUnit roda a aplicação sem janela real: o teste cria a View, simula teclas e cliques e verifica o estado, inclusive em servidores de CI Linux sem tela.
[AvaloniaFact]
public void Enter_no_campo_de_codigo_adiciona_o_livro()
{
var vm = CriarVendaComCatalogo(("9788535910667", "Dom Casmurro", 39.90m));
var view = new VendaView { DataContext = vm };
var janela = new Window { Content = view };
janela.Show();
view.CampoCodigo.Text = "9788535910667";
janela.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
Assert.Single(vm.Itens);
Assert.Equal(39.90m, vm.Total);
}
A publicação gera um executável por plataforma, autocontido, porque as máquinas do balcão não precisam ter o .NET instalado:
dotnet publish src/Livraria.Caixa -c Release -r linux-x64 --self-contained -p:PublishSingleFile=true
dotnet publish src/Livraria.Caixa -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
Com bindings compilados e um ViewLocator sem reflexão, o Avalonia também suporta Native AOT, com início praticamente instantâneo e menos memória, o que é bem-vindo em máquinas modestas. A distribuição e a atualização (pacotes .deb ou .rpm no Linux, MSIX ou instaladores no Windows, ou ferramentas de atualização automática como Velopack) completam o ciclo.
O caixa da Livraria vende em Windows e Linux com a mesma interface, lê códigos de barras sem driver, confere quem está operando pelo mesmo provedor de identidade do site e continua registrando vendas quando a rede cai, entregando-as depois sem duplicar nenhuma. No caminho, o MVVM mostrou seu valor: toda a lógica da tela está em classes testáveis, e a View cuida só do que é visual. Falta o último canal da Livraria, o que fica no bolso do cliente.
Fontes e leituras recomendadas
- Avalonia — Getting Started e The MVVM Pattern: https://docs.avaloniaui.net/
- Avalonia — Compiled Bindings e Headless Testing: https://docs.avaloniaui.net/docs/basics/data/data-binding/compiled-bindings
- Microsoft Learn — CommunityToolkit.Mvvm: ObservableProperty e RelayCommand: https://learn.microsoft.com/dotnet/communitytoolkit/mvvm/generators/observableproperty
- RFC 8252 — OAuth 2.0 for Native Apps: https://www.rfc-editor.org/rfc/rfc8252
- Duende — IdentityModel.OidcClient: https://docs.duendesoftware.com/identitymodel-oidcclient/
- Martin Kleppmann et al. — Local-first software (Ink & Switch, 2019): https://www.inkandswitch.com/local-first/
Exercícios
Exercício 1
O total da venda não se atualiza quando o operador clica "+" num item, mas se atualiza quando um livro novo é lido. Qual das duas assinaturas do construtor do VendaViewModel está faltando, e por que o sintoma é exatamente esse?
Ver resposta
✓ Resposta: Falta a assinatura de PropertyChanged de cada item. CollectionChanged dispara quando um item entra ou sai da coleção, e por isso a leitura de um livro novo atualiza o total. Clicar "+" muda a Quantidade de um item que já está na coleção: a coleção não muda, e CollectionChanged não dispara. Só o próprio item avisa a mudança, pelo seu PropertyChanged, e se ninguém o escuta, Total nunca é recalculado para a View. A correção é assinar o PropertyChanged de cada item novo (nos e.NewItems do CollectionChanged) e, para ser completo, cancelar a assinatura dos que saem (e.OldItems), para não manter referências a itens removidos. Quando a venda é finalizada, Itens.Clear() dispara Reset, com OldItems nulo, então quem precisar soltar assinaturas deve guardar a própria lista.
Exercício 2
Um desenvolvedor mudou FinalizarAsync para chamar a API diretamente e só gravar na fila local "se der erro". Descreva a situação em que isso duplica uma venda e a situação em que perde uma.
Ver resposta
✓ Resposta: Duplicação: a API recebe o POST, grava a venda e a resposta se perde (a conexão cai, o timeout do cliente estoura antes da resposta chegar). O caixa trata como erro e grava na fila. O sincronizador reenvia depois. Se o envio direto não usava a mesma chave de idempotência da fila, a API registra a venda duas vezes. Perda: o caixa faz o POST, e o computador desliga (queda de energia, travamento) antes da resposta e antes de qualquer gravação local. A venda aconteceu fisicamente, o cliente levou o livro, e ela não existe em lugar nenhum. Gravar primeiro localmente, de forma durável, e só depois enviar, sempre com o Id da venda como chave de idempotência, fecha as duas janelas. Se a rede estiver boa, o sincronizador pode ser acionado imediatamente depois da gravação, e o envio acontece em milissegundos, sem abrir mão da garantia.
Exercício 3
Por que o cliente livraria-caixa no Keycloak é público, sem segredo, enquanto o livraria-site é confidencial? O que impede alguém de usar o client_id do caixa para obter tokens?
Ver resposta
✓ Resposta: O site roda num servidor que controlamos, e o segredo fica em variáveis de ambiente que só o processo lê. O caixa é um executável instalado em muitas máquinas, e qualquer segredo embutido pode ser extraído por quem tem acesso a uma delas. Um segredo que todos podem ler não é segredo, e declarar o cliente como público é mais honesto. O que protege o fluxo: (1) o PKCE, que liga o código de autorização a um verificador aleatório gerado a cada login, de modo que um código interceptado não pode ser trocado por tokens; (2) as URIs de retorno registradas (http://127.0.0.1:{porta}/), que fazem o provedor só entregar o código ao próprio computador; (3) sobretudo, o usuário precisa se autenticar. Alguém que use o client_id do caixa num programa próprio ainda precisa das credenciais de um operador, e obtém exatamente as permissões desse operador, nada além. O client_id identifica o aplicativo e não autoriza nada sozinho.
Exercício 4
Uma venda offline de 3 exemplares de um livro chega à API quando o sistema tem 1 em estoque. Descreva o que a API faz, o que o painel administrativo mostra e o que acontece com uma compra online do mesmo livro um segundo depois.
Ver resposta
✓ Resposta: A API registra a venda de balcão (a regra desse endpoint é registrar sempre), o estoque passa a −2, e a transação grava também na caixa de saída o evento estoque.divergente com o produto, o estoque resultante e a venda de origem. O retransmissor publica o evento. O consumidor da API o recebe e envia EstoqueDivergente ao grupo admins do hub, e o painel mostra algo como "Dom Casmurro: estoque −2 após venda no terminal 3". A compra online seguinte chega ao ServicoDeVendas, cujo update condicional (WHERE estoque >= quantidade) falha, porque −2 é menor que 1, e o cliente recebe 409. Não há risco de vender online o que não existe. Se houver um consumidor que suspende a venda online de produtos divergentes, a página do livro passa a mostrar "indisponível" em tempo real pelo ProdutoAlterado. A reconciliação (contar a prateleira e corrigir o estoque) é uma tarefa humana, registrada na auditoria.
Exercício 5
O sincronizador sai do ciclo (goto proximoCiclo) na primeira falha transitória. Por que não continuar tentando as outras vendas do lote? E em que situação essa decisão atrasaria indevidamente vendas que poderiam ter sido enviadas?
Ver resposta
✓ Resposta: Uma falha transitória (rede indisponível, 503, circuito aberto) quase sempre afeta todas as chamadas daquele momento. Continuar tentando as outras 19 vendas do lote só geraria 19 falhas a mais, com os timeouts correspondentes, mais log e mais carga numa API que talvez esteja se recuperando. Parar e esperar o próximo ciclo é o comportamento educado, o mesmo que o circuit breaker de "O site consumindo a API" impõe. A decisão atrasaria indevidamente vendas quando a falha é específica de uma venda, mas foi classificada como transitória: por exemplo, uma venda que provoca sempre um 500 por um bug na API ao processar um produto específico. Ela ficaria no topo da fila, falharia em todo ciclo, e bloquearia todas as outras para sempre, o problema da mensagem venenosa de "Worker Services e mensageria com RabbitMQ". A correção é contar as tentativas por venda e, depois de um limite (digamos 20, ou um dia), movê-la para "rejeitada" para revisão, e ordenar o lote de modo que vendas com muitas falhas não fiquem sempre na frente.