Book cover

Página Principal | Modo Dark

Fundamentos de Manutenção de Software

Marco Tulio Valente

8 Sistemas Legados

Programs, like people, get old. We can’t prevent aging, but we can understand its causes, take steps to limit its effects, temporarily reverse some of the damage it has caused, and prepare for the day when the software is no longer viable. ― David L. Parnas (Software Aging, 1994)

O capítulo começa com uma definição de sistemas legados, discutindo as principais características de tais sistemas (Seção 8.1). Basicamente, o objetivo é destacar que eles, de fato, são sistemas antigos e que usam tecnologias também antigas, mas que ao mesmo tempo ainda apoiam processos críticos de diversas organizações. Em seguida, apresentamos um conjunto de técnicas para ajudar na manutenção de sistemas legados (Seção 8.2). Especificamente, vamos tratar de documentação, limpeza de código, testes automatizados, sprouts, wrappers, branch por abstração, dark launch e execução paralela. Em seguida, vamos apresentar a melhor estratégia para descontinuação de sistemas legados, a qual deve ocorrer de forma gradativa, seguido um padrão que ficou conhecido pelo nome de Strangler Fig (Seção 8.3). Para concluir, vamos discutir algumas decisões e preocupações relacionadas à extração de serviços de um sistema legado (Seção 8.4), incluindo reescrita ou reúso de código, delimitação do serviço a ser extraído, preparação do legado para a extração, modelos de comunicação distribuída e migração de dados.

8.1 Introdução

Uma das características de produtos de software é que eles costumam durar mais tempo do que produtos físicos. Por exemplo, em certos domínios, como no setor financeiro ou no governo, é comum ter sistemas que estão em funcionamento por décadas. Esses sistemas são chamados de sistemas legados. Além de estarem em funcionamento por muito tempo, eles possuem também as seguintes características.

1. Tecnologias antigas: Sistemas legados são implementados em linguagens de programação antigas, como COBOL, Fortran, PL/I, Natural, Delphi, Visual Basic, Clipper, etc. Além disso, eles podem usar bancos de dados também antigos como Adabas, DB2 ou VSAM. E também costumam usar frameworks desatualizados, como JBoss ou AngularJS, apenas para dar alguns exemplos.

Eles, muitas vezes, são executados em grandes computadores que surgiram na década de 1960, desenvolvidos por empresas como a IBM. Esses computadores, chamados de mainframes, possuem hardware e sistemas operacionais proprietários. Apesar de antigo, o hardware de mainframes foi projetado para lidar com grandes volumes de transações que demandam acesso eficiente a bancos de dados. Além disso, mainframes possuem uma arquitetura que facilita escalabilidade vertical, isto é, por meio do aumento da capacidade de seus processadores, sem que seja necessário comprar novas máquinas.

2. Dívida técnica: Sistemas legados não possuem uma arquitetura bem definida. Os módulos possuem baixa coesão, alto acoplamento e as suas interfaces não são claras. Existe muita comunicação por meio de variáveis globais ou arquivos. É comum ter um percentual elevado de duplicação de código, com várias funções com o mesmo propósito, porém implementadas em arquivos diferentes. Não se costuma seguir um guia de estilo, não existe uma linguagem ubíqua (tal como estudamos no Capítulo 2) e também há poucos comentários (ao contrário do que recomendamos no Capítulo 3). Também não existem testes automatizados, sejam eles de unidade, de integração ou fim a fim (end-to-end). Algumas vezes, também não existe um sistema de controle de versões.

Logo, nesses sistemas temos uma combinação de tecnologias obsoletas e uma enorme quantidade de dívida técnica. Consequentemente, a manutenção de sistemas legados é muito difícil, sujeita a riscos, trabalhosa e lenta. Manutenção corretiva sempre leva mais tempo, pois ninguém entende completamente a implementação e as regras de negócio que estão implementadas no sistema. Além disso, existe o risco constante de regressões — corrigir um bug no arquivo X, mas causar um outro bug no arquivo Y —, o que também desestimula refatorações. Manutenções evolutivas, isto é, implementações de novas funcionalidades, sofrem dos mesmos riscos e desafios.

3. Complexidade: Sistemas legados são complexos e grandes, possuindo, por exemplo, milhões de linhas de código. Isso também explica por que eles continuam sendo usados, pois uma migração para uma tecnologia mais moderna é complexa e arriscada, principalmente para empresas estabelecidas e que possuem uma responsabilidade grande.

4. Dificuldades de contratação: Como sistemas legados usam tecnologias antigas e possuem muita dívida técnica, é muito mais difícil atrair novos desenvolvedores para trabalhar e dar manutenção neles.

5. Relevância para o negócio: Por fim, mas não menos importante, sistemas legados estão longe de serem irrelevantes, pois muitos automatizam atividades que constituem o coração de organizações, como sistemas de conta corrente em bancos, sistemas de reserva de passagens em companhias aéreas ou sistemas do governo responsáveis pela arrecadação de impostos ou pelo pagamento de aposentadorias. Ou seja, apesar das dificuldades e problemas tecnológicos, esses sistemas continuam gerando valor e sendo fundamentais para as organizações.

Mundo Real: Em 2017, a agência de notícias Reuters publicou um infográfico, intitulado COBOL blues, com estatísticas impressionantes sobre o uso de COBOL. Segundo a agência, 43% dos sistemas bancários nos Estados Unidos são implementados em COBOL e 80% das transações bancárias presenciais são realizadas usando esses sistemas. No caso de transações em caixas eletrônicos, o percentual sobe para 95%. Estima-se que existam 220 bilhões de linhas de código implementadas em COBOL (provavelmente, no mundo inteiro). Apesar desses dados não serem específicos do Brasil, sabemos que no setor bancário brasileiro também existem grandes sistemas implementados em COBOL, assim como no governo, seguradoras e empresas de telecomunicações.

Para dar um segundo exemplo do mundo real, agora sobre a dificuldade de encontrar programadores em COBOL, ficou famosa uma entrevista do governador do estado norte-americano de New Jersey, no início da pandemia de Covid, em 2020. Nessa entrevista, o governador mencionou que além de respiradores artificiais, máscaras e profissionais de enfermagem, o estado estava precisando urgentemente de programadores COBOL. O motivo é que o estado não estava conseguindo adaptar os sistemas, implementados em COBOL, para processar o pagamento de auxílios financeiros às pessoas mais afetadas pela epidemia.

8.1.1 Lições Aprendidas

Sistemas legados são um exemplo vivo da importância de boas práticas de Engenharia de Software. Ou seja, eles atestam que compensa seguir boas práticas de documentação, modelagem, design, arquitetura, testes, refatoração, DevOps, dentre outras. Portanto, o custo de não adotá-las será alto para as organizações. Após alguns anos, provavelmente, elas terão que lidar com sistemas com manutenção muito difícil e que serão um fator limitante ao seu crescimento e melhoria.

Evidentemente, os legados de hoje começaram a ser desenvolvidos há décadas, quando o amadurecimento das boas práticas e técnicas de Engenharia de Software ainda estava em andamento. Ou seja, nas décadas de 1970 e 1980, era mais difícil estimar as consequências de não seguir tais práticas. Porém, atualmente, o cenário é diferente e já temos evidências robustas de que dívida técnica e problemas de manutenção são uma realidade. Eles podem demorar anos para aparecer, mas isso não significa que eles não irão se acumular e comprometer a manutenção e evolução de sistemas centrais para o negócio de diversas organizações.

8.2 Técnicas para Manutenção

Como afirmado pelo Prof. David Parnas na frase que abre o capítulo não podemos evitar o envelhecimento de sistemas de software, mas podemos compreender suas causas, tomar medidas para limitar seus efeitos e reverter temporariamente parte dos danos que ele causou. Então, nesta seção, vamos descrever técnicas para tornar mais efetiva a manutenção de sistemas legados. Dentre elas, vamos tratar de documentação, limpeza de código, testes automatizados, sprouts, wrappers, branch por abstração, dark launch e execução paralela.

8.2.1 Documentação

Sistemas legados são um grande repositório de conhecimento e de regras de negócio que foi construído ao longo dos anos. Porém, a própria organização responsável pelo legado não tem pleno domínio desse conhecimento, pois muitos dos colaboradores e desenvolvedores responsáveis pela sua especificação e implementação não estão mais na organização. Portanto, para melhorar a manutenibilidade de sistemas legados, é importante tornar esse conhecimento visível e acessível a todos, o que pode ser feito por meio de documentação.

Além disso, investimento em documentação independe de tecnologia, isto é, pode-se documentar sistemas implementados em qualquer linguagem de programação. Por exemplo, COBOL, desde a primeira versão da linguagem, sempre ofereceu suporte a comentários de código. Comentários são importantes porque eles ajudam a documentar regras de negócio que constituem exceções a um caso geral e que são conhecidas como corner cases ou edge cases. Elas, normalmente, são as regras mais difíceis de entender em um sistema legado. Como exemplo, podemos citar:

Se não estiverem devidamente comentadas, um desenvolvedor com pouca experiência no sistema vai ficar inseguro se tiver que entender ou modificar código que implementa as regras de negócio acima. Porém, se elas estiverem comentadas, ele certamente vai ficar mais confiante para realizar sua tarefa de manutenção.

No Capítulo 3, citamos alguns estudos com sistemas de código aberto que mostram que eles possuem uma densidade de comentários próxima de 20%, isto é, de cada 100 linhas de código, 20 linhas são comentários. Em sistemas legados, essa densidade pode ser inclusive maior, dada a dificuldade de se manter esses sistemas.

Mencionamos primeiro comentários porque é uma forma de documentação simples e barata. Porém, pode-se investir também em outras formas de documentação, como casos de uso, diagramas de sequência, fluxogramas e diagramas de atividades.

8.2.2 Limpeza de Código

Muitas vezes, sistemas legados têm muito código que não é mais necessário. Logo, esse código deve ser deletado, pois ele atrasa atividades de manutenção ao ocupar espaço mental dos desenvolvedores, que perdem tempo ao achar que o código tem relação com uma mudança que eles planejam implementar. Fazendo uma analogia, assim como um guarda-roupas cheio de peças que não usamos há anos, código também tem muitas linhas que só ocupam espaço e prejudicam o seu entendimento. Então, a seguir, descrevemos alguns tipos de código que podem ser removidos(e que ocorrem com mais intensidade em sistemas legados).

Código Morto: Código morto é aquele que não é mais usado nem chamado. Por exemplo, uma função que está implementada, mas nunca é chamada.

Código Comentado: Muitas vezes temos também muitos comentários que apenas comentam código, o que é considerado um anti-padrão, como estudamos no Capítulo 3. Logo, esses comentários também devem ser deletados, para facilitar a navegação pelo código, tornando-o mais limpo e enxuto.

Funcionalidades Obsoletas: Funcionalidades obsoletas são aquelas que não são mais usadas, logo podem ser removidas. Como exemplo, podemos citar relatórios que não são usados há anos ou regras de negócio que não se aplicam mais.

Suponha a seguinte regra de negócio que mencionamos antes: alunos de um certo curso que ingressaram antes de um determinado ano possuem um critério de totalização de horas-aula específico. No entanto, não existem mais alunos na universidade que possuem suas horas de aula totalizadas por meio desse critério. Eles se formaram ou desistiram do curso. Logo, o código que implementa essa regra se tornou obsoleto e pode ser removido.

Código Duplicado: Esse é outro problema recorrente de sistemas legados, pois, sob pressão, desenvolvedores podem reimplementar uma função que já existe no código. Isso dificulta a manutenção, pois mudanças futuras terão que ocorrer em todas as instâncias do código duplicado.

Para evitar riscos, a remoção de duplicação deve ser feita em etapas. Suponha que temos duas funções que fazem a mesma coisa: formatarDataBrasileira e formatarDataBR. E queremos remover a segunda delas. Então, podemos proceder como descrito a seguir.

Primeiro, como mostrado abaixo, comentamos o corpo de formatarDataBR e inserimos nele uma chamada à função que vai permanecer, isto é, formatarDataBrasileira. Logo, por segurança, as duas funções ainda permanecem no código, mas a segunda versão apenas direciona a execução para a primeira.

public String formatarDataBR(LocalDate data) {
  // implementação antiga comentada
  return formatarDataBrasileira(data);
}

Então, testamos o sistema por alguns dias. Se tudo estiver funcionando, podemos remover formatarDataBR e atualizar todas as suas chamadas para que passem a chamar formatarDataBrasileira.

Essas mudanças de código em baby steps são sempre recomendadas, principalmente em sistemas de manutenção mais difícil, como sistemas legados.

Mundo Real: Mesmo grandes empresas de tecnologia possuem montantes consideráveis de código morto em seus sistemas. Então, para evitar o acúmulo desse tipo de código, a Meta desenvolveu uma ferramenta interna para remoção de código morto, chamada SCARF (Systematic Code and Asset Removal Framework, link). Segundo os autores da ferramenta, em um período de cinco anos, ela ajudou a remover mais de 100 milhões de linhas de código morto nos diversos produtos e sistemas da empresa. Isso foi feito de forma automática, por meio da abertura de 370 mil pull requests. Se em uma empresa relativamente nova e de tecnologia, existe essa quantidade de código morto, imagine em um sistema legado com 50 anos de desenvolvimento.

Para dar um segundo exemplo da importância de atividades de limpeza de código em sistemas legados, em um artigo publicado em 2009 na revista IEEE Software (link), Santonu Sarkar e mais cinco colegas descrevem uma experiência de modularização de um grande sistema bancário. O sistema nasceu no final da década de 1990 e, na época da escrita do artigo, possuía mais de 25 milhões de linhas de código. Segundo os autores, uma inspeção preliminar desse código mostrou que pelo menos 5% das funções de API do sistema eram duplicadas.

8.2.3 Testes Automatizados

Michael Feathers é autor de um livro chamado Working Effectively with Legacy Code. Uma frase do prefácio do seu livro ficou famosa: código legado é simplesmente um código sem testes. Logo, o autor defende que é fundamental investir na implementação de testes em sistemas legados. Segundo ele, agindo assim surgirão ilhas de código cobertas por testes. Com o passar do tempo, essas ilhas vão se conectar, formando um grande “continente” de código protegido por testes.

De fato, testes automatizados são fundamentais em desenvolvimento moderno de software, funcionando como uma rede de proteção contra regressões, inclusive aquelas que podem ocorrer quando refatoramos o código. Em resumo, toda organização deve investir na implementação de testes, inclusive para seus sistemas legados. Também sabemos que a maioria dos testes deve ser de unidades, conforme ilustrado na famosa Pirâmide de Testes, mostrada na próxima figura.

Pirâmide de testes

Porém, no caso de sistemas legados, a implementação de testes de unidade é mais desafiadora, devido ao alto acoplamento entre os módulos do sistema. Esse acoplamento dificulta o teste de pequenas unidades de código, tal como uma única função. Por exemplo, é comum que uma função f chame funções g1, g2, …, gn. Isso torna mais difícil testar f sem testar todas as g*.

Porém, se testarmos todas essas funções, o escopo do teste vai aumentar e ele vai deixar de ser um teste de unidade. Neste livro, não vamos nos alongar em uma apresentação sobre tipos de testes, pois o leitor interessado pode consultar nosso livro anterior (Engenharia de Software Moderna). No entanto, apenas para a explicação ficar autocontida, temos que lembrar que existe a possibilidade de usar mocks nas chamadas das funções g* que mencionamos acima. Porém, a criação de mocks pode dar algum trabalho, além de tornar o teste menos realista.

Portanto, gostamos de fazer a seguinte recomendação:

Em sistemas legados, principalmente aqueles escritos em linguagens antigas, temos que ser pragmáticos: qualquer tipo de teste automatizado é melhor do que nenhum teste.

O importante é dar os primeiros passos na implementação de testes. Um bom caminho é começar com testes fim a fim, usando frameworks como Selenium, Cypress e Playwright. Por isso, principalmente no início, pode acontecer de termos uma pirâmide de testes invertida (veja abaixo). Porém, vale de novo a recomendação: uma pirâmide invertida é melhor do que nenhuma pirâmide de testes.

Pirâmide de testes invertida (mais comum em sistemas legados)

Antes de concluir, uma observação: em sistemas legados mais antigos, a interface pode não ser Web, o que inviabiliza o uso dos frameworks mencionados para testes fim a fim. Porém, esses legados podem possuir scripts ou rotinas batch, que recebem um arquivo de entrada e geram um arquivo de saída. Nesses casos, podemos testar os scripts da seguinte forma:

script arq_entrada_n > arq_saida_n
diff arq_saida_n oraculo_n

Chamamos o script com uma certa entrada e depois comparamos a saída com um arquivo que possui a resposta certa (oraculo). Se tudo der certo, a diferença entre eles será vazia. E podemos repetir o teste para outros arq_entrada, arq_saida e oraculo. Claro, essa é uma forma simples e barata de testes fim a fim, mas ela é agnóstica em relação a tecnologia e, portanto, pode ser usada em sistemas antigos que possuem rotinas batch ou rotinas que podem ser chamadas diretamente a partir do sistema operacional.

8.2.4 Sprouts e Wrappers

Sprouts e wrappers são conceitos úteis para implementar código novo em sistemas legados, tendo sido ambos propostos por Michael Feathers. O objetivo é, de agora em diante, melhorar a qualidade do código que precisamos implementar em um sistema legado. E, para isso, vamos começar a investir também na implementação de testes de unidade. Ou seja, o código antigo (legado) vai continuar sem testes, mas o código novo (sprouts e wrappers) vai começar a ter testes.

Sprouts: Sprouts (em uma tradução livre, brotos) são métodos criados especificamente para implementar novas funcionalidades ou regras de negócio em sistemas legados. A ideia é simples: de agora em diante, no nosso legado, vamos tentar implementar todo código novo em métodos também novos, criados especificamente para esse fim. Como dissemos, um modo de implementar esses métodos é por meio de sprouts. A imagem que podemos fazer é de uma árvore antiga com um grande tronco envelhecido (o legado), do qual vão começar a surgir pequenos brotos (os sprouts) de cor verde, isto é, com código novo.

Como os sprouts são métodos novos, implementados do zero, é mais fácil escrever testes de unidade para eles. Outro ponto importante é que o código legado vai chamar os sprouts.

Suponha que você trabalha em um sistema legado com um método para calcular o preço de pedidos:

public class ServicoPedido {
  public double calcularPreco(Pedido pedido) {     // método legado
    double preco = pedido.getPrecoBase();
    if (pedido.getCliente().isVIP()) {
      preco = preco * 0.92;
    }
    return preco;
  }
}

Suponha ainda que as regras para calcular o desconto de clientes VIP mudaram de forma importante. Então, você decide implementar integralmente a nova regra em um método novo, chamado calcularDesconto. Logo, esse método é um sprout, conforme ilustrado a seguir:

public class ServicoPedido {
  
  public double calcularPreco(Pedido pedido) {     // método legado
    double preco = pedido.getPrecoBase();
    double desconto = calcularDesconto(pedido);    // chama sprout
    return preco - desconto;
  }

  public double calcularDesconto(Pedido pedido) {  // método sprout
    ... // regras de cálculo, incluindo nova regra de clientes VIP
  }
}

Veja que o código legado (calcularPreco) foi minimamente modificado, para chamar o sprout (calcularDesconto). Como o sprout é um método novo, pequeno e com poucas dependências, fica mais fácil escrever um teste de unidade para ele, como ilustrado a seguir:

@Test
void testarDescontoParaClientePremium() {
   Cliente cliente = new Cliente(true);
   Pedido pedido = new Pedido(100.0, cliente);
   ServicoPedido servico = new ServicoPedido();

   double desconto = servico.calcularDesconto(pedido);

   assertEquals(10.0, desconto, 0.001);
  }

Wrappers: Assim como sprouts, um wrapper é um método que criamos especificamente para adicionar código novo em um sistema legado. Porém, a diferença é que um wrapper envolve o código do legado, isto é, ele tem o controle e, portanto, é ele que chama o legado.

Suponha que você trabalha em um sistema legado que calcula o valor de fretes. E, agora, a empresa está implantando um frete expresso que é 10% mais caro. Essa nova regra de negócio pode ser implementada em um wrapper da seguinte forma:

public class CalculadoraFreteWrapper {

  private final SistemaFreteLegado legado = new SistemaFreteLegado();

  // método wrapper
  public double calcularFrete(double peso, boolean expresso) {
    double custoBase = legado.calcularFrete(peso);   // chama legado
    return expresso ? custoBase * 1.1 : custoBase;   // frete expresso
  }
}

Mostramos uma solução com classes, mas ela se aplica também a linguagens sem orientação a objetos, como mostrado a seguir:

// função wrapper 
double calcularFrete(double peso, boolean expresso) { 
  double custoBase = calcularFreteLegado(peso);    // chama legado
  return expresso ? custoBase * 1.1 : custoBase;   // frete expresso
}

// função legada 
double calcularFreteLegado(double peso) {
  ...
} 

De novo, a vantagem é que tende a ser mais fácil testar apenas o wrapper, já que ele é um método menor e que pode ser implementado já pensando em testabilidade.

Em resumo, sprouts e wrappers são usados para implementar código novo em sistemas legados. A diferença é que, no caso do sprout, o método legado chama o método novo; já no wrapper, o método novo envolve o legado e passa a chamá-lo, conforme ilustrado na próxima figura.

Sprouts vs Wrappers

8.2.5 Branch por Abstração

Branch por abstração é uma técnica útil quando queremos substituir uma implementação antiga por uma nova. Para isso, a técnica propõe a criação de uma abstração intermediária que permita implementar e testar o novo código de forma independente da implementação antiga. Assim, se algo der errado com a nova implementação, podemos facilmente voltar para a implementação antiga.

Suponha que você trabalha em um sistema legado e que você ficou encarregado de propor uma nova implementação para uma função chamada calcularLimiteCredito. Então, para garantir que a transição da implementação atual para a nova implementação ocorra de forma segura, você pode usar branch por abstração da seguinte forma:

1. Primeiro, você deve renomear a função para calcularLimiteCreditoVersaoAntiga, de forma a manter sua implementação preservada e intacta.

2. Em seguida, você deve criar uma nova função calcularLimiteCredito que vai atuar como uma camada de abstração, chamando calcularLimiteCreditoVersaoAntiga, enquanto a nova implementação, calcularLimiteCreditoVersaoNova, permanece desativada:

double calcularLimiteCredito() {
  calcularLimiteCreditoVersaoAntiga();
  // calcularLimiteCreditoVersaoNova();  
}

3. No ambiente de desenvolvimento, você deve inverter os comentários, para implementar e testar calcularLimiteCreditoVersaoNova.

4. Dando tudo certo, você deleta calcularLimiteCreditoVersaoAntiga e também a função intermediária (calcularLimiteCredito). Por fim, deve renomear calcularLimiteCreditoVersaoNova para calcularLimiteCredito.

5. Porém, se algo der errado, a versão antiga do código permanece disponível.

Entre as principais vantagens da técnica está o fato de que, durante a mudança, o sistema pode continuar operando normalmente com a implementação antiga e, caso a alteração não seja bem-sucedida, é simples retornar à versão anterior. Portanto, branch por abstração reduz o risco associado a mudanças, que tende a ser elevado em sistemas legados.

Pergunta Frequente: Por que o nome branch por abstração? Primeiro, no nosso exemplo, a abstração é a nova função criada para decidir qual implementação será usada em um dado momento: a implementação antiga ou a nova. Já o termo branch vem do fato de que a técnica lembra um branch de sistemas de controle de versões, como Git. Ou seja, em vez de criar um branch no sistema de controle de versões, criamos um branch lógico no próprio código, por meio de uma abstração intermediária.

8.2.6 Dark Launch e Execução Paralela

Dark launch e execução paralela são duas técnicas para testar novas funcionalidades em produção, porém de forma controlada e monitorada.

Dark Launch: Essa técnica propõe que uma nova funcionalidade não seja imediatamente habilitada para os usuários finais. A ideia é que ela permaneça escondida, mesmo já estando implantada no ambiente de produção, conforme ilustrado na próxima figura.

Dark Launch

Por exemplo, suponha uma nova funcionalidade para exibir lançamentos futuros em um aplicativo bancário. O aplicativo já acessa o backend para recuperar esses lançamentos, mas eles não são exibidos para os usuários. Por exemplo, a aba que vai mostrar os lançamentos permanece oculta.

O objetivo de dark launch é testar a implementação de uma nova funcionalidade, validar os logs que ela gera e identificar regressões, problemas de desempenho, de carga ou outros comportamentos indesejados antes de liberar a funcionalidade para uso. A ideia é não criar expectativas antes de realizar o máximo possível de testes em produção.

Execução Paralela: Essa técnica também tem como objetivo ajudar no teste de novas funcionalidades. A ideia é executar em conjunto uma implementação nova com sua versão antiga, exatamente para comparar os resultados e detectar possíveis bugs na nova implementação, conforme ilustrado na próxima figura.

Execução Paralela

Suponha a função calcularLimiteCredito, que usamos para explicar branch por abstração anteriormente. Suponha ainda que temos duas implementações dessa função.

Acabamos de implementar a nova versão da função e não temos certeza de que cobrimos todos os possíveis edge cases, pois a lógica para calcular o limite de crédito de um cliente não é trivial. Então, durante um período de testes, podemos executar ambas as funções em produção e verificar se a nova implementação sempre retorna os mesmos valores. Para isso, podemos usar uma função intermediária:

double calcularLimiteCredito() {
  double limiteAntigo = calcularLimiteCreditoAntiga();
  double limiteNovo = calcularLimiteCreditoNova();
  if (limiteNovo != limiteAntigo)
     "logar resultado inconsistente da nova implementação" 
  return limiteAntigo;
}

Veja que o sistema vai continuar funcionando normalmente com a função antiga, pois ela está testada e confiamos nos seus resultados. Logo, para os usuários do sistema nada vai mudar. Mas também já chamamos a nova versão e, se em algum caso específico, ela retornar um resultado diferente, registramos esse resultado para análise e depuração.

Execução paralela é interessante porque, em vez de investir em testes manuais, tiramos proveito do grande número de usuários de um sistema legado e da existência de um oráculo natural (a implementação antiga) para testar, sem muito esforço, a nova implementação. Consequentemente, reduzimos os riscos de bugs.

8.3 Descontinuação

Como sugere a frase do Prof. David Parnas, com a qual abrimos o capítulo, não é razoável supor que um sistema legado vai operar para sempre. Na verdade, já é surpreendente a resiliência de certos sistemas, que continuam cumprindo seu propósito após décadas de uso. Logo, em algum momento, teremos que começar a pensar na descontinuação (ou aposentadoria) de um sistema legado.

8.3.1 O que não Funciona?

Antes de nos aprofundarmos em técnicas para descontinuação de sistemas legados, já gostaríamos de ressaltar que uma estratégia que não funciona é a migração de um sistema legado de uma só vez. O principal motivo é que eles são sistemas complexos, que processam milhares ou milhões de transações por dia e que automatizam milhares de regras de negócio (sendo que muitas delas podem não ser mais do conhecimento dos desenvolvedores atuais do sistema, pois foram introduzidas no código décadas atrás). Então, é muito arriscado desenvolver um sistema novo, do zero, e marcar um dia D para migrar do legado para esse novo sistema. Isso já foi tentado por algumas empresas, os resultados não foram bons e o projeto de migração acabou abandonado.

8.3.2 Strangler Fig

Quando se trata da descontinuação de um sistema legado, o que funciona melhor é uma migração gradativa. Na verdade, existe um nome para esse padrão de migração gradativa: Strangler Fig. Esse padrão, proposto por Martin Fowler, remete a uma variedade de figueira (a árvore que dá figos) que é estranguladora (strangler fig). Essa figueira cresce ao redor de uma árvore hospedeira, envolvendo-a aos poucos. Com o tempo, a árvore hospedeira vai morrendo por estrangulamento, até que reste apenas a figueira.

Nessa analogia, a árvore hospedeira é o sistema legado e a figueira são os serviços que queremos remover gradativamente do legado, até que ele não tenha mais nenhuma funcionalidade em operação. Quando isso acontece, o legado pode ser definitivamente descontinuado. A próxima figura ilustra o funcionamento dessa forma de migração gradativa usando o padrão Strangler Fig.

Padrão Strangler Fig para migração de sistemas legados. Os serviços do legado vão sendo gradativamente migrados para uma nova aplicação

Apesar de não ser obrigatório, na figura mostramos o legado como um único monolito, pois essa arquitetura é comum nesse tipo de sistema. Por outro lado, na nova aplicação, fizemos questão de mostrar cada novo serviço em uma caixa diferente, significando que eles são independentes entre si. Por exemplo, eles podem ser microsserviços, como também é bastante comum hoje em dia.

Mundo Real: Um caso conhecido de aplicação do padrão Strangler Fig foi a migração dos sistemas da Netflix de um monolito implementado em Java, executado em servidores próprios, para uma arquitetura de microsserviços rodando na nuvem. A migração começou em 2008, após um problema no banco de dados do monolito que interrompeu por três dias o envio de DVDs aos clientes (na época, a Netflix ainda não oferecia streaming). A descontinuação do monolito foi realizada de forma gradual ao longo de cerca de sete anos. Nesse período, a empresa transferiu progressivamente seus sistemas para a nuvem e desativou seus servidores próprios. Em 2016, os últimos servidores usados pela empresa foram desligados, e toda a infraestrutura da Netflix passou a operar na nuvem.

8.4 Extração de Serviços

Para aplicar o padrão Strangler Fig e iniciar a descontinuação de um sistema legado, precisamos antes tomar algumas decisões, incluindo:

Nas próximas seções, vamos explorar os pontos que devem ser considerados para tomar as decisões mencionadas.

8.4.1 Reescrita ou Reúso

Normalmente, os serviços que pretendemos extrair estão implementados em uma linguagem antiga, usando bibliotecas e bancos de dados também antigos. Por isso, o comum é remover o código do legado e reimplementá-lo, de forma integral, em novas tecnologias, como serviços independentes.

Por outro lado, existem exceções. Por exemplo, podemos ter um legado implementado em uma versão antiga de Java e, nesse caso, o objetivo pode ser extrair alguns microsserviços desse legado, para tornar a manutenção e evolução deles mais ágil. Então, pode ser interessante reusar o código que se pretende extrair e apenas atualizá-lo para uma versão mais nova da linguagem e das bibliotecas usadas, se isso for necessário.

8.4.2 Delimitação do Serviço

Outra decisão chave consiste em selecionar e delimitar o serviço do sistema legado que será extraído. Por exemplo, em um sistema bancário, podemos pensar em extrair serviços como pagamento de boletos, PIX, concessão de empréstimos consignados, detecção de fraudes, seguros, etc.

Diversos critérios devem ser considerados nessa decisão, incluindo a relevância e o risco do serviço para o negócio, bem como demandas tecnológicas. Por exemplo, um serviço pode ser um forte candidato para extração porque depende de uma biblioteca de terceiros cujo suporte será descontinuado em breve.

Porém, nesta seção vamos focar nos critérios técnicos, isto é, aqueles relacionados com boas práticas de projeto de software. Sob esse ponto de vista, o serviço extraído deve possuir alta coesão e baixo acoplamento com outros serviços. Ou seja, ele deve implementar uma funcionalidade bem definida (coesão) e fazer isso com o menor número possível de dependências com outros serviços (acoplamento).

Suponha que já temos um serviço candidato à extração. Então, para estimar o esforço que a extração vai demandar devemos computar os seguintes conjuntos:

Conjuntos UsadosLegado e UsadosNovo

Para ilustrar de uma forma um pouco mais detalhada, seja um legado e um serviço que se planeja extrair conforme mostrado na próxima figura.

Acoplamento entre legado e um serviço candidato a extração

Nesse caso, temos:

O conjunto Novo contém os arquivos que planeja-se migrar para um serviço separado e autônomo. Já Legado são os arquivos que vão continuar no legado. Logo, o tamanho de Novo dá uma ideia do esforço necessário para a migração.

Já os conjuntos UsadosLegado e UsadosNovo representam o acoplamento entre o legado remanescente e o serviço a ser extraído. Idealmente, queremos que esse acoplamento seja mínimo, conforme afirmamos antes. Portanto, quanto menor o tamanho dos conjuntos UsadosLegado e UsadosNovo menor será o esforço de extração do serviço.

Concluindo, ao planejar a extração de um serviço de um legado, é importante calcular esses conjuntos de forma criteriosa. Eles permitem obter uma estimativa dos desafios e riscos inerentes ao processo de extração. Sendo mais explícito, se os conjuntos UsadosLegado e UsadosNovo possuírem dezenas de arquivos, a extração certamente será complexa. Então, uma preparação prévia do legado pode ser necessária, para reduzir essa complexidade, como veremos na próxima seção.

8.4.3 Preparação do Legado

Para facilitar a extração de um serviço, muitas vezes é importante refatorar o código do legado antes. Por exemplo, um ponto de atenção deve ser o conjunto de arquivos UsadosNovo, que mencionamos antes. Esses arquivos implementam funções que o novo serviço precisa chamar, mas que continuarão implementadas no legado. Essas funções complicam a extração porque temos comunicação não apenas no sentido Legado Serviço, mas também no sentido inverso.

Duas refatorações principais podem ser tentadas para reduzir o tamanho do conjunto UsadosNovo, conforme descrito a seguir.

Menos chamadas, com mais dados: Uma oportunidade de refatoração surge quando o legado chama uma função — que planejamos migrar para o novo serviço — e passa apenas um valor inicial, a partir do qual a função irá obter os demais dados de que precisa para operar. Por exemplo, suponha a seguinte função:

double avaliarEmprestimo(long clienteId, double valor, int prazo) {
  ...
}

Essa função recebe apenas o identificador do cliente. Porém, para avaliar a concessão de um empréstimo, ela também precisa da renda mensal e idade do cliente. Logo, se não tomarmos nenhuma providência, o novo serviço vai chamar o legado de volta para recuperar esses dados, como ilustrado no diagrama de sequência a seguir.

Serviço a ser extraído precisa de dados do legado para operar

Nesses casos, para eliminar essas chamadas de volta, podemos passar todos os dados de que o serviço precisa de uma só vez como parâmetros para avaliarEmprestimo, como a seguir:

void avaliarEmprestimo(long clienteId, double valor, int prazo,
                       double rendaMensal,
                       int idade) {
  ...
}

Assim, reduzimos o acoplamento entre o serviço e o legado, o que tornará mais fácil a extração do serviço.

Em resumo, em um sistema distribuído, como é o caso de um sistema composto por um legado e por um novo serviço, o ideal é ter menos interações remotas, mesmo que isso implique em aumentar o volume de dados trocados em cada chamada, que costumamos chamar de payload das chamadas.

Duplicação de Código: Muitas vezes, o legado também implementa uma função utilitária de que o novo serviço vai precisar. Aqui, chamamos de função utilitária uma função que presta um serviço genérico e bem delimitado, sem depender de regras de negócio ou de outros módulos do sistema. Como exemplo, podemos citar funções para cálculo de datas, validação de CPF ou CNPJ, formatação de valores monetários, geração de identificadores e rotinas de serialização ou conversão de dados. Uma primeira solução pode ser manter essas funções no legado e fazer com que o novo serviço as chame de volta. Porém, já vimos que isso cria acoplamento, o que é indesejável. Então, como essas funções são pequenas e autocontidas, uma solução consiste em reimplementá-las no novo serviço. Ou seja, a ideia é trocar acoplamento por duplicação de código, o que, nesse contexto específico, tende a ser vantajoso.

8.4.4 Comunicação Distribuída

Em um sistema legado e monolítico, todos os módulos rodam em um mesmo processo. Logo, a comunicação entre eles se dá por meio de chamadas de funções, as quais estão no mesmo espaço de memória.

No entanto, ao extrair um serviço de um legado, migramos também para um modelo de comunicação distribuída. Em outras palavras, a comunicação entre eles será remota usando-se protocolos de comunicação em redes. Consequentemente, precisamos definir a tecnologia de comunicação distribuída que será usada nos seguintes casos:

Para viabilizar as interações acima, temos duas opções principais de comunicação: síncrona ou assíncrona.

Comunicação Síncrona: Nesse modelo, um cliente faz uma requisição e fica esperando a resposta de um servidor. Por exemplo, ele é o modelo de comunicação adotado por APIs REST ou GraphQL. Ele é mais simples de ser implementado, mas também cria um acoplamento temporal entre o legado e o novo serviço. Isto é, ambos processos devem estar no ar, ao mesmo tempo, para que possam se comunicar.

Tratamento de falhas é uma outra preocupação relevante em um modelo de comunicação síncrona. Especificamente, nesse caso temos que adaptar o legado para tratar falhas de comunicação com o novo serviço, o que pode ser desafiador. O motivo é que, em um legado, existe um risco na implementação de qualquer funcionalidade, ainda mais uma funcionalidade que não é trivial, como o tratamento de falhas de comunicação com um serviço remoto.

Comunicação Assíncrona: Nesse modelo, um cliente faz uma requisição e continua seu processamento enquanto ela está sendo processada por um servidor. No caso de sistemas distribuídos, esse modelo de comunicação requer o uso de um sistema intermediário, como uma fila de mensagens ou um sistema de Publish/Subscribe. Não iremos descrever esses sistemas aqui, pois o leitor interessado pode consultar nosso livro anterior (Engenharia de Software Moderna). Mas, basicamente, esses sistemas intermediários, chamados também de brokers ou barramentos, atuam como um buffer confiável para as mensagens trocadas entre dois processos. Por exemplo, os processos podem se comunicar mesmo que não estejam no ar ao mesmo tempo, pois as mensagens seguem o caminho produtor broker consumidor. Portanto, quando se usa essa solução não existe mais a mensagem servidor indisponível. O motivo é que as mensagens ficam armazenadas no barramento até que um processo consumidor (ou servidor) apareça para processá-las.

Exemplo: A próxima figura ilustra um legado do qual foi extraído um serviço de pagamentos. A comunicação entre eles ocorre por meio de um sistema de filas. Na verdade, são usadas duas filas: o legado posta os dados do pedido de pagamento em uma primeira fila; o novo serviço de pagamentos lê esses dados, processa o pagamento e posta o resultado (pagamento bem-sucedido ou não) em uma fila de respostas, que é então consumida pelo legado.

Comunicação entre legado e um novo serviço usando filas de mensagens

As mensagens postadas na fila podem ser, por exemplo, documentos JSON. A seguir, mostramos uma mensagem com um pedido hipotético de compra que foi realizada no legado e que agora tem que ser debitada pelo serviço de pagamento:

{ "tipo": "pedido_pagamento",
  "pedidoId": "PED-983421",
  "timestamp": "2026-03-10T10:15:32Z",
  "cliente": {
    "clienteId": "CLI-44721",
    "nome": "João da Silva"
  },
  "pagamento": {
    "valor": 149.90,
    "moeda": "BRL",
    "metodo": "cartao_credito",
    "parcelas": 1
  },
  "cartao": {
    "token": "tok_9f82a1bc33",
    "bandeira": "VISA"
  }
}

Se o pagamento for processado com sucesso, o serviço de pagamentos pode postar como resposta a seguinte mensagem:

{ "tipo": "resposta_pagamento",
  "pedidoId": "PED-983421",
  "timestamp": "2026-03-10T10:15:34Z",
  "status": "APROVADO",
  "transacaoId": "TX-77123911",
  "autorizacao": {
    "codigoAutorizacao": "A57291",
    "adquirente": "Cielo"
  },
  "valorProcessado": 149.90,
  "moeda": "BRL",
  "mensagem": "Pagamento autorizado"
}

Porém, se houver algum problema, a resposta JSON pode ser a seguinte:

{ "tipo": "resposta_pagamento",
  "pedidoId": "PED-983421",
  "timestamp": "2026-03-10T10:15:34Z",
  "status": "RECUSADO",
  "codigoErro": "LIMITE_INSUFICIENTE",
  "mensagem": "Cartão recusado pela operadora"
}

8.4.5 Migração de Dados

Por último, mas não menos importante, temos que decidir como vamos dividir o banco de dados entre o legado e o novo serviço que pretendemos extrair. Algumas das possíveis soluções de particionamento são apresentadas a seguir.

Banco de Dados Compartilhado. Essa solução, na verdade, consiste em manter o banco de dados do legado inalterado e apenas compartilhá-lo com o novo serviço. A vantagem é que ela não tem custos de implementação e, portanto, pode ser um primeiro passo no processo de migração. Por outro lado, essa solução mantém o novo serviço acoplado ao legado do ponto de vista de dados. Consequentemente, evoluções no modelo de dados, incluindo a criação de novas tabelas e campos, deverão ser acordadas entre os times do legado e o time responsável pelo novo serviço. Na prática, isso implica em uma menor autonomia e agilidade para evoluir o novo serviço.

Banco de Dados Dedicado. Nesta solução, o novo serviço começa a ganhar autonomia também do ponto de vista de dados, ou seja, ele passa a ter um banco de dados próprio. Então, a decisão mais importante consiste em definir quais tabelas deverão ser migradas para o novo serviço. As possíveis estratégias de migração de tabelas são as seguintes (acompanhe também por meio do diagrama que segue a descrição):

1. Migrar tabelas de uso exclusivo do novo serviço e que não são usadas pelo legado, o que é uma solução de implementação relativamente simples.

2. Particionar verticalmente tabelas que são usadas pelo legado e pelo novo serviço. Por exemplo, suponha uma tabela Clientes com campos como id, nome, endereco, email, limiteCredito e scoreRisco. Se o novo serviço que pretendemos extrair for responsável pela análise de crédito, podemos migrar para seu banco os atributos ligados a essa responsabilidade, isto é, limiteCredito e scoreRisco, mantendo os demais campos no legado. Portanto, a tabela Clientes será verticalmente particionada em duas tabelas, cada uma sob responsabilidade de um sistema diferente.

3. Criar uma API no legado para permitir que o novo serviço acesse tabelas que ficaram nesse sistema. No exemplo anterior, o novo serviço pode ainda precisar dos dados básicos de clientes, como nome, endereço e email. Então, ele irá acessar esses dados chamando de volta uma API disponibilizada no legado apenas para esse fim.

4. Replicar tabelas do legado no novo serviço. Essa solução é recomendada para dados que não mudam com frequência, como listas de cidades, países, moedas, categorias de cliente, categorias de produto ou faixas de risco. A vantagem é que o novo serviço ficará desacoplado do legado no que tange a tais tabelas. Por outro lado, precisamos definir como a replicação será feita e qual o grau de desatualização dos dados é aceitável.

Estratégias para migração de tabelas do legado para um novo serviço

Para concluir, é importante esclarecer que qualquer esforço de migração, normalmente, inclui uma combinação das soluções descritas acima.

Bibliografia

Michael C. Feathers. Working Effectively with Legacy Code. Pearson, 2004.

Sam Newman. Monolith to Microservices: Evolutionary Patterns to Transform Your Monolith. O’Reilly, 2019.

Marco Tulio Valente. Engenharia de Software Moderna: Princípios e Práticas para Desenvolvimento de Software com Produtividade, 2020.

Alessandra Levcovitz, Ricardo Terra, Marco Tulio Valente. Towards a Technique for Extracting Microservices from Monolithic Enterprise Systems. 3rd Brazilian Workshop on Software Visualization, Evolution and Maintenance, p. 97-104, 2015.

Exercícios

1. Pode ser que você não pretenda investir na implementação de testes de unidade para um sistema legado que está mantendo (por exemplo: porque ele está implementado em uma linguagem na qual isso é mais difícil, como COBOL). Mesmo assim você acha que existem vantagens em incentivar a criação de sprouts e wrappers? Justifique.

2. Qual a principal diferença entre as técnicas de dark launch e release canário? Para saber mais sobre release canário, você pode consultar o Capítulo 10 do nosso livro anterior (Engenharia de Software Moderna).

3. Existe muita crítica — e até memes — sobre a prática de testar em produção em Engenharia de Software. No entanto, neste capítulo estudamos duas técnicas que, na prática, realizam testes controlados em ambiente de produção e que podem ser úteis para validar novas funcionalidades em sistemas legados. Identifique essas duas técnicas e descreva como elas funcionam. Em seguida, explique quais vantagens cada uma delas oferece para teste de mudanças em sistemas legados.

4. Suponha que você ficou encarregado de realizar uma manutenção evolutiva importante em uma classe ServicoPagamento, a qual vai requerer mudanças em diversos dos seus métodos. Você decide então usar branch por abstração para garantir que a mudança ocorra com segurança. Para isso, você primeiro implementa uma nova classe ServicoPagamentoNovo. Então, qual abstração intermediária você deve implementar em seguida para garantir que o sistema continue funcionando com a classe antiga, enquanto implementa a classe nova?

5. Suponha a seguinte função em C:

int processar_pedido(...) {
    ...
    int frete = calcula_frete_legado(...);
    ...
    return status;
}

Nesse código, sabemos também que a função calcula_frete_legado é instável e lenta, pois ela acessa algumas tabelas de um banco de dados muito antigo. Suponha, no entanto, que você foi encarregado de escrever um teste de unidade para processar_pedido. Qual modificação você faria primeiro no código de processar_pedido antes de escrever esse teste?

6. Pesquise e descreva o significado do termo seams, no contexto de implementação de testes de unidade para sistemas legados. Explique esse conceito usando o exemplo do exercício anterior.