Milhões de linhas de Haskell: a engenharia de produção da Mercury
(blog.haskell.org)- A Mercury oferece serviços bancários para mais de 300 mil empresas com uma base de código de cerca de 2 milhões de linhas de Haskell, excluindo comentários e afins, e em 2025 processou US$ 248 bilhões em volume transacionado e US$ 650 milhões em receita anualizada
- O valor do uso de Haskell na Mercury está menos na pureza em si e mais em colocar o conhecimento operacional em APIs e tipos, manter comportamentos perigosos atrás de fronteiras estreitas e fazer do caminho seguro o caminho mais fácil
- A confiabilidade não é tratada como a prevenção de toda falha, mas como a capacidade do sistema de absorver variabilidade, e o sistema de tipos exclui classes de erros e preserva o conhecimento institucional como uma forma de documentação que o compilador faz cumprir
- A Mercury usa o Temporal como framework de durable execution para retries, timeouts, cancelamentos e recuperação de falhas em fluxos de trabalho financeiros, e publicou em open source o SDK de Haskell
hs-temporal-sdk - O valor de Haskell em produção não está em colocar tudo nos tipos, mas em proteger com tipos os invariantes que podem levar a perda de dados, erros financeiros e problemas regulatórios, enquanto a complexidade é encapsulada e operada junto com testes, documentação e code review
A escala operacional de Haskell na Mercury e sua visão de confiabilidade
- A Mercury opera uma base de código Haskell de cerca de 2 milhões de linhas, excluindo comentários e afins
- A Mercury é uma fintech que oferece serviços bancários para mais de 300 mil empresas e, em 2025, processou US$ 248 bilhões em volume transacionado e US$ 650 milhões em receita anualizada
- A empresa tem cerca de 1.500 funcionários, e a organização de engenharia contrata principalmente desenvolvedores generalistas; a maioria nunca havia usado Haskell antes de entrar
- Esse sistema vem operando há anos sob crescimento acelerado, o cenário em que US$ 2 bilhões em novos depósitos entraram em 5 dias durante a crise do SVB, escrutínio regulatório e situações comuns e incomuns de sistemas financeiros em larga escala
Confiabilidade não é prevenir falhas, mas absorver variabilidade
- A abordagem tradicional de confiabilidade se concentra em enumerar falhas, adicionar verificações e testes e encontrar bugs, mas isso sozinho não basta
- A Mercury trata confiabilidade como a capacidade do sistema de absorver variabilidade
- O sistema deve ser capaz de degradar de forma elegante
- Os operadores devem conseguir entender e ajustar o sistema
- A arquitetura deve tornar fácil fazer a coisa certa e difícil fazer a coisa errada
- Em uma organização que cresce rápido, perguntas operacionais reais passam a ser se um novo engenheiro consegue ler e entender um módulo, se um serviço cai junto quando o banco de dados fica lento e se o compilador captura o uso incorreto de interfaces
- O sistema de tipos está mais próximo de um mecanismo de apoio operacional do que de uma simples prova de correção
- Exclui determinadas classes de erro
- Preserva o conhecimento institucional em uma forma que o compilador consegue ler, mesmo depois que o autor saiu
- Funciona como documentação imposta de forma mais consistente do que uma wiki
- A engenharia de estabilidade na Mercury não é uma polícia da qualidade que atrasa o desenvolvimento de produto, mas uma forma colaborativa de lidar com o impacto de uma funcionalidade quebrada desde o início do design
- Raio de explosão em caso de falha
- Quais operações precisam de idempotência e de que forma
- Como será o rollback
- Como lidar com trabalhos em andamento
- Avaliar antecipadamente quais sistemas absorvem falhas e quais as amplificam
Pureza não é uma propriedade da linguagem, mas uma fronteira de interface
- A pureza em Haskell não significa que internamente não haja nenhum efeito colateral, mas sim que a interface cria uma fronteira que impede o vazamento de efeitos colaterais
- Por trás de funções puras em bibliotecas como
bytestring,textevectorexistem implementações internas com alocação mutável, escrita em buffer e unsafe coercion - A mônada
STusa mutações in-place observáveis e efeitos colaterais dentro do cálculo, mas o tipo rank-2 derunSTimpede que referências mutáveis criadas internamente escapemrunST :: (forall s. ST s a) -> a - Internamente, comportamentos imperativos são possíveis, mas externamente só o resultado aparece, e o estado mutável não vaza para fora da fronteira
- Esse princípio se aplica a todo o sistema operacional
- A camada de banco de dados pode usar internamente pooling de conexões, retries e estado mutável
- O cache pode usar mapas mutáveis concorrentes
- O cliente HTTP pode ter circuit breakers, pools de conexão e bastante bookkeeping
- O ponto central é encapsular comportamentos perigosos em interfaces estreitas para dificultar o uso indevido
- Em sistemas reais, o objetivo não é evitar completamente mudanças, mas deixar claro onde elas estão e limitar quem, na base de código, precisa saber disso
Tornar a coisa certa a coisa fácil
- Em bases de código grandes, frequentemente surgem padrões em que a correção depende de uma ordem específica ou de etapas extras invisíveis
- É preciso dar flush no log de auditoria depois da transação
- É preciso verificar a feature flag antes de chamar o endpoint
- É preciso enfileirar a notificação dentro da transação do banco de dados
- Se esse conhecimento operacional existir só em wikis, documentos de onboarding, reviews de design antigos, threads no Slack ou na memória de alguns engenheiros sêniores, ele desaparece rápido
- Haskell permite codificar esses procedimentos em tipos para que não possam ser esquecidos
- A forma ruim é pedir que usem a função correta, mas deixar um caminho de desvio disponível
-- Please use this one, not the other one writeWithEvents :: Transaction -> [Event] -> IO () -- Don't use this directly (but we can't stop you) writeTransaction :: Transaction -> IO () publishEvents :: [Event] -> IO ()- Uma forma melhor é reorganizar os tipos para que o único caminho de execução da operação inclua a publicação de eventos
data Transact a -- opaque; cannot be run directly record :: Transaction -> Transact () emit :: Event -> Transact () -- The *only* way to execute a Transact: commit and publish atomically commit :: Transact a -> IO a - Aqui, o sistema de tipos não está tanto provando um teorema profundo sobre eventos, mas tornando o procedimento operacional correto o caminho mais fácil
- Quando um novo engenheiro pergunta como escrever uma transação, a assinatura de tipos e a API pública dão a resposta, e o conhecimento permanece mesmo quando engenheiros sêniores saem
Execução durável e Temporal
- Os fluxos de trabalho de sistemas financeiros não ficam contidos em uma única transação
- envio de pagamento
- espera pela aprovação do parceiro
- atualização do razão
- notificação ao cliente
- tratamento de cancelamentos e timeouts
- casos em que o parceiro teve sucesso, mas o worker morreu antes de registrar
- casos em que não há resposta por problemas de rede
- Esses fluxos exigem estado, retries, timeouts, idempotência e execução durável que continue além de crashes de processo e deploys
- No passado, a Mercury orquestrava esses processos com máquinas de estado baseadas em banco de dados, tarefas cron, workers em segundo plano e tratamento de retries e timeouts espalhado pelo código
- Funcionava, mas era frágil, difícil de entender e uma causa desproporcional de incidentes operacionais
- O Temporal é o framework de durable execution da Mercury, permitindo escrever workflows como código sequencial comum enquanto a plataforma registra cada etapa no histórico de eventos
- Se um worker falha no meio de um workflow, outro worker pode fazer replay do prefixo determinístico para reconstruir o estado e continuar do ponto onde parou
- Retries, timeouts, cancelamentos e tratamento de erros são fornecidos pela plataforma, em vez de serem reimplementados separadamente por cada equipe
- Um workflow do Temporal tem uma natureza parecida com a de uma função pura sobre o histórico de eventos
- o workflow reexecutado precisa produzir a mesma sequência de comandos do original
- essa exigência de determinismo se parece com a restrição de mesmo input, mesmo output de código puro
- efeitos colaterais são isolados em activities, que correspondem ao
IOdo workflow
- A Mercury criou e abriu como open source o SDK Haskell
hs-temporal-sdk, que encapsula o Core SDK oficial do Temporal com Rust FFI - O padrão de adoção do Temporal também foi abordado na palestra da conferência Temporal Replay, e a Mercury obteve melhorias operacionais ao substituir cadeias frágeis de cron e máquinas de estado por workflows duráveis
O domínio deve ser projetado na linguagem do negócio, não da camada de transporte
- Um erro comum em sistemas que cresceram é deixar conceitos do sistema chamador vazarem para o modelo de domínio
- Se um código escrito para handlers de requisições HTTP for depois reutilizado em tarefas cron, workers em segundo plano baseados em filas ou workflows do Temporal, exceções HTTP como
StatusCodeException 409 "Conflict"podem se propagar para contextos não HTTP - Uma tarefa cron não tem um chamador esperando uma resposta 409, e o código de status leva o significado de negócio para a camada errada
- A solução é modelar erros de domínio como tipos de domínio
- saldo insuficiente deve ser
InsufficientFunds - requisição duplicada deve ser
DuplicateRequest - timeout do parceiro deve ser
PartnerTimeout
- saldo insuficiente deve ser
- Em cada fronteira, usa-se uma camada fina de transformação
data PaymentError = InsufficientFunds | DuplicateRequest RequestId | PartnerTimeout Partner toHttpError :: PaymentError -> HttpResponse toHttpError InsufficientFunds = err402 "Insufficient funds" toHttpError (DuplicateRequest _) = err409 "Duplicate request" toHttpError (PartnerTimeout _) = err502 "Partner unavailable" toWorkerStrategy :: PaymentError -> WorkerAction toWorkerStrategy InsufficientFunds = Fail "Insufficient funds" toWorkerStrategy (DuplicateRequest _) = Skip toWorkerStrategy (PartnerTimeout _) = RetryWithBackoff - As preocupações da camada de transporte devem ficar nas bordas, e o modelo de domínio não deve carregar códigos de status HTTP ao ser chamado de handlers web, CLI, tarefas cron, workers em segundo plano ou engines de workflow
O custo da codificação em tipos e o ponto de equilíbrio
- Colocar invariantes nos tipos é poderoso, mas traz custos de carga cognitiva, rigidez e dificuldade quando os requisitos mudam
- Se a violação puder levar a perda de dados, erro financeiro, problema regulatório ou incidente de on-call, o custo da codificação em tipos se justifica
- Se a única razão for que o sistema funciona assim hoje, ou porque se quer experimentar técnicas de nível de tipo, há grande chance de tornar o codebase mais difícil de mudar
-
O lado de codificar demais
- estados ilegais se tornam impossíveis de representar, e o domínio é modelado fielmente em tipos
- mudanças em regras de negócio viram alterações de tipos que atravessam 50 módulos, alongando o refactor
- fica mais difícil para novos engenheiros entenderem as assinaturas de tipos
-
O lado de não codificar nada
- os tipos passam a se aproximar de
String,IO ()ou, no pior caso,Dynamic - o código fica fácil de mudar, mas sem contratos, e o significado depende da memória de quem o escreveu
- quando a pessoa autora sai, fica difícil entender por que o sistema não funciona
- os tipos passam a se aproximar de
-
Critérios úteis
- invariantes que evitam corrupção silenciosa tendem a valer a pena nos tipos
- transações confirmadas sem eventos
- pagamentos processados sem log de auditoria
- transições de estado aparentemente possíveis, mas semanticamente impossíveis
- invariantes que falham de forma evidente podem ser suficientemente tratados com verificações em runtime e boas mensagens de erro
- resposta 500
- falha de assertion
- incompatibilidade de tipos na fronteira JSON
- o impulso de modelar todo o domínio em tipos deve ser contido
- o domínio tem exceções, regras de compatibilidade legada, regras conflitantes e comportamentos especiais para clientes específicos
- tipos são uma ferramenta para a equipe, não só para o compilador
- eles devem formar uma camada de defesa junto com testes, documentação, code review, exemplos e playbooks
- Dentro da Mercury também existem bibliotecas que usam mecanismos complexos de nível de tipo, como GADT, type family e phantom types que rastreiam transições de estado
- essa complexidade é necessária em mecanismos onde, se algo der errado, o dinheiro pode ir para o lugar errado ou invariantes regulatórias podem ser quebradas
- o essencial é encapsular a complexidade
- o módulo que implementa a máquina de estados em nível de tipo deve ter poucos autores com entendimento profundo e testes suficientes
- para quem usa, a API deve parecer algumas funções com tipos comuns
- um product engineer deve conseguir chamar isso com segurança sem conhecer os mecanismos internos de prova em nível de tipo
- se, em code review, um PR que mexe em outros módulos estiver cheio de anotações de tipo copiadas para apaziguar o compilador, isso é sinal de que a abstração está vazando para além da fronteira
- invariantes que evitam corrupção silenciosa tendem a valer a pena nos tipos
Projetando para introspecção
- Se confiabilidade é a capacidade de adaptação, introspecção é uma das formas de obter essa capacidade
- Operadores não conseguem operar o que não conseguem ver, e equipes têm dificuldade para se adaptar a sistemas cujo interior é opaco
- Haskell não tem monkey patching, então é difícil trocar em tempo de execução o cliente HTTP interno de uma biblioteca ou substituir chamadas ao banco de dados por funções que emitam spans do OpenTelemetry
- Rust tem a mesma limitação, mas o ecossistema Rust convergiu para o padrão de middleware
tower, enquanto o ecossistema Haskell se divide entre várias abordagens - Se uma biblioteca expõe apenas um conjunto de funções concretas de topo, para instrumentá-la é preciso encapsulá-la em um novo módulo e esperar que as pessoas importem esse módulo em vez do original
-
Registros de funções
- A solução mais usada é expor registros de funções em vez de funções concretas
-- A concrete module gives you no leverage: sendRequest :: Request -> IO Response -- A record of functions gives you all of it: data HttpClient = HttpClient { sendRequest :: Request -> IO Response , getManager :: IO Manager } - Com isso, é possível envolver
sendRequestcom instrumentação de tempo e retornar um novoHttpClient - Dá para adicionar em tempo de execução preocupações transversais como fault injection para testes, troca por mocks, retries, tracing, rewrite de requisições e comportamento por tenant
- Como em
type Middleware = Application -> Applicationdo WAI, esse padrão que torna transformações de comportamento componíveis é muito útil operacionalmente
- A solução mais usada é expor registros de funções em vez de funções concretas
-
Interceptadores compostos com
Monoid- Tipos de middleware e interceptador em geral podem ter instâncias de
SemigroupeMonoid - O
Middlewaredo WAI é um endomorfismo, e endomorfismos formam um monoid sob composição eid - Registros de hooks de interceptadores podem ser compostos campo a campo, então preocupações como tracing, timeout e rewrite de fila de tarefas podem ser combinadas com
mconcatsem encanamento adicionalappTemporalInterceptors = mconcat [ retargetingInterceptor , otelInterceptor , sentryInterceptor , sqlApplicationNameInterceptor , loggingContextInterceptor , statementTimeoutInterceptor , teamNameInterceptor , clientExceptionInterceptor , workflowTypeNameInterceptor ] - Cada interceptador lida com apenas uma preocupação em um módulo independente, sobrescreve apenas os campos necessários a partir de
mempty, e a ordem fica explícita na lista
- Tipos de middleware e interceptador em geral podem ter instâncias de
-
Sistemas de efeitos
- Sistemas de efeitos como
effectful,polysemy,fused-effectseclefftambém oferecem outro caminho - Você define as operações disponíveis como tipos de efeito e pode trocar no ponto de chamada os interpreters de produção, teste e tracing
- É possível interceptar efeitos, registrar métricas ou injetar latência, e então reenviá-los ao handler real
- A desvantagem é introduzir mecanismos extras como listas de efeitos no nível de tipos, pilhas de handlers e erros de tipo difíceis
- Registros de funções são simples o suficiente para um novo engenheiro entender em uma tarde
- Sistemas de efeitos como
-
Um exemplo positivo de
persistent- O
SqlBackenddepersistenté um registro de funções com itens comoconnPrepare,connInsertSql,connBegin,connCommiteconnRollback - Ao adicionar instrumentação do OpenTelemetry, foi possível envolver os campos relevantes e anexar spans de tracing a todas as operações de banco de dados
- Sem fork e com quase nenhuma mudança no código-fonte, obteve-se visibilidade da camada de banco de dados
- O
-
Bibliotecas difíceis de operar
- A Mercury quase não usa bindings públicos de clientes de API web publicados no Hackage
- Quando um binding de terceiros faz chamadas HTTP com funções concretas, fica difícil fazer tracing, aplicar timeouts alinhados ao SLO, simular falhas de parceiros ou explicar um vazio de 400 ms no trace
- Por isso, a empresa escreve seus próprios clientes e os torna observáveis desde o início
-
O custo de um ecossistema pequeno
- Algumas bibliotecas Haskell não estão abandonadas, mas acabam como infraestrutura pública sem um responsável claro que faça melhorias rápidas
- Interfaces antigas são mantidas, e a adoção de novos designs sobre observabilidade, modelagem de fronteiras e operabilidade pode ser lenta
http-clientsuporta diretamente apenas HTTP/1.1; é utilizável o bastante, mas em certos momentos pode exigir contornos
Requisitos operacionais para autores de pacotes
- Autores de bibliotecas devem fornecer pontos de escape como registros de funções, tipos de efeito e callbacks para que usuários possam injetar comportamento sem modificar o código-fonte
- Só de adicionar
hs-opentelemetry-apicomo dependência e colocar spans em torno das operações centrais deIOjá ajuda quem opera a biblioteca em produção- O pacote de API é conservador com breaking changes e foi projetado para funcionar de forma inerte se a aplicação não inicializar o SDK do OpenTelemetry
- O overhead de desempenho é mínimo, e ele não gera exceções inesperadas nem logging a partir da aplicação do usuário
- O footprint de dependências ainda não é tão pequeno quanto se gostaria, e há trabalho em andamento para melhorar isso
- Não se deve escrever logs diretamente no código da biblioteca
- Em vez de importar um framework de logging e escrever direto em
stdoutoustderr, é melhor fornecer callbacks, parâmetros de logger ou um tipo de dado de mensagem de log que o chamador possa rotear - Para onde os logs vão é uma decisão que pertence ao ambiente operacional da aplicação
- A Mercury envia pipelines de logs estruturados para a stack de observabilidade; se a biblioteca escrever diretamente em
stderr, isso exigirá encanamento separado do fluxo de JSON lines
- Em vez de importar um framework de logging e escrever direto em
- Também vale considerar expor módulos
.Internal- A preocupação de que usuários passem a depender de APIs internas e dificultem refactors é válida
- Mas raramente se justifica a certeza de que a API pública já cobre todos os casos de uso
- Um módulo
.Internalcom aviso explícito de estabilidade pode ser melhor do que o usuário fazer fork do pacote e vendoring containers,texteunordered-containerssão bons exemplos dessa abordagem no ecossistema Haskell- Por outro lado, se usuários resolverem silenciosamente suas necessidades usando módulos internos, pode haver menos feedback sobre falhas na API pública
O que não colocamos nos tipos
- Mesmo Haskell em produção tem partes nada elegantes
unsafePerformIOé usado dentro de bibliotecas das quais dependemos no dia a diabytestringetextalocam buffers mutáveis internamente, escrevem neles e depois fazem freeze para produzir o resultado- O tipo não diz o que aconteceu durante a construção
- Os limites são mantidos por convenção, raciocínio cuidadoso e revisão de código
- Se a alternativa type-safe tornar o custo de desempenho ou complexidade excessivo, você mesmo pode acabar escrevendo esse tipo de compromisso
- É preciso documentar os invariantes que o sistema de tipos não verifica
- É preciso manter o desconforto e reavaliar periodicamente se uma alternativa type-safe se tornou prática
- Haskell em produção não é ausência de compromissos, e sim isolamento disciplinado dos compromissos
- Muitas bibliotecas Haskell no Hackage têm poucos testes ou nenhum
- A ideia de que “se compila, funciona” às vezes pode ser verdadeira para código puro pequeno e tipos fortes
- Quase nunca é verdade para código pesado em IO, integrações com sistemas externos e código em que os bugs estão no significado, não na estrutura
- Os tipos podem dizer que algo retorna
Either ParseError Transaction, mas não conseguem dizer o seguinte- se o campo
amounté analisado em centavos ou em dólares - se a API do parceiro interpreta de forma diferente um campo omitido e um campo nulo
- se a lógica de retry causa cobrança em duplicidade em uma janela específica de tempo de ano bissexto
- se o campo
- Em produção, construímos sistemas sobre essas bibliotecas e herdamos suposições não verificadas, então precisamos complementar isso com testes de integração na nossa própria camada
- Também se acumulam compromissos como orphan instance, funções parciais acreditadas como totais no contexto,
errorprometido como inalcançável, wrappers de FFI estranhos e hierarquias de exceção feitas manualmente - O objetivo não é pureza moral, e sim garantir, por meio de revisão de código, documentação, exemplos e testes, que seja possível saber onde está cada compromisso, por que ele foi feito e o que quebra se ele for removido
Vale a pena usar Haskell em produção
- Haskell não é a escolha mais rápida no primeiro dia
- O ecossistema atual não oferece de imediato um ambiente de desenvolvimento com hot reloading e tudo incluído como Next.js ou Rails
- A biblioteca necessária pode não existir, ou existir mas ser mantida por uma única pessoa no tempo livre
- Às vezes as mensagens de erro são extremamente difíceis de entender
- O problema de contratação é exagerado
- O CTO da Mercury, Max Tagher, já disse publicamente que engenheiro de backend Haskell é o cargo mais fácil de contratar em toda a Mercury
- A demanda por empregos em Haskell é maior que a oferta, invertendo a dinâmica normal de contratação
- A Mercury contrata tanto pessoas com muita experiência em Haskell quanto pessoas sem nenhuma, e estas últimas passam a ser produtivas com um programa de treinamento de 6 a 8 semanas
- Se você precisasse de 100 especialistas em Haskell amanhã, o problema do tamanho do pool seria real; mas, se estiver disposto a contratar bons desenvolvedores generalistas e ensiná-los, ele é menos real
- O maior risco de contratação não é o tamanho do pool, e sim a inclinação
- Haskell atrai idealistas que se importam com precisão e abstração, gostam de ler artigos acadêmicos e questionam suposições existentes
- Se essa força não for controlada, pode virar um passivo em produção
- Tentar reescrever a camada de banco de dados com uma nova codificação de álgebra relacional em nível de tipos, recusar merge porque um script descartável usou
Stringem vez deText, ou puxar todo design para um total rewrite no estilo do artigo mais recente desacelera o time
- Haskell em produção exige uma cultura de pragmatismo
- O sistema de tipos é uma ferramenta elétrica, não uma religião
- Tratar problemas que já têm boas soluções como oportunidades de inventar um novo mecanismo não combina com produção
- O retorno aparece com o tempo
- Uma refatoração que levaria semanas em uma base de código com tipagem dinâmica pode terminar em horas depois de uma mudança de tipo, porque o compilador aponta todos os call sites
- Um novo engenheiro pode ler as type signatures e entender o contrato de um módulo
- Estados impossíveis podem de fato se tornar impossíveis de representar, evitando incidentes em produção
- A Mercury vê o retorno sobre o investimento aparecer em meses, não em anos
- Especialmente em serviços financeiros, o custo de bugs de integridade de dados não é medido em reclamações de usuários, e sim em questionamentos regulatórios e no dinheiro dos outros
- O sistema de tipos não elimina o risco, mas fornece ferramentas que tornam mais difícil introduzi-lo por engano em uma base de código que cresce rápido
- O valor de Haskell em produção não está em ser bala de prata nem movimento moral, e sim em um conjunto poderoso de ferramentas que permite até a equipes com diferentes níveis de domínio de Haskell manter dispositivos perigosos dentro de limites, preservar conhecimento operacional e fazer do caminho seguro o caminho fácil
1 comentários
Comentários do Hacker News
É verdade que Haskell está entre as linguagens mais fortes para impor esse tipo de coisa via tipos, mas o mesmo padrão funciona muito bem também em Rust e TypeScript
Também gosto da forma de evitar bugs óbvios de autorização que se repetem em apps web com um fluxo como User -> LoggedInUser -> AccessControlledLoggedInUser
Acho que esse padrão é usado muito menos do que deveria na indústria
Se for preciso distinguir, por segurança, strings antes/depois de escaping, até em linguagens dinâmicas dá para encapsular com uma classe Escaped e ter funções como
escape(str)->Escaped,dangerouslyAssumeEscaped(str)->EscapedHá um custo de desempenho, então é preciso fazer concessões, mas é possível
Outra abordagem é Application Hungarian, embora aí se dependa mais da disciplina do programador do que do compilador: https://www.joelonsoftware.com/2005/05/11/making-wrong-code-...
Por exemplo, em C# isso também é plenamente possível, mas na prática acaba gerando mais ruído visual do que a própria definição dos tipos
Só que, para evitar o efeito “mônadas são assustadoras, então vamos escrever um tutorial”, costumam não dizer isso explicitamente e até dar outros nomes às coisas
A influência maior talvez venha mais de type classes do que de mônadas
Como não há tipagem nominal, para criar algo como um newtype que encapsule tipos primitivos é preciso lembrar de uns truques bem hacky
Pela minha experiência, OCaml foi mais poderoso que Rust para impor esse tipo de segurança de tipos
Com GADTs ele tem mais poder de expressão, além de mais conveniência com variantes polimórficas e tipos de objeto/row types de registros, sem falar no sistema de módulos e funtores
E, em domínios onde garbage collection basta, também se evitam as limitações de abstração e as dificuldades causadas pelo borrow checker do Rust
Gostei muito de trabalhar com Haskell por alguns anos
Não foi algo que eu estivesse buscando de propósito, mas a oportunidade apareceu por acaso, e foi interessante e intelectualmente estimulante
Só que, infelizmente, mesmo depois de 3 anos usando apenas Haskell, minha produtividade em Rust ainda era facilmente o dobro da que eu tinha em Haskell
Haskell tem mais armadilhas que você precisa conhecer de antemão para evitar, e dependendo de quem escreve, o código pode ser tão difícil de absorver que vira quase uma linguagem só de leitura
O toolchain frequentemente vem acoplado com Nix, que por si só já é um monstro complexo, as extensões da linguagem parecem espalhadas por todo lado
Os arquivos Cabal também não são grande coisa, e leva tempo para se acostumar com os erros do compilador
No meu último produto, comecei a migrar o backend de Typescript para Rust porque eu estava cansado dos crashes
Hoje considero isso um dos maiores erros técnicos que já cometi, porque a produtividade despencou
Um exemplo de tempo perdido só em Rust: escrever uma função de ordem superior do tipo “abre conexão com banco de dados, faz algo e fecha” é trivial em Haskell, TypeScript, JavaScript, C++ e PHP, mas em Rust foi tão impraticável que, mesmo perguntando a amigos especialistas em Rust, acabei desistindo
Também houve várias vezes em que tentei refatorar, passei o dia inteiro corrigindo erros de tipo, só para chegar a um erro no arquivo de topo e perceber que, por uma parte fundamental do design, a refatoração inteira era inviável e precisava ser revertida
Além disso, Rust é a única linguagem moderna que consigo lembrar em que usar valores por interface em vez de tipos concretos fica, dependendo do caso, em algum lugar entre técnica avançada e simplesmente impossível
Por isso cheguei à conclusão de que código de aplicação, isto é, código que não seja de sistema nem de biblioteca, basicamente não deveria ser escrito em Rust
E também queria entender o que você quis dizer com “só de leitura”
Ao contrário da percepção geral, acho que o fato de a Mercury ter escolhido Haskell, e de seus líderes iniciais terem ampla experiência com Haskell, pode muito bem ter contribuído de forma importante para o sucesso
Do ponto de vista de cliente da Mercury, essa empresa é uma das peças centrais da minha caixa de ferramentas, e não consigo afastar a sensação de que a escolha por Haskell tornou melhores o progresso, o desenvolvimento e a jornada como um todo
Claro que dá para fazer esse tipo de afirmação sobre a maioria das linguagens, e isso não significa que linguagens funcionais como Haskell sejam uma fórmula de sucesso
Mas tomar esse tipo de decisão deliberada antes da era do “vibe coding” e dos LLMs parece especialmente visionário, e vejo isso como resultado combinado com a cultura de engenharia detalhada no texto
Eu também gosto de boa cultura técnica, mas já vi empresas com ótima cultura técnica morrerem por causa de um foco de negócio ruim
Indo além, pode até ser que uma cultura de fintech no estilo startup tenha produzido uma boa cultura técnica
Como eles não começaram como banco, ao contrário do SVB por exemplo, não precisavam ser tão conservadores nem integrar com stacks tecnológicos antigos e horríveis
Fico feliz que tenham tido sucesso com Haskell, mas, como Jane Street com OCaml, acho que, ao contrário do que a empresa talvez queira fazer acreditar, a escolha da linguagem é quase acidental do ponto de vista do negócio
Só fico curioso sobre o que usam no frontend. Imagino que esse Haskell seja todo backend
Porque assim dava para incutir cultura e estilo desde o começo nas pessoas que chegavam
Antes do vibe coding, a maioria dessas pessoas provavelmente não entraria simplesmente saindo hackeando tudo sem instrução
Quando você vem de outros serviços, isso é realmente muito satisfatório
Um amigo muito próximo trabalha nessa empresa, e mesmo de fora a cultura de engenharia parece boa
Acho que Haskell é a ferramenta certa para esse trabalho e que eles estão aproveitando bem seus pontos fortes, mas também fico com a impressão de que boa parte do sucesso pode vir simplesmente do fato de a empresa ser bem administrada no geral
Parece que esse autor conseguiria tocar uma organização de engenharia bem-sucedida independentemente da linguagem usada
Estou lendo Real-World OCaml agora e, embora já soubesse de algumas coisas, estou aprendendo mais programação funcional
Parece possível construir componentes de software surpreendentemente robustos com programação funcional
Mas também fico em dúvida
Hoje o backend do produto roda com NiceGUI e cumpre bem o seu papel
O código é razoável, em MVVM, e a coisa mais importante é conectar no websocket por cliente para consumir dados e mostrar análises
Não deve haver muitos clientes, e o site provavelmente terá de algumas dezenas até, no máximo, algumas centenas de visitantes
Também quero REPL ou hot reload, e sei que, conforme as funcionalidades crescerem, programação funcional pode combinar bem com transformações de pipeline de dados em coisas como painel de gerenciamento de usuários, mais análises etc.
Só que Haskell e OCaml são linguagens estáticas
Se eu quiser algo dinâmico que também cresça e escale depois, Clojure ou Elixir parecem boas escolhas
Ao mesmo tempo, tenho medo de que, se um dia for preciso refatorar, tudo quebre
Hoje uso Python com Mypy, e o frontend é gerado pelo backend via NiceGUI
cabal replSinceramente, acho que muitos usuários de Haskell não aproveitam isso o suficiente
Já trabalhei em um sistema parecido usando uma linguagem relativamente de nicho, Scheme e depois Racket, e mesmo com o crescimento ele continuou sendo mantido por um time pequeno por muito tempo, mantendo boa velocidade
Não produzíamos muitos bugs e normalmente conseguíamos adicionar funcionalidades muito rápido
Por exemplo, fomos os primeiros a conseguir uma certa certificação para hospedar dados sensíveis na AWS
Às vezes, adicionar recursos ficava mais lento porque, em vez de usar componentes prontos das plataformas populares, precisávamos construir coisas do zero
Mas, uma vez feito, funcionava bem, voltávamos ao ritmo anterior e não ficávamos lentos por causa da gordura e da complexidade de dezenas de frameworks prontos
Como tínhamos controle direto sobre uma plataforma administrável, também conseguíamos migrar rapidamente para a AWS quando surgiu necessidade
Desde o início, o sistema também tinha um certo segredo arquitetural para lidar com dados complexos e interações web, e isso permitia desenvolver muitas funcionalidades rapidamente, além de continuar empurrando o sistema em direções inteligentes depois
A diferença em relação à fintech em Haskell é que o tamanho do time era muito pequeno
Em qualquer momento havia só 2 ou 3 engenheiros de software, além de uma pessoa responsável por toda a operação
Então não existia a dificuldade de coordenar centenas de pessoas mantendo um sistema consistente
Normalmente uma pessoa cuidava das mudanças de código mais técnicas e arquiteturais, e outra adicionava rapidamente funcionalidades cheias de lógica de negócio para processos complexos
Se usadas com cuidado, as ferramentas de IA do tipo LLM atuais ou de futuro próximo talvez permitam capturar parte da eficiência de times muito pequenos e extremamente eficazes também no desenvolvimento de software
O modelo que me vem à cabeça não é o de produzir um gigantismo inchado só para eliminar story points e empurrar a sustentabilidade para depois, mas sim o de poucos pensadores muito afiados mantendo o sistema em um caminho ao mesmo tempo poderoso e administrável
É uma faca de dois gumes
2 milhões de linhas é uma conquista impressionante, mas também um peso de manutenção considerável
As vantagens de Haskell são teoricamente claras, mas as desvantagens são menos intuitivas
A tentação está em modelar tudo nos tipos
A própria base de código deixa de ser uma aplicação e passa a ser uma especificação de negócio
Cada mudança de política vira uma grande refatoração e, graças à segurança do Haskell, isso pode acabar surpreendentemente trabalhoso
No fim, não dá para ter tudo, e em algum momento você acaba preso aos tipos
Haskell é realmente impressionante e poderoso, especialmente nessa escala, mas traz problemas próprios
A tentação de modelar lógica de negócio nos tipos pode criar estruturas rígidas, e a segurança fornecida por essa estrutura pode fazer você deixar de enxergar outros tipos de risco
Não dá para ter tudo, mas dá para ter muita coisa
Fiz estágio na Jane Street alguns anos atrás; não era Haskell, era OCaml, mas parecia que eles encontravam esse equilíbrio muito bem
Mesmo em um domínio com alta complexidade intrínseca, onde confiabilidade e precisão estão diretamente ligadas à sobrevivência do negócio, eles se moviam com velocidade surpreendente
Olhando para trás, o centro da força da Jane Street estava em contratar programadores de OCaml experientes e com ótimo senso, como Stephen Weeks, e colocá-los para construir as bibliotecas centrais desde o começo e conduzir toda a base de código
Infelizmente, a Mercury parece não ter feito essa parte tão bem assim
Sinceramente, o maior problema de um sistema de tipos Turing-completo é que, em teoria, dá para implementar uma aplicação inteira que, ao compilar, vira pó
Um caso parecido de sucesso com Haskell na Bellroy será tema de um encontro Melbourne Compose em breve: https://luma.com/uhdgct1v
O problema que eu tenho com programação funcional é debugging
Mais exatamente, eu vejo isso como um ponto forte da programação imperativa, especialmente no estilo procedural
Em estilo funcional/declarativo, em geral você descreve não como algo é construído, mas que estado aquilo deveria ter, e a linguagem monta tudo e entrega o resultado final
Se você fez tudo certo, ótimo, talvez até melhor; mas, quando isso não acontece e o resultado esperado não aparece, a questão é como encontrar o bug
Em linguagens como C isso é relativamente simples
Você segue linha por linha, observa o estado de execução entre cada etapa, na prática a RAM, e se algo divergir do esperado então há algo errado naquela linha, então você entra ali e segue assim
Quanto mais a programação funcional tenta esconder o estado, mais difícil isso fica
Também é interessante que a seção mais longa do texto trate justamente desse problema, o tal “design for introspection”
O autor precisou fazer um esforço deliberado para tornar o código depurável, e isso traz uma boa visão prática de um uso de Haskell que muitas vezes é ignorado
Até código trivial também
As outras linguagens mainstream nem chegam perto disso
Em situações em que não dá para escrever assim, como concorrência com memória compartilhada, eu uso transações
E nisso também as outras linguagens mainstream não chegam perto
Nem estou contando vantagens fáceis como ausência de null, ausência de casts implícitos de inteiro etc.
É totalmente verdade que depurar código Haskell é mais difícil do que em outras linguagens
Mas isso é inevitável quando você elimina os 90% inferiores das armadilhas que atrapalham o caminho
Claro, isso não é exclusivo do funcional; mesmo em linguagens majoritariamente imperativas como Python e JavaScript, muita gente usa o shell do Python, o console do navegador, shells de Node/Deno/Bun, notebooks etc. como primeira camada de debugging
Há concessões interessantes nesse modelo centrado em REPL
Em linguagens como C, costuma-se partir do debugging do programa inteiro e de breakpoints, tentando acertar o ponto exato onde pode estar o problema
Num mundo centrado em REPL, tenta-se tornar mais possível testar diretamente no REPL os componentes do programa
Por isso, as fronteiras de módulo/API/tipo acabam se parecendo com as fronteiras de depurabilidade
Às vezes há uma pressão maior para tornar essas fronteiras corretas e fáceis de usar do que em linguagens imperativas como C/C++
Em compensação, comparado ao debugging centrado no programa inteiro, pode ficar mais difícil isolar problemas complexos de integração entre unidades em cenários estranhos do mundo real
Mas a abordagem REPL-first muitas vezes incentiva a reduzir ao mínimo a área de superfície de integração, então em linguagens funcionais esses efeitos de integração podem aparecer menos do que em linguagens imperativas
Não acho correta a ideia de que linguagens funcionais escondem estado
Elas também rodam em hardware imperativo e lidam com estado real do hardware
Em algum ponto existe uma tradução entre esses dois mundos, e talvez ela nem seja tão diferente quanto parece
Quando necessário, ainda dá para voltar a breakpoints e depuradores imperativos
Por isso eu chamo de debugging “orientado a REPL”
Com o REPL você consegue afunilar o problema até a unidade com defeito, isto é, o módulo/API/função exato e as entradas que produzem uma saída surpreendente
Se olhando o código-fonte o bug ainda não ficar visível, dá para passar para o debugger imperativo e ter praticamente a mesma experiência de executar linha por linha, além de ganhar contexto extra
Nesse ponto, você provavelmente já afunilou tanto com o REPL que a própria unidade é pequena e estreita, então talvez nem precise mais escolher bons breakpoints
Acho que a mensagem captada da seção “design for introspection” foi equivocada
Aquela seção não era sobre depurabilidade, mas sobre observabilidade
Era sobre conectar corretamente sistemas de logging/telemetria, mockar fakes durante testes e adicionar retry/circuit breakers no nível do sistema inteiro, em vez de deixar isso a cargo de bibliotecas isoladas
Mesmo no mundo imperativo, isso não é um problema de debugging, e sim de decomposição via injeção de dependência, instalação de middleware e uso de interfaces abstratas em vez de classes concretas nas fronteiras de API pública
Esse tipo de sugestão de design é refatoração, e afeta menos a depurabilidade do que o quão fácil é instalar middleware de observabilidade na API pública de outra pessoa
Acho difícil imaginar o que exatamente 2 milhões de linhas de Haskell estariam fazendo
É muito código, e Haskell me passa a impressão de ser uma linguagem “densa”, capaz de fazer muita coisa com pouco código
Fico pensando se isso vem de haver muitas bibliotecas para coisas como serialização/desserialização JSON, frameworks de API REST, logging etc.
Se bindings de terceiros fazem chamadas HTTP com funções concretas, não há como adicionar tracing, não há como injetar timeouts alinhados aos SLOs, não há como simular falhas de parceiros nos testes, nem como explicar um buraco de 400ms no trace sem ficar só teorizando
Então eles escrevem isso por conta própria
Dá mais trabalho no começo, mas os clientes que constroem já nascem projetados para observabilidade
Quer dizer que ideias relativamente muito abstratas podem ser expressas com poucos caracteres
Algumas pessoas também chamam isso de “alto nível”
Ainda assim, acho que 2 milhões de linhas não é tanto código quanto parece à primeira vista
Especialmente para uma empresa em um setor regulado como o financeiro e com código acumulado ao longo de anos
O número de linhas pode até ser um pouco menor, mas a contagem de palavras costuma ser parecida com a de linguagens orientadas a objetos mais imperativas
Nesse meio, algo como
St M -> C Taté pode parecer aceitável, mas em software real é muito mais útil escreverTransactionState Debit -> Verified TransactionOutra parte vem de um fator cultural que remonta ao LISP
Existe uma tendência de algumas pessoas tentarem economizar linhas com truques difíceis de entender ou macros, sendo excessivamente espertas
Numa empresa financeira como a Mercury, imagino que clareza e legibilidade sejam mais incentivadas do que esse tipo de prática
Por exemplo, um linter poderia forçar a quebrar código monádico em expressões
dodetalhadas com várias linhas, em vez de escrever tudo em uma linha com>>e>>=