O problema
Uma oficina mecânica pequena, um dono que também é o mecânico, um caderno de orçamentos e um controle de peças que vivia entre a cabeça dele e um monte de anotações soltas. Cada orçamento era feito na mão: peça por peça, mão de obra por mão de obra, soma no fim, e o cliente recebia isso escrito num papel ou fotografado e mandado pelo WhatsApp. Quando o orçamento era fechado, a baixa no estoque — se acontecia — era manual e, com frequência, esquecida. O resultado clássico: peça que "tinha" no papel mas não tinha na prateleira, retrabalho de contagem, e nenhum jeito de olhar rápido "quanto eu fechei essa semana".
Não tinha internet confiável no local, não tinha interesse em pagar mensalidade de um SaaS genérico de oficina, e o dono não queria aprender uma ferramenta cheia de funcionalidade que ele nunca ia usar. O que faltava era uma coisa simples: cadastrar cliente, cadastrar peça e mão de obra, montar o orçamento, fechar com baixa de estoque automática, e gerar um PDF decente pra mandar pro cliente. Foi esse o escopo do Hardox.
Por que desktop, e não mais um SaaS
A decisão mais importante do projeto foi arquitetural antes de ser técnica: sem servidor. Usuário único, um computador só, sem depender de internet para o sistema funcionar durante o expediente. Isso descartou de cara qualquer arquitetura cliente-servidor com API, autenticação de sessão HTTP, hospedagem, backup em nuvem — tudo que normalmente vem "de graça" com um web app, mas que aqui seria complexidade pura sem benefício nenhum pro usuário real.
A escolha caiu em Wails v2: um binário nativo que empacota um backend em Go com uma webview rodando um frontend Vue, sem Electron, sem Node em produção, sem processo de servidor HTTP separado. O app conversa com o backend via bindings gerados automaticamente a partir de structs Go — do ponto de vista do frontend, é só await numa função JS que por baixo dos panos vira uma chamada nativa.
Stack
- Backend: Go + Wails v2 + GORM sobre SQLite, usando
github.com/glebarez/sqlite— um driver SQLite em Go puro, sem cgo. Isso importa mais do que parece: sem cgo, cross-compilar pra Windows a partir do macOS (ou vice-versa) é trivial, sem toolchain de C por plataforma. - Frontend: Vue 3 + TypeScript + Vite, UI com Tabler (Bootstrap 5) e Font Awesome.
- PDF: maroto para gerar o orçamento impresso.
- Banco: um único arquivo SQLite, em WAL mode, guardado no diretório de config do usuário — o mesmo arquivo é usado tanto em desenvolvimento quanto no app compilado.
Arquitetura: três camadas, uma direção só
O backend segue uma separação simples e rígida: repository (queries GORM) → service (regra de negócio, DTOs, transações) → bind (uma struct por API exposta ao frontend, que valida sessão e traduz erros) → tudo isso registrado em wails.Bind no main.go. O frontend nunca fala diretamente com repository ou service — só com os bindings gerados em frontend/wailsjs/go/bind/*.
Isso vira relevante rápido quando o domínio deixa de ser CRUD puro. O orçamento (Budget) é o objeto central do sistema, e tem uma máquina de estados simples: ABERTO → FECHADO ou CANCELADO, os dois terminais. Só orçamento ABERTO aceita alteração de item. O método Close() é a transação mais delicada do sistema inteiro: ele valida o estoque de todas as peças do orçamento antes de dar baixa em qualquer uma — um item com estoque insuficiente aborta o fechamento inteiro, sem deixar o banco pela metade.
Outro detalhe que só fica óbvio depois que o sistema está em uso real: os itens do orçamento fazem um "snapshot" da descrição e do preço da peça/mão de obra no momento em que são adicionados. Se o dono reajustar o preço de uma peça no catálogo mês que vem, os orçamentos já fechados não podem mudar de valor retroativamente — o cliente recebeu um PDF com um preço, e é esse preço que tem que continuar batendo se ele voltar com o papel na mão.
Erros que só o Go↔JS boundary tem
Como o binding entre Go e o webview funciona rejeitando a Promise do JS com a string do Error() do Go, um erro solto vira uma mensagem de erro genérica e feia na tela. Pra evitar isso, existe uma camada de tradução: erros de domínio conhecidos (domain.ErrInsufficientStock, domain.ErrBudgetNotEditable, etc.) viram sentinelas explícitas em internal/domain/errors.go, e uma função translateError() os embrulha num JSON {"code","message"} antes de cruzar a fronteira. Do lado do frontend, um parseApiError() desfaz esse JSON, e o código Vue pode decidir o que fazer olhando o code em vez de fazer match de texto livre. Erro de validação pontual (tipo "quantidade deve ser maior que zero") não precisa dessa cerimônia toda — vira um fmt.Errorf simples em português e cai no código genérico INTERNAL, mostrado como está.
Os problemas que só aparecem em produção
A parte mais interessante de construir isso não foi o CRUD — foi o que quebrou (ou quase quebrou) depois que o app saiu do ambiente de desenvolvimento e foi usado de verdade, no dia a dia da oficina.
window.confirm() não funciona direito dentro do webview do Wails. Um botão de exclusão guardado por um simples if (!confirm(...)) return simplesmente não fazia nada ao clicar — sem erro, sem log, o clique morria ali. A solução foi tirar qualquer dependência de confirm/alert/prompt nativos e centralizar num modal de confirmação próprio: uma store Pinia (useConfirmStore().ask(message): Promise<boolean>) resolvida por uma única instância de <ConfirmModal> montada no App.vue. Qualquer view que precisar confirmar uma ação usa essa store — nunca uma cópia local do modal.
Dois dropdowns brigando pelo mesmo clique. O Tabler já reexporta os componentes JS do Bootstrap (Modal, Dropdown etc.) e registra a delegação de clique deles no document. Em algum momento entrou uma importação direta do pacote bootstrap isolado em algum lugar do frontend, e o resultado foi bizarro: dropdown abrindo e fechando no mesmo clique, porque agora existiam dois listeners de data-api competindo. A regra ficou documentada no CLAUDE.md do projeto: nunca importar o pacote bootstrap avulso, sempre usar o bundle do @tabler/core.
Backup e restauração de um banco que está com conexão aberta. Um sistema local, sem servidor, sem "nuvem" pra recuperar um banco corrompido — o backup precisa ser confiável e simples o bastante pra rodar direto na tela de Configurações. A exportação usa VACUUM INTO na conexão viva do *gorm.DB, o que é seguro com o app rodando normalmente. A importação é mais delicada: valida o arquivo escolhido (cabeçalho SQLite + tabelas esperadas) antes de tocar em qualquer coisa, faz checkpoint e fecha a conexão atual, copia o banco atual pro lado com sufixo .bak-* com timestamp, sobrescreve o arquivo ativo, e então encerra o processo (wailsruntime.Quit). A razão de fechar o app em vez de trocar a conexão "a quente" é estrutural: cada repository no main.go foi construído com o ponteiro *gorm.DB original no boot, não existe um ponteiro vivo pra redirecionar — reiniciar o processo é o único jeito seguro de todo mundo enxergar o arquivo restaurado.
Autenticação sem servidor
Não existe conceito de sessão HTTP aqui — o próprio processo do Wails é o limite da sessão. É uma conta fixa, autenticação só por senha, guardada num flag em memória (auth.SessionGuard). No primeiro uso, o fluxo passa por NeedsInitialSetup → CreateInitialUser. Simples de propósito: não existe multiusuário no problema que esse sistema resolve, então não faz sentido simular um.
Resultado
Hoje o Hardox roda como app nativo compilado (wails build) no computador da oficina, com o banco de dados local e backups feitos direto pela tela de configurações. O fluxo mudou de "caderno + WhatsApp + estoque de memória" para: cadastra cliente, monta orçamento lançando peça e mão de obra em modal, fecha o orçamento — o que já dá baixa automática nas peças e bloqueia se faltar estoque —, imprime o PDF pro cliente, e o dashboard mostra o total do dia por status. Nenhuma peça é vendida "no papel" sem passar pelo controle, porque o controle é o próprio caminho mais rápido de fechar um orçamento — não um passo extra que dá pra pular.
O código está aberto no repositório do Hardox, incluindo o histórico completo de como o domínio foi evoluindo — de um CRUD simples até essa máquina de estados de orçamento com baixa de estoque transacional.
O que fica de lição
Sistema pequeno, escopo fechado, mas nenhum dos problemas reais apareceu no ambiente de desenvolvimento — todos vieram do uso de verdade: o clique que não faz nada porque o webview não implementa confirm, o dropdown que pisca porque duas bibliotecas competem pelo mesmo evento, o banco que precisa sobreviver a uma restauração sem virar arquivo corrompido. É fácil subestimar a distância entre "funciona no wails dev" e "funciona na mão de alguém que só quer fechar um orçamento e ir pro próximo carro."