System Design
Por que escalabilidade importa mesmo antes da sua aplicação ficar grande
Escalabilidade não é sobre tráfego que você ainda não tem. Alguns hábitos de .NET, manter a aplicação stateless, async até o fim, e não chamar o database num loop, não custam nada agora e são dolorosos de adicionar depois.
"Você não precisa se preocupar com escalabilidade ainda" é um conselho que ouvi bastante, e está na maior parte certo. Projetar para milhões de usuários quando você não tem nenhum é como projetos nunca são lançados.
Mas existe uma categoria de decisão que não custa nada quando você a toma cedo e é genuinamente dolorosa de retrofitar depois, e como desenvolvedor júnior essas são as que vale a pena conhecer. Não porque meus projetos têm tráfego, mas porque algumas dessas coisas são a diferença entre adicionar um segundo server ser uma mudança de config ou uma reescrita.
Mantenha o estado fora da aplicação
Essa é a grande. Se minha API armazena qualquer coisa importante na própria memória, então um request tem que voltar para a mesma instância para funcionar corretamente, e isso significa que eu nunca posso rodar duas cópias.
Dados de session em memória, uma lista em cache num campo static, um arquivo enviado escrito no disco local. Cada uma dessas coisas silenciosamente amarra um usuário a uma instância específica. Tire elas de lá, para um database, um cache distribuído, blob storage, e de repente não importa mais qual instância responde a um request.
Esbarrei nisso concretamente com o URL shortener que construí. Três cópias da aplicação atrás de um load balancer só funciona porque nenhuma delas guarda nada localmente. Toda cópia vê os mesmos dados porque o dado vive no Cassandra e no Redis, não na aplicação. Isso não foi uma otimização de escalabilidade, foi só como a aplicação tinha que ser escrita para load balancing ser possível de qualquer jeito.
Async até o fim
No ASP.NET Core, uma thread bloqueada esperando por uma chamada de database é uma thread que não consegue atender outro request. Com um usuário isso é invisível. Com cem requests concorrentes é a diferença entre atendê-los e enfileirá-los.
// bloqueia uma thread enquanto espera
var product = _context.Products.Find(id);
// devolve a thread para o pool
var product = await _context.Products.FindAsync(id);
A versão async não é mais rápida para um único request. Ela só significa que a thread volta para o pool em vez de ficar ali parada sem fazer nada, o que é o que permite um server pequeno lidar com muito mais trabalho concorrente do que ele tem threads.
Escrever await desde o início não custa nada. Converter um codebase síncrono para async depois significa tocar em todo método na cadeia de chamadas, porque async tem que ir até o fim.
Não chame o database num loop
O erro clássico, e um que eu já cometi:
var orders = await _context.Orders.ToListAsync();
foreach (var order in orders)
{
// uma query extra por pedido
order.Customer = await _context.Customers.FindAsync(order.CustomerId);
}
Com dez pedidos isso são onze queries e ninguém percebe. Com mil pedidos são mil e uma, e o endpoint dá timeout. O conserto é pedir o que você precisa de antemão:
var orders = await _context.Orders
.Include(o => o.Customer)
.ToListAsync();
O que torna isso digno de pegar cedo é que funciona bem em desenvolvimento. Meu database de teste tem cinco linhas nele. O problema só aparece com dado real, que é a pior hora para descobri-lo.
Só busque o que você precisa
Hábito relacionado: AsNoTracking() em queries somente leitura, e selecionar as colunas específicas em vez de entities inteiras quando eu só preciso de alguns campos.
var names = await _context.Products
.AsNoTracking()
.Select(p => new { p.Id, p.Name })
.ToListAsync();
O change tracker do EF Core existe para conseguir detectar o que você modificou e gerar updates. Numa query onde nada é modificado, isso é contabilidade sem motivo.
Coloque os índices desde cedo
Se eu sei que uma coluna vai ser consultada, o índice pertence à migration que cria a tabela. Adicionar um índice numa tabela vazia é instantâneo. Adicionar um numa tabela grande em produção é uma operação que você tem que planejar em volta.
O que eu ainda ignoraria
Camadas de cache, message queues, read replicas, sharding, qualquer coisa disso. Elas resolvem problemas que eu consigo medir, e eu ainda não consigo medi-los. Adicionar um cache a um projeto sem tráfego só significa mais código e uma nova classe de bug onde o cache e o database discordam.
A linha que eu de fato traço
A pergunta que eu faço não é "isso vai escalar." É "se isso precisar mudar depois, é uma mudança de config ou uma reescrita?"
Stateless, async, sem queries em loops, índices sensatos — todos esses são "escreva assim desde o início e é de graça." Caching, queues, e replicas são todos "adicione quando você tiver uma medição que diga que precisa disso." Acertar a primeira lista cedo é o que torna a segunda lista uma escolha em vez de uma emergência.