Com a evolução dos aplicativos, nascem novas técnicas, frameworks, linguagens de programação. Porém, existe um fato consolidado dentro da arquitetura de software corporativo que é a integração com tecnologias necessárias para armazenar as informações inerentes ao sistema. Seja SQL ou NoSQL um ponto importante é que o paradigma do time de desenvolvimento é diferente do time da persistência da informação. Com o intuito de facilitar o desenvolvimento surgem as ferramentas que realizam a interpretação entre a camada da aplicação e os bancos. Com isso, aparecem grandes desafios: Como lidar com essa lacuna multiparadigma? Como favorecer o desenvolvimento sem impactar a performance e a modelagem no banco de dados? O objetivo desse post é falar um pouco desses pontos para que, finalmente, os programadores e os DBAs consigam viver em paz e harmonia.
Quando se fala de uma aplicação, principalmente, em um escopo corporativo, o primeiro passo certamente, é deixar claro a diferença entre o que é arquitetura e design. Afinal, esses conceitos ainda são o maior dilema e existem diversas biografias que apontam conceitos e pontos de vistas diferentes. Para simplificar, a arquitetura é o processo de software que cuida da flexibilidade, escalabilidade, usabilidade, segurança e é nesse ponto do qual o lado técnico encontra as expectativas de negócio. Dentre os exemplos de decisões de arquitetura:
- Serverless Architecture: Essa solução se refere uma aplicação que depende de um serviço para gerenciar a complexidade de serviços de backend. Pode ser dividida em Backend as a Service (BaaS), e Function as a Service (FaaS). O principal objetivo dessa decisão é abstrair a complexidade de deploy, economizar de recursos além de aumentar a elasticidade, que são necessários para os servidores tradicionais.
- Event-Driven Architecture: Uma arquitetura que se baseia em eventos seja como produtor seja como consumidor. O principal objetivo aqui é desacoplar dependências de sistemas, uma vez que o produtor não precisa ter informação de quem são os serviços consumidores. Por exemplo, pensando em um e-commerce quando o consumidor realizar uma compra é possível lançar um evento de "Ordem pendente", assim, todos os interessados neste evento podem escutar, um exemplo simples, o estoque pode reservar o produto do cliente e gerar um novo evento.
- Microservices Architecture: Certamente, a arquitetura mais popular atualmente. De uma maneira geral, o objetivo é criar pequenos módulos de maneira independentes e cada módulo tem uma responsabilidade única de resolver ou ter um foco específico. A comunicação é realizada a partir de uma API.
O design tem uma responsabilidade em baixo nível e lida diretamente com a infraestrutura do software, por exemplo, o que e como cada módulo está fazendo, o escopo das classes, o propósito das funções, dentre outras informações. Alguns exemplos de Design:
- SOLID: Reference aos princípios de Single Responsibility, Open Closed, Liskov substitution, Interface Segregation and Dependency Inversion Principles. Do qual são consideradas o maior alicerce da programação orientada a objeto.
- Design Patterns: o conjunto de boas práticas utilizadas e definidas, principalmente, no mundo orientado a objeto, do qual, salvará o tempo com melhoria e manutenção do sistema.
Uma maneira interessante de se pensar na diferença é que a arquitetura é estratégica, ou seja, cuida do alto nível e na decisão do negócio e o design tem relação com tática e ligados a infraestrutura de decisões em nível de código, afinal, se pode definir que uma estratégia é determinada por uma sequência de táticas e tais sequências impactam o escopo do negócio.
Uma vez dito as diferenças entre design e arquitetura, existe um fato que as aplicações estão ficando cada vez mais complexas, com requisitos funcionais e não-funcionais cada vez mais complicados, além da integração com diversos sistemas, diversas estratégias vêm sendo cada vez mais aprimoradas e utilizadas no mundo software. Um ponto importante é que a grande parte dessas estratégias tomam como base uma das estratégias romanas mais antiga do mundo, dividir e conquistar. Existem diversos benefícios em utilizar esse pensamento, por exemplo, quebrar a complexidade em pequenas partes de um todo diminuindo a complexidade e permitindo que mais pessoas trabalhem em um determinado problema.
Esse tipo de estratégia teve e tem um grande êxito em diversos campos da computação, como podemos ver no caso das placas mãe dos computadores, onde eram composta por um componente que tinha todas as funções e com o passar do tempo se criaram módulos menores e cada um com o seu objetivo e propósito além das especialidades, por exemplo, a Intel tem uma grande especialidade em fabricar processadores e a NVidia e outras empresas foram especializadas em fabricar placas de vídeo. Esse resultado não é muito diferente no mundo do software, uma vez que se divide em módulos muito facilmente quando falamos na testabilidade de um módulo a ponto, inclusive de mockar, e garantir uma maior facilidade na criação de testes e por consequência, maior qualidade na entrega, por exemplo.
A visão de dividir em blocos, em uma aplicação clássica as primeiras camadas que serão encontradas são as físicas. Vejamos, em um simples serviço de requisição existe a camada de banco de dados, a camada do servidor ou lógica e a apresentação. Dentro dessa camada lógica, existem também diversas divisões. Pensando além do MVC do qual divide em camadas de Modelagem, Visão e Controle e focando na comunicação entre as camadas físicas e lógica, além do banco de dados, existe uma ponta cujo o foco é justamente fazer essa comunicação. Pensando em padrão de projetos, seria o Data Access Object que prevê uma abstração para acesso ao banco de dados.
No entanto, existe uma grande diversidade de paradigmas tanto do lado dos desenvolvedores com orientação a objetos, funcional e reativa quanto no lado do banco de dados como relacional, orientado a chaves, família de coluna, documentos e grafos. Assim, é necessário pensar em uma maneira em realizar a conversão tanto na comunicação quanto na conversão entre os paradigmas do banco de dados com o desenvolvimento.
Realizar esse tipo de conversão manualmente, além de uma grande quantidade de tempo é suscetível a erros além de ser muito repetitivo. Para facilitar esse tipo de tarefa nasceu os conversores, com o foco bastante simples, em realizar a transformação dos paradigmas de maneira fácil. Existem diversas vantagens nessa abordagem, por exemplo, o foco do desenvolvedor é maior em entidades reais, diminuindo o ponto de atenção no banco de dados, menos código duplicado e de infra, além de maior visibilidade no domínio do negócio.
Porém, isso vem com um alto preço e impacto para o banco de dados, já que os desenvolvedores ficam tão focados no domínio, que se esquecem que as estruturas do banco de dados não são orientadas a objeto. Esse falta de contato dá uma falsa impressão de que não se é necessário entender de banco de dados e com isso nascem os maiores problemas nas estruturas internas, como impacto de performance e no problema de desenho das entidades. Dentro do mundo dos mapeadores, se pode dividir em dois tipos e cada um com a sua respectiva estratégia:
O primeiro deles é o active record, tendo como ponto mais forte a simplicidade de código a integração com o banco de dados. Basicamente, dado uma entidade basta fazermos com que seja filha de uma classe pai de active record. Isso permite uma grande facilidade dentro dos bancos de dados e assim conseguem herdar várias operações, no caso, o bom e velho CRUD (criar, recuperar, atualizar e deletar informação) dentro do banco de dados. Essa facilidade tem um alto preço, o maior deles está no acoplamento do banco de dados com a entidade, alto impacto de performance já que, por padrão, cada operação é atômica. Assim, para cada operação requer um commit e um callback e muita vezes, se deseja salvar mil entidades de maneira transacional. Por isso, esse tipo de solução é recomendada para um baixo fluxo de dados.
Vamos ver um exemplo utilizando o ActiveJDBC sendo possível ver a facilidade de interação com o banco de dados.
public class Person extends Model {}
Person person = new Person("Ada","Lovelace");
person.saveIt();
Person ada = Person.findFirst("name = ?", "Ada");
String name = ada.get("name");
ada.delete();
List<Person> people = Person.where("name = 'Ada'");
O segundo deles é o mapeamento, sendo seu o objetivo fazer com que as entidades sejam mapeadas e o componente de operação do banco de dados fique fora da entidade. Isso traz diversas vantagens, como o controle de transação e performance sendo explícito, e ficando mais fácil de fazer com que a modelagem não seja acoplada ao modelo. Porém, isso aumenta drasticamente a complexidade para o lado dos desenvolvedores.
Por exemplo, utilizando o acesso com o tradicional JPA.
@Entity
public class Person {
@Id
private String name;
@Column
private String lastName;
}
EntityManager manager = getEntityManager();
manager.getTransaction().begin();
Person person = new Person("Ada","Lovelace");
manager.persist(person);
manager.getTransaction().commit();
List<Person> people = manager.createQuery("SELECT p from Person where p.name = @name").setParameter("name", "Ada").getResultList();
Existe um fato importante entre esses bancos de dados. Por mais poderosos e aprimorados que sejam os conversores ainda existe uma impedância entre os bancos de dados e o paradigma no mundo do desenvolvimento. Pensando em orientação a objetivos um banco de dados não possui encapsulamento, acessibilidade, interface, herança e polimorfismo e suporte a diversos tipos. O fato é que num modelo complexo ao se utilizar boas práticas como DDD e/ou o clean code, os bancos de dados não suportaram esses tipos de operações e quanto mais rápido o desenvolvedor perceber isso melhor será para a saúde do banco de dados.
Existem diversas falhas quando se modela e como se reflete no banco de dados. O primeiro deles são os famosos campos auto incrementais como ID, como a maioria das documentações fala. Esse tipo de banco requer um grande poder computacional, por exemplo, imagine o cenário em que sejam adicionados mais de cinco mil entidades, é necessário enfileirar essas entidades para garantir o processo de contagem.
Um outro ponto é escolher a chave como auto incremental significando um desperdício de um campo que será o mais automatizado da tabela. Um erro comum é usar esse campo como auto incremental e ter um campo único natural do negócio como um documento único do usuário ou o nickname no sistema. Falando em chave única, vale lembrar que nos bancos de dados relacionais existem o processo de normalização e essa normalização requer chaves compostas, que por mais incômoda que seja para o lado do software, é importante este tipo de normalização pois terá impacto positivo em performances dentro do banco de dados relacionais. Um outro ponto é tentar a normalização dentro de uma base de dados não relacional, evitar emulações é uma boa maneira de se ganhar performance em um sistema, porém, é um erro recorrente, semelhante a se comprar um mamífero na esperança que bote um ovo, o resultado é uma arquitetura do ornitorrinco.
Essas simulações resultam de maneira catastrófica para uma aplicação, problemas como a falta de índices, os erros de N+1 além de realizar a busca com coringa em vez de se utilizar um motor de busca como ElasticSearch. A solução temporária é adicionar cache e memória na aplicação, podendo diminuir a consistência, além de ser uma solução temporária, uma vez que existe uma limitação física de memória que precisará ser corrigido cedo ou tarde.
Além da performance, existem problemas na integridade no banco de dados. Vejamos, um banco NoSQL que trabalha com schemeless e permite qualquer dado de entrada, porém, em uma aplicação é crucial que o valor dentro do campo e-mail tenha um valor válido, a mesma coisa acontece também com campos como moeda, idade, etc.
A solução para o problema de integridade de dados é o desenho de um modelo rico que como diz no clean code, expõe o comportamento para esconder os dados. Existem boas práticas como a criação de builder eficientes, fluent APIs, DSL além do conceito de Value Object do DDD e do when make a type. De modo que em nível de design caso seja NoSQL, a camada de software garante a integridade e para os bancos de dados relacionais com schema existirá um double check.
Subindo a visão e indo para arquitetura, existe um livro muito interessante chamado Clean Architecture, onde deixa claro alguns conceitos: Separar o que importa, ou seja, negócios do código, de negócio do código de infraestrutura. De uma maneira geral, código de infraestrutura é todo aquele código que não pertence ao negócio ou core-business da empresa. Dessa forma se terá:
- Uma entidade que será baseada no modelo rico e totalmente focada na orientação a objeto;
- Uma camada repositório que será responsável por abstrair a forma de inserir as informações com a entidade rica;
Utilizando essa estratégia de criar uma camada que adapta ao banco de dados, é possível separar a entidade da modelagem com o banco de dados a ponto de fazer uma modelagem olhando somente nos dados sem que o modelo saiba, trocando a implementação do banco de dados sem que a entidade saiba, ou seja, existe uma total transparência entre o banco de dados e o paradigma da aplicação. De modo que essa camada de infra, poderá ser chamada de camada de paz entre os desenvolvedores e o DBA. Uma vez que garante tirar o máximo de proveito do banco de dados e o máximo de proveito da orientação a objetos. Porém, existe um impacto nessa solução, pois adiciona uma nova camada para aplicação aumentando a complexidade e a quantidade de código a ser gerenciado criado por parte dos desenvolvedores.
Com isso se falou das vantagens e desvantagens de utilizar conversores que fazem o papel de ponte entre o paradigma do mundo de desenvolvimento com o banco de dados sendo relacional ou não. Como toda arquitetura, é muito importante avaliar o trade-off de cada escolha, como a simplicidade do ActiveRecord resulta em um alto impacto de performance e a subutilização do banco de dados. Os Mapper incrementam um pouco mais a complexidade, porém, traz para mais próximo o melhor do mundo uma vez que o desenvolvedor obedeça às boas práticas de programação e não deixem que a ferramenta crie as estruturas automaticamente. Em nível arquitetural, uma separação total do codigo de infra e da modelagem garante um modelo rico com uma boa experiência com o banco de dados, porém, uma nova camada resulta numa maior complexidade. E cabe ao time escolher, pragmaticamente, quando escolher cada uma das soluções por razões de performance e tempo de entrega.