SQL Essencial com SQLite: Perguntas a um Banco de Dados

SQL Essencial com SQLite: Perguntas a um Banco de Dados

O modelo relacional e a linguagem SQL na prática, com SQLite: tabelas STRICT, chaves primárias e estrangeiras, dinheiro em centavos, SELECT, UPDATE e DELETE, a armadilha do NULL, GROUP BY, JOIN e LEFT JOIN, índices com EXPLAIN QUERY PLAN e transações ACID.
Linguagem C#

• • 19 min de leitura

Um banco de dados relacional guarda os dados em tabelas, protege sua integridade com regras e responde a perguntas escritas numa linguagem que você, depois do LINQ, já sabe ler.

"Testes Automatizados com xUnit" terminou com as limitações do catálogo em JSON: dois caixas sobrescrevendo as mudanças um do outro, perguntas que exigem carregar tudo na memória e nenhuma proteção contra uma venda de um produto inexistente. Dados que muitas pessoas consultam e alteram ao mesmo tempo, com regras de integridade e perguntas complexas, pedem um banco de dados. Abrimos a Fase 5, a mais longa do curso, com a fundação de tudo o que vem depois: o modelo relacional e a linguagem SQL. Nesta aula quase não escrevemos C#: a ferramenta é o SQLite, e a linguagem é o SQL. Nas próximas, o C# volta para conversar com ele.

Por que um banco de dados

Um arquivo JSON é ótimo para guardar a configuração de um programa, mas um banco de dados oferece garantias que um arquivo não tem:

  • Acesso concorrente seguro. Vários programas, ou várias threads, leem e escrevem ao mesmo tempo sem corromper nada, e o banco coordena tudo.
  • Integridade. Regras declaradas uma vez, como "todo produto tem preço positivo" e "toda venda aponta para um produto existente", são verificadas em toda gravação, venha ela de onde vier.
  • Consultas eficientes. O banco responde a "o faturamento de cada autor" sem carregar todos os dados na memória do programa, usando índices para achar o que precisa.
  • Transações. Várias alterações acontecem por inteiro ou não acontecem, mesmo com queda de energia no meio.

A gravação atômica de "Arquivos, Streams e JSON" e o lock de "Concorrência" eram versões artesanais de problemas que os bancos de dados resolvem de forma geral há cinquenta anos.

O modelo relacional

Em 1970, Edgar F. Codd, pesquisador da IBM, propôs organizar os dados em relações, que na prática chamamos de tabelas. Cada tabela tem colunas com nome e tipo, e cada linha é um registro. A proposta tinha uma ideia central: quem consulta diz o que quer, e não como buscar. O banco decide o caminho. Se isso soa familiar, é porque o LINQ de "LINQ, Parte I" foi desenhado com a mesma inspiração, e várias das suas palavras-chave vêm diretamente do SQL.

Duas noções sustentam o modelo:

  • Chave primária: uma coluna (ou combinação de colunas) que identifica cada linha de forma única. É o Id que tanto nos preocupou, finalmente no seu lugar natural.
  • Chave estrangeira: uma coluna que guarda a chave primária de outra tabela, criando um relacionamento. A venda guarda o identificador do produto, exatamente como o record Venda de "LINQ, Parte II", com a diferença de que agora o banco garante que o produto existe.

SQLite: um banco de dados num arquivo

Usaremos o SQLite para aprender. Ele não é um servidor: é uma biblioteca, escrita em C, que lê e grava um banco de dados inteiro num único arquivo. É, com folga, o banco de dados mais usado do mundo: está em todo celular Android e iOS, nos navegadores e em inúmeros programas de desktop. E ele tem uma conexão direta com a aula anterior à de testes: quando o C# usar o SQLite, estará chamando uma biblioteca C por P/Invoke, exatamente como em "Interop com C".

Para acompanhar esta aula, instale a ferramenta de linha de comando sqlite3 (em Linux e macOS ela costuma já estar presente, e no Windows pode ser baixada do site oficial) ou um programa gráfico como o DB Browser for SQLite. Crie o banco:

sqlite3 livraria.db

Um detalhe do SQLite que vale conhecer de saída: por padrão, ele é flexível com tipos. Uma coluna declarada como número aceita um texto sem reclamar. Desde a versão 3.37, a palavra-chave STRICT no fim da criação da tabela faz o SQLite verificar os tipos como os outros bancos fazem. Vamos usá-la sempre.

Criando as tabelas

A linguagem SQL se divide em duas partes. A que define a estrutura se chama DDL. A que manipula os dados, DML. Começamos pela estrutura da Livraria:

PRAGMA foreign_keys = ON;

CREATE TABLE autores (
    id    INTEGER PRIMARY KEY,
    nome  TEXT    NOT NULL UNIQUE
) STRICT;

CREATE TABLE produtos (
    id              INTEGER PRIMARY KEY,
    tipo            TEXT    NOT NULL CHECK (tipo IN ('impresso', 'ebook', 'audiolivro')),
    titulo          TEXT    NOT NULL,
    autor_id        INTEGER NOT NULL REFERENCES autores(id),
    preco_centavos  INTEGER NOT NULL CHECK (preco_centavos > 0),
    estoque         INTEGER CHECK (estoque >= 0),
    peso_gramas     INTEGER,
    tamanho_mb      REAL
) STRICT;

CREATE TABLE vendas (
    id          INTEGER PRIMARY KEY,
    produto_id  INTEGER NOT NULL REFERENCES produtos(id),
    quantidade  INTEGER NOT NULL CHECK (quantidade > 0),
    data        TEXT    NOT NULL    -- no formato ISO 8601: '2026-09-01'
) STRICT;

Cada detalhe desse código é uma decisão de modelagem.

INTEGER PRIMARY KEY faz o SQLite gerar o identificador automaticamente a cada inserção, com o valor seguinte ao maior existente. O contador estático que usamos desde "Classes e Objetos", com todas as suas fragilidades, finalmente pode ser aposentado.

As restrições (NOT NULL, UNIQUE e CHECK) são as validações de "Quando Algo Dá Errado" e dos construtores da Fase 2, agora aplicadas pelo banco. Um INSERT com preço zero ou com um tipo que não está na lista é recusado, não importa qual programa tente.

REFERENCES declara as chaves estrangeiras. Aqui mora uma armadilha famosa: no SQLite, a verificação de chaves estrangeiras vem desligada por padrão, por compatibilidade com versões antigas. O PRAGMA foreign_keys = ON; a liga, e precisa ser executado a cada conexão. Sem ele, o REFERENCES é só um comentário.

O dinheiro em centavos. O SQLite não tem um tipo decimal exato. Os números fracionários são guardados como REAL, o double de ponto flutuante binário, com todas as imprecisões que vimos em "Cada Valor Tem um Tipo". Por isso, preços ficam como inteiros em centavos: R$ 59,90 é guardado como 5990. A conversão para decimal acontece no C#. Outros bancos, como PostgreSQL e MySQL, têm um tipo NUMERIC/DECIMAL exato, e lá guardaremos os valores diretamente.

Uma tabela para toda a hierarquia. Livros impressos, e-books e audiolivros ficam na mesma tabela, distinguidos pela coluna tipo, que é o discriminador que usamos no JSON em "Arquivos, Streams e JSON". As colunas específicas de cada tipo ficam nulas quando não se aplicam: um e-book não tem estoque nem peso. Essa estratégia se chama tabela por hierarquia, e é uma das três formas de mapear herança para tabelas. Voltaremos a elas com o Entity Framework. (Para manter o exemplo curto, omitimos as colunas próprias do audiolivro, como duração e narrador.)

Datas como texto. O SQLite também não tem um tipo de data, e a convenção é guardá-las como texto no formato ISO 8601, AAAA-MM-DD. Esse formato tem uma propriedade valiosa: a ordem alfabética coincide com a ordem cronológica, então comparações e ordenações funcionam corretamente.

Inserindo dados

Os mesmos dados de "LINQ, Parte II":

INSERT INTO autores (id, nome) VALUES
    (1, 'Machado de Assis'), (2, 'Graciliano Ramos'),
    (3, 'Guimarães Rosa'),   (4, 'Clarice Lispector');

INSERT INTO produtos (id, tipo, titulo, autor_id, preco_centavos, estoque, peso_gramas, tamanho_mb) VALUES
    (1, 'impresso',   'Dom Casmurro',                    1, 5990, 10,   350,  NULL),
    (2, 'impresso',   'Memórias Póstumas de Brás Cubas', 1, 4490, 0,    300,  NULL),
    (3, 'ebook',      'Dom Casmurro',                    1, 1990, NULL, NULL, 2.4),
    (4, 'impresso',   'Vidas Secas',                     2, 3800, 4,    250,  NULL),
    (5, 'ebook',      'São Bernardo',                    2, 2290, NULL, NULL, 1.6),
    (6, 'impresso',   'Grande Sertão: Veredas',          3, 8900, 2,    1200, NULL),
    (7, 'audiolivro', 'A Hora da Estrela',               4, 3490, NULL, NULL, NULL);

INSERT INTO vendas (produto_id, quantidade, data) VALUES
    (1, 3, '2026-09-01'), (3, 5, '2026-09-01'),
    (4, 2, '2026-09-02'), (1, 1, '2026-09-02'),
    (5, 4, '2026-09-03'), (7, 1, '2026-09-03'), (3, 2, '2026-09-03');

Informamos os identificadores de autores e produtos explicitamente para que os exemplos fiquem previsíveis. Nas vendas, deixamos o banco gerá-los. Textos em SQL ficam entre aspas simples, e uma aspa dentro do texto é escrita duplicada: 'D''Ávila'. Guarde esse detalhe: ele é o centro da próxima aula.

Consultando: SELECT

A consulta básica tem a forma SELECT colunas FROM tabela WHERE condição ORDER BY ordenação:

SELECT titulo, preco_centavos / 100.0 AS preco
FROM produtos
WHERE preco_centavos < 4000
ORDER BY preco_centavos;
Dom Casmurro        19.9
São Bernardo        22.9
A Hora da Estrela   34.9
Vidas Secas         38.0

Compare com o LINQ de "LINQ, Parte I": WHERE é o Where, SELECT é o Select, ORDER BY é o OrderBy. A única diferença de ordem é que o SQL escreve o SELECT primeiro, embora o banco o avalie depois do filtro. AS preco dá um nome à coluna calculada. A divisão por 100.0, e não por 100, é a mesma lição da divisão inteira de "Operadores, Texto e Conversa com o Usuário": com 100, 1990 / 100 daria 19.

Para paginar, LIMIT e OFFSET fazem o papel do Take e do Skip: LIMIT 10 OFFSET 20 devolve a terceira página de dez itens.

Alterando e removendo: UPDATE e DELETE

UPDATE produtos SET estoque = estoque + 5 WHERE id = 6;
DELETE FROM vendas WHERE id = 7;

Duas instruções curtas, e as mais perigosas do SQL. Um UPDATE ou DELETE sem WHERE atinge todas as linhas da tabela, sem pedir confirmação. Um hábito profissional antes de qualquer alteração em dados reais: escreva primeiro o SELECT com o mesmo WHERE, confira quais linhas ele devolve e só então troque pelo UPDATE, de preferência dentro de uma transação, que você pode desfazer.

O valor que não existe: NULL

Em "Nullable Reference Types" vimos que comparar um int? nulo com qualquer número dá sempre falso. O SQL tem exatamente o mesmo comportamento, e ele engana muita gente:

SELECT COUNT(*) FROM produtos WHERE estoque = NULL;    -- 0 (!)
SELECT COUNT(*) FROM produtos WHERE estoque IS NULL;   -- 3

NULL significa "valor desconhecido", e qualquer comparação com um valor desconhecido tem resultado desconhecido, que o WHERE trata como falso. Nem NULL = NULL é verdadeiro. Para testar a ausência, use sempre IS NULL e IS NOT NULL. E lembre-se disso nas condições negativas: WHERE estoque > 0 exclui os e-books, cujo estoque é nulo, e é provavelmente por isso que você escreveria WHERE estoque IS NULL OR estoque > 0 para listar os produtos disponíveis.

Agregando: GROUP BY e HAVING

As funções de agregação (COUNT, SUM, AVG, MIN e MAX) resumem várias linhas num valor, e GROUP BY faz isso por grupo, como o GroupBy de "LINQ, Parte II":

SELECT tipo, COUNT(*) AS quantidade, AVG(preco_centavos) / 100.0 AS preco_medio
FROM produtos
GROUP BY tipo;

HAVING filtra grupos, enquanto o WHERE filtra linhas antes do agrupamento:

SELECT produto_id, SUM(quantidade) AS unidades
FROM vendas
GROUP BY produto_id
HAVING SUM(quantidade) >= 4;

Cruzando tabelas: JOIN

A junção combina linhas de tabelas diferentes pela igualdade de chaves, como o Join do LINQ. Eis o relatório de faturamento por autor de "LINQ, Parte II", agora respondido pelo banco:

SELECT a.nome AS autor,
       SUM(v.quantidade * p.preco_centavos) / 100.0 AS faturamento
FROM vendas v
JOIN produtos p ON p.id = v.produto_id
JOIN autores  a ON a.id = p.autor_id
GROUP BY a.nome
ORDER BY faturamento DESC;
Machado de Assis    378.9
Graciliano Ramos    167.6
Clarice Lispector   34.9

São os mesmos números calculados com LINQ na memória, agora obtidos sem que o programa carregue uma única venda. v, p e a são apelidos das tabelas, que deixam a consulta legível.

O JOIN simples é uma junção interna: produtos sem vendas não aparecem. Para a pergunta "quais produtos nunca venderam?", precisamos da junção externa, o LEFT JOIN, que mantém todas as linhas da tabela da esquerda e preenche com NULL o que não tem correspondente:

SELECT p.titulo
FROM produtos p
LEFT JOIN vendas v ON v.produto_id = p.id
WHERE v.id IS NULL;
Memórias Póstumas de Brás Cubas
Grande Sertão: Veredas

É o GroupJoin de "LINQ, Parte II", e o LeftJoin do .NET 10, na forma original de onde ambos vieram.

Índices: não percorrer tudo

Para encontrar as vendas de um produto, o banco precisaria, a princípio, ler todas as linhas da tabela vendas. Com dez vendas, tanto faz. Com dez milhões, é a diferença entre milissegundos e segundos. Um índice é uma estrutura auxiliar, ordenada por uma ou mais colunas, que permite ir direto às linhas desejadas:

CREATE INDEX ix_vendas_produto ON vendas (produto_id);

Por dentro, o índice é uma árvore B, uma árvore balanceada e larga em que cada busca percorre poucos níveis. É o equivalente, em disco, do HashSet que transformou uma busca quadrática numa busca instantânea no último exercício de "LINQ, Parte II". O comando EXPLAIN QUERY PLAN mostra se o banco decidiu usar o índice:

EXPLAIN QUERY PLAN SELECT * FROM vendas WHERE produto_id = 3;
-- algo como: SEARCH vendas USING INDEX ix_vendas_produto (produto_id=?)

Sem o índice, o plano mostraria SCAN vendas, uma varredura completa. Índices não são gratuitos: cada INSERT, UPDATE e DELETE precisa atualizá-los, e eles ocupam espaço. Crie índices para as colunas usadas com frequência em WHERE, JOIN e ORDER BY, e não para todas. Chaves primárias já têm índice automaticamente. Chaves estrangeiras não, e quase sempre deveriam ter.

Transações: tudo ou nada

Uma venda envolve duas alterações: baixar o estoque e registrar a venda. Se o programa cair entre as duas, o estoque fica errado. Uma transação agrupa as operações para que aconteçam por inteiro ou não aconteçam:

BEGIN;
UPDATE produtos SET estoque = estoque - 2 WHERE id = 4 AND estoque >= 2;
INSERT INTO vendas (produto_id, quantidade, data) VALUES (4, 2, '2026-09-04');
COMMIT;

COMMIT confirma tudo. Se algo der errado no meio, ROLLBACK desfaz tudo o que foi feito desde o BEGIN, e uma queda de energia antes do COMMIT tem o mesmo efeito. As garantias das transações têm um nome consagrado, ACID: atomicidade (tudo ou nada), consistência (as regras continuam válidas), isolamento (transações simultâneas não enxergam o trabalho pela metade umas das outras) e durabilidade (o que foi confirmado sobrevive a falhas). O mecanismo por trás da durabilidade é uma versão sofisticada da gravação atômica de "Arquivos, Streams e JSON".

Repare na condição AND estoque >= 2 no UPDATE. Ela é a resposta do banco à condição de corrida de "Concorrência": verificar e alterar numa única instrução, que o banco executa de forma indivisível. Voltaremos a ela na próxima aula.

Juntando tudo: o relatório inteiro em SQL

O relatório de vendas por dia, com o total de unidades e o faturamento, mais uma coluna com a média por venda:

SELECT v.data,
       COUNT(*)                                          AS vendas,
       SUM(v.quantidade)                                 AS unidades,
       SUM(v.quantidade * p.preco_centavos) / 100.0      AS faturamento,
       ROUND(AVG(v.quantidade * p.preco_centavos) / 100.0, 2) AS ticket_medio
FROM vendas v
JOIN produtos p ON p.id = v.produto_id
GROUP BY v.data
ORDER BY v.data;
2026-09-01  2  8  279.2  139.6
2026-09-02  2  3  135.9  67.95
2026-09-03  3  7  166.3  55.43

Os totais conferem com os de "LINQ, Parte II": R$ 279,20, R$ 135,90 e R$ 166,30. A comparação com o LINQ não é coincidência: as duas linguagens descrevem o que se quer, filtrar, agrupar, cruzar e agregar, e deixam o como para o motor que executa. A diferença é onde o trabalho acontece. Com LINQ sobre listas, os dados precisam estar na memória do programa. Com SQL, o banco trabalha perto dos dados, usa índices e devolve só o resultado.

Até aqui, digitamos cada comando no terminal do SQLite. A Livraria, porém, é um programa C#, e é ele que precisa criar as tabelas, inserir as vendas e fazer as consultas. Colocar SQL dentro de um programa parece simples: basta montar o texto do comando e enviá-lo ao banco. E é exatamente nesse "montar o texto" que mora uma das falhas de segurança mais antigas, mais exploradas e mais fáceis de evitar da história do software.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Escreva uma consulta que devolva os três produtos mais vendidos em unidades, com o título, o tipo e o total de unidades, em ordem decrescente.

Ver resposta

✓ Resposta:

SELECT p.titulo, p.tipo, SUM(v.quantidade) AS unidades
FROM vendas v
JOIN produtos p ON p.id = v.produto_id
GROUP BY p.id, p.titulo, p.tipo
ORDER BY unidades DESC, p.titulo
LIMIT 3;

O resultado é o e-book de Dom Casmurro com 7 unidades, depois o impresso de Dom Casmurro e São Bernardo, com 4 cada, os mesmos de "LINQ, Parte II". O agrupamento é por p.id, e não só pelo título, porque os dois "Dom Casmurro" são produtos diferentes. Agrupar pelo título os somaria. É a mesma armadilha resolvida lá com a chave composta new { Titulo, Formato }. O desempate por título em ORDER BY torna o resultado determinístico. Sem ele, a ordem entre os empatados poderia variar de uma execução para outra.

Exercício 2

A consulta SELECT COUNT(*) FROM produtos WHERE estoque = NULL; devolve zero. Explique por quê, corrija-a e escreva outra que conte os produtos disponíveis (digitais ou com estoque positivo).

Ver resposta

✓ Resposta: NULL representa um valor desconhecido, e o resultado de qualquer comparação com ele, inclusive NULL = NULL, também é desconhecido. O WHERE só mantém linhas cuja condição é verdadeira, então nenhuma linha passa. A forma correta de testar a ausência é IS NULL:

SELECT COUNT(*) FROM produtos WHERE estoque IS NULL;                     -- 3: os digitais
SELECT COUNT(*) FROM produtos WHERE estoque IS NULL OR estoque > 0;      -- 6

Na segunda consulta, os três digitais têm estoque nulo e entram pela primeira condição. Dos quatro impressos, só Memórias Póstumas, com estoque zero, fica de fora. Sem o estoque IS NULL, a condição estoque > 0 excluiria os digitais em silêncio, porque para eles o resultado seria desconhecido, e não falso por algum motivo visível. É o mesmo comportamento das comparações com int? de "Nullable Reference Types": a ausência precisa ser tratada explicitamente.

Exercício 3

Tente inserir uma venda do produto 999, que não existe, antes e depois de executar PRAGMA foreign_keys = ON; numa conexão nova. O que acontece em cada caso, e que lição isso traz para o programa C#?

Ver resposta

✓ Resposta: Numa conexão nova, sem o PRAGMA, o INSERT INTO vendas (produto_id, quantidade, data) VALUES (999, 1, '2026-09-05'); é aceito: o SQLite mantém a verificação de chaves estrangeiras desligada por padrão, por compatibilidade com bancos criados antes de o recurso existir, e a venda órfã entra no banco. Com PRAGMA foreign_keys = ON; executado antes, o mesmo comando falha com "FOREIGN KEY constraint failed", e nada é gravado. A lição para o programa é que essa configuração vale por conexão, e não por banco. Todo código que abre uma conexão precisa ligá-la. Esquecer num único ponto, como um script de importação, basta para contaminar o banco com dados inválidos. O provedor do SQLite para .NET permite ligá-la na própria cadeia de conexão, o que centraliza a decisão num único lugar, como veremos na próxima aula. Os outros bancos da fase, PostgreSQL e MySQL, verificam chaves estrangeiras sempre.

Exercício 4

Aumente em 10% o preço de todos os livros impressos, com arredondamento para o centavo mais próximo, de forma segura: confira as linhas afetadas antes e use uma transação.

Ver resposta

✓ Resposta:

-- 1. Conferir o que será alterado
SELECT id, titulo, preco_centavos, CAST(ROUND(preco_centavos * 1.10) AS INTEGER) AS novo
FROM produtos
WHERE tipo = 'impresso';

-- 2. Alterar dentro de uma transação
BEGIN;
UPDATE produtos
SET preco_centavos = CAST(ROUND(preco_centavos * 1.10) AS INTEGER)
WHERE tipo = 'impresso';
-- conferir de novo: SELECT titulo, preco_centavos FROM produtos WHERE tipo = 'impresso';
COMMIT;   -- ou ROLLBACK, se algo parecer errado

O SELECT prévio com o mesmo WHERE mostra exatamente quais linhas serão atingidas e quais serão os novos valores. O arredondamento merece atenção: preco_centavos * 110 / 100, com inteiros, faria divisão inteira e truncaria. Com os preços atuais, que terminam em zero, as duas contas coincidem (5990 vira 6589 nas duas). Com 1999, porém, a divisão inteira dá 2198, e o correto, 2198,9 arredondado, é 2199: a truncagem perde um centavo. ROUND(... * 1.10) arredonda ao inteiro mais próximo, e o CAST deixa explícito que o valor gravado é um inteiro. A tabela STRICT até aceitaria o 6589.0 que o ROUND devolve, porque ele vira inteiro sem perda, mas recusaria um valor com casas decimais. Dentro da transação, dá para conferir o resultado antes de torná-lo definitivo. Um UPDATE esquecido sem WHERE teria aumentado também os e-books e o audiolivro, e o ROLLBACK seria a salvação.

Exercício 5

Traduza para SQL a consulta LINQ de "LINQ, Parte I" que listava os três produtos mais baratos, com título, formato e preço, e compare as duas formas.

Ver resposta

✓ Resposta: A consulta LINQ era:

catalogo.OrderBy(p => p.Preco).Take(3)

Em SQL:

SELECT titulo, tipo AS formato, preco_centavos / 100.0 AS preco
FROM produtos
ORDER BY preco_centavos
LIMIT 3;

O resultado é o mesmo: Dom Casmurro em e-book (19,90), São Bernardo (22,90) e A Hora da Estrela (34,90). As correspondências são diretas: OrderBy vira ORDER BY, Take vira LIMIT, e a projeção do Select vira a lista de colunas. A diferença está em onde cada uma executa. O LINQ sobre uma List<Produto> exige que todos os produtos já estejam na memória e ordena todos eles para pegar três. O SQL executa no banco, que pode usar um índice sobre preco_centavos para ler só as três primeiras entradas, e devolve apenas três linhas ao programa. Com sete produtos, tanto faz. Com setecentos mil, a diferença é decisiva. E é justamente essa semelhança entre as duas linguagens que permitirá, daqui a três aulas, escrever consultas em LINQ que o Entity Framework traduz para SQL e executa no banco.

Comentários

Mais em Linguagem C#

Interfaces no .NET: WinForms, WPF, MAUI, Avalonia e o que escolher
Interfaces no .NET: WinForms, WPF, MAUI, Avalonia e o que escolher

Do Windows Forms de 2002 ao .NET MAUI e ao Avalonia: a história das interfaces…

Deploy: contêineres, nginx, Linux e a orquestração com Aspire
Deploy: contêineres, nginx, Linux e a orquestração com Aspire

A Livraria sai do computador de quem desenvolve: imagens de contêiner geradas…

Tempo real com SignalR: quando o servidor toma a iniciativa
Tempo real com SignalR: quando o servidor toma a iniciativa

A Livraria passa a avisar quem está conectado: polling, SSE e WebSockets, hubs…