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.
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 Run22 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 UIDashboard 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 QueryMais de 15 telas com sessão persistida e refresh de token ao voltar para foreground.
Landing
Next.js 15 · Tailwind · VercelSite 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