// estudo de caso

MakerDesk: um SaaS multi-tenant do zero à produção

Plataforma de gestão para negócios de impressão 3D. Produto próprio, desenvolvido inteiramente por mim — modelagem do banco, API, web, mobile, landing e billing recorrente. Esta página é sobre as decisões e o que cada uma custou.

4
aplicações em produção
37
tabelas modeladas
28
migrations versionadas
25
controllers REST

O problema

Quem vende impressão 3D precifica no chute. O custo de uma peça depende do peso de filamento, do tempo de máquina, do custo/hora da impressora, de custos extras e de uma taxa de falha que quase ninguém contabiliza — e o resultado disso costuma ser uma planilha que ninguém atualiza. Sem isso organizado, não dá para saber se um pedido deu lucro.

O MakerDesk fecha esse ciclo: materiais e impressoras alimentam o cálculo de custo, o preço de venda sai com margem explícita, a encomenda consome estoque de verdade e o dashboard mostra o que sobrou. A regra de negócio central do sistema é essa conta.

Arquitetura

API

NestJS 11 + Fastify · PostgreSQL + Drizzle · Docker no Google Cloud Run

22 módulos de domínio, 25 controllers REST documentados em Swagger, jobs agendados, rate limiting, Helmet e CORS restrito.

Web

React 19 + Vite · TanStack Query · Tailwind · Radix UI

Dashboard com KPIs e gráficos, kanban de encomendas com drag-and-drop, catálogo público por organização e painel administrativo.

Mobile

React Native + Expo SDK 54 · Expo Router · TanStack Query

Mais de 15 telas com sessão persistida e refresh de token ao voltar para foreground.

Landing

Next.js 15 · Tailwind · Vercel

Site institucional estático, otimizado para busca.

Integrações

Stripe
Checkout, assinaturas, proration e webhooks idempotentes
Google Cloud Storage
Upload de imagens com conversão para WebP via Sharp
pdf-lib
Orçamentos em PDF gerados no servidor
ExcelJS
Exportação de relatórios em XLSX
Brevo
E-mails transacionais

Decisões técnicas e trade-offs

Toda escolha aqui fechou uma porta. O que segue é o que ganhei e o que paguei em cada uma.

Isolamento entre organizações na aplicação, não no banco

decisãoTodo acesso a dados passa por um escopo de organização aplicado na camada de repositório, em vez de Row Level Security no PostgreSQL.

por quêA API roda com uma conexão de serviço e precisa de operações administrativas que cruzam organizações (painel admin, relatórios de plataforma, jobs agendados). Com RLS, cada uma dessas rotas viraria uma exceção de política.

trade-offO banco deixa de ser a última linha de defesa: um `where` esquecido vaza dados entre inquilinos. O preço disso é disciplina — o escopo tem que valer na leitura e na escrita, e precisa de teste que rode contra um Postgres real, porque com ORM mockado a query sem filtro passa.

Drizzle ORM em vez de Prisma

decisãoSchema e queries em TypeScript com Drizzle, migrations SQL versionadas no repositório.

por quêAs migrations são arquivos SQL legíveis que eu reviso antes de aplicar, e as queries compilam para SQL previsível — o que importa em relatórios e agregações do dashboard. Sem engine binária, o container fica menor e sobe mais rápido no Cloud Run.

trade-offMenos ergonomia que o Prisma em relações profundas: joins e agregações são mais verbosos, e boa parte do ferramental de terceiros ainda assume Prisma.

Autenticação delegada ao Supabase, verificação local por JWKS

decisãoO Supabase emite o token; a API valida a assinatura contra o JWKS do projeto com `jose`, dentro de um guard do Nest.

por quêNão reimplementar cadastro, recuperação de senha e rotação de refresh token. Validando por JWKS, a API não faz chamada de rede a cada request — só busca a chave pública, que fica em cache.

trade-offDependência de um provedor externo no caminho crítico do login, e o modelo de usuário fica dividido entre o Supabase e as tabelas de domínio. A troca de provedor um dia custa uma migração de identidades.

Planos como feature flags e limites declarados no próprio método

decisãoDecorators `@HasFeature` e `@PlanLimit` marcam o que cada rota exige — hoje são 52 checagens de recurso e 13 de limite de uso.

por quêA regra de plano fica ao lado do código que ela protege, não num `if` espalhado pelo serviço. Adicionar um plano é mudar dados de seed, não código.

trade-offUm decorator com código de recurso inexistente falha silenciosamente — libera o que deveria bloquear. Isso pede um teste de contrato que verifique que todo código usado existe no seed e tem contador correspondente.

Upgrade com proration, downgrade agendado

decisãoUpgrade cobra a diferença proporcional na hora; downgrade entra como Subscription Schedule para valer só no fim do ciclo já pago. Webhooks são idempotentes.

por quêQuem paga mais deve ter acesso imediato; quem reduz o plano não pode perder recurso que já pagou. E o Stripe reentrega webhooks — processar duas vezes viraria cobrança ou liberação duplicada.

trade-offO estado da assinatura passa a viver em dois lugares (Stripe e banco) e precisa reconciliar. Downgrade agendado também significa que o app tem de exibir "plano atual" e "plano a partir de tal data" como coisas diferentes.

Máquinas de estado para encomendas e orçamentos

decisãoTransições válidas declaradas explicitamente; mudanças que mexem em estoque acontecem dentro de transação no banco.

por quêO fluxo real tem caminhos que não podem existir — concluir uma encomenda cancelada, faturar um orçamento já convertido. Deixar isso como campo de texto livre é convidar dado inconsistente.

trade-offCada novo estado exige revisar a matriz de transições e as telas que dependem dela. É mais rígido de evoluir do que um simples enum.

Quatro repositórios separados, e o que isso ensinou

decisãoAPI, web, mobile e landing nasceram como repositórios independentes.

por quêCada app tem ciclo de deploy próprio: a landing vai para a Vercel a cada commit, a API sobe imagem no Cloud Run.

trade-offOs tipos do contrato HTTP foram duplicados entre web e mobile, e mudança de campo na API quebrava o cliente só em runtime. Foi daí que veio o boilerplate em monorepo que mantenho hoje: pacote de tipos compartilhado, api-client único e CI que falha quando o schema muda sem migration.

O que eu faria diferente

  • Começaria em monorepo, com o contrato HTTP num pacote de tipos compartilhado. A duplicação entre web e mobile foi a fonte mais cara de bug bobo.
  • Escreveria o teste de isolamento entre organizações contra um Postgres real desde o primeiro módulo, não depois. Com ORM mockado, uma query sem filtro de organização passa no teste e vaza em produção.
  • Trataria os webhooks do Stripe como fila desde o início, com reprocessamento explícito. Idempotência resolve entrega dupla, mas não resolve o webhook que falha enquanto a API está reiniciando.

O que aprendi aqui virou base reutilizável: hoje mantenho um boilerplate SaaS em monorepo com auth, organizações, billing e CI de deploy blue-green.

Falar sobre o projeto