Por que o bom senso é mais importante do que os padrões, e o Active Record não é tão ruim

Acontece que os desenvolvedores, especialmente os jovens, adoram padrões, gostam de discutir sobre qual padrão deve ser aplicado aqui ou ali. Para argumentar até a rouquidão: isso é uma fachada ou um proxy, e talvez até um singleton. E se sua arquitetura não for limpa, hexagonal, então alguns desenvolvedores estão prontos para queimar na fogueira da Santa Inquisição.



Ao fazer isso, eles esquecem que os padrões são apenas soluções possíveis. Os padrões, como quaisquer princípios, têm limites de aplicabilidade e é importante entendê-los. A estrada para o inferno é pavimentada com a adesão cega e religiosa até mesmo a palavras autorizadas.



E a presença dos padrões necessários na estrutura não garante sua aplicação correta e consciente.







O brilho e a pobreza do Active Record



Vejamos o padrão Active Record como um antipadrão, que algumas linguagens de programação e frameworks tentam evitar de todas as maneiras possíveis.



A essência do Active Record é simples: armazenamos lógica de negócios com lógica de armazenamento de entidade. Em outras palavras, para simplificar, cada tabela no banco de dados corresponde a uma classe de entidade junto com um comportamento.





Há uma opinião bastante forte de que combinar lógica de negócios com lógica de armazenamento em uma classe é um padrão muito ruim e inutilizável. Isso viola o princípio da responsabilidade exclusiva. E por esta razão Django ORM é ruim por design.



Na verdade, pode não ser muito bom combinar lógica de armazenamento e lógica de domínio na mesma classe.


Vamos pegar os modelos de usuário e perfil, por exemplo. Este é um padrão bastante comum. Há uma placa principal e outra adicional, que armazena nem sempre dados obrigatórios, mas às vezes necessários.





Acontece que a entidade do domínio “usuário” agora está armazenada em duas tabelas, e no código temos duas classes. E toda vez que fazemos algumas correções diretamente no user.profile, precisamos nos lembrar que este é um modelo separado e que fizemos alterações nele. E salve-o separadamente.



   def create(self, validated_data):
        # create user 
        user = User.objects.create(
            url = validated_data['url'],
            email = validated_data['email'],
            # etc ...
        )

        profile_data = validated_data.pop('profile')
        # create profile
        profile = Profile.objects.create(
            user = user
            first_name = profile_data['first_name'],
            last_name = profile_data['last_name'],
            # etc...
        )

        return user


Para obter uma lista de usuários, é imperativo pensar se o atributo será obtido desses usuários profile, a fim de selecionar imediatamente dois sinais com uma junção e não colocá-los SELECT N+1em um loop.



user = User.objects.get(email='example@examplemail.com')
user.userprofile.company_name
user.userprofile.country


As coisas ficam ainda piores se, dentro da arquitetura de microsserviço, parte dos dados do usuário são armazenados em outro serviço - por exemplo, funções e direitos no LDAP.



Ao mesmo tempo, é claro, não quero que usuários externos da API se importem com isso de alguma forma. Existe um recurso REST /users/{user_id}e gostaria de trabalhar com ele sem pensar em como os dados são armazenados nele. Se eles estiverem armazenados em fontes diferentes, será mais difícil alterar o usuário ou obter uma lista de dados.



De modo geral, ORM! = Modelo de Domínio!





E quanto mais o mundo real difere da suposição de “uma tabela no banco de dados - uma entidade do domínio”, mais problemas com o padrão Active Record.


Acontece que toda vez que você escreve a lógica de negócios, deve se lembrar de como a essência do domínio é armazenada.



Os métodos ORM são o nível mais baixo de abstração. Eles não suportam quaisquer limitações da área de assunto, o que significa que dão a oportunidade de cometer erros. Eles também escondem do usuário quais consultas são realmente feitas no banco de dados, o que leva a consultas longas e ineficientes. O clássico, quando as consultas são feitas em loops, em vez de uma junção ou filtro.



E o que mais, além da construção de consultas (a capacidade de construir consultas), o ORM nos oferece? Deixa pra lá. Capacidade de mover para um novo banco de dados? E quem em sã consciência e memória firme mudou para um novo banco de dados e ORM o ajudou nisso? Se você perceber isso não como uma tentativa de mapear o modelo de domínio (!) No banco de dados, mas como uma biblioteca simples que permite fazer consultas ao banco de dados de forma conveniente, então tudo se encaixa.



E mesmo que seja usado nos nomes das classes Model, e nos nomes dos arquivos - models, eles não se tornam modelos. Não se iluda. É apenas uma descrição dos rótulos Eles não vão ajudar a encapsular nada.



Mas se tudo está tão ruim, o que fazer? Padrões de arquiteturas em camadas vêm ao resgate.



Arquitetura em camadas contra-ataca!



A ideia por trás das arquiteturas em camadas é simples: separamos a lógica de negócios, a lógica de armazenamento e a lógica de uso.



Parece completamente lógico separar o armazenamento da mudança de estado. Essa. faça uma camada separada que pode receber e salvar dados do armazenamento "abstrato".



Deixamos toda a lógica de armazenamento, por exemplo, na classe de armazenamento Repository. E os controladores (ou camada de serviço) usam-no apenas para obter e salvar entidades. Então poderemos mudar a lógica de armazenar e receber como quisermos, e este será um lugar! E quando escrevemos o código do cliente, podemos ter certeza de que não esquecemos de mais um lugar no qual precisamos salvar ou de onde devemos retirá-lo, e não repetimos o mesmo código um monte de vezes.





Não importa para nós se a entidade consiste em registros em tabelas ou microsserviços diferentes. Ou se entidades com comportamento diferente dependendo do tipo são armazenadas em uma tabela.



Mas essa divisão de responsabilidades não é gratuita . Deve ser entendido que camadas adicionais de abstração são criadas para evitar mudanças de código “ruins”. Obviamente, ele Repositoryesconde o fato de que o objeto está armazenado no banco de dados SQL, portanto, devemos tentar não deixar o SQLismo sair dos limites Repository. E todas as solicitações, mesmo as mais simples e óbvias, terão que ser arrastadas pela camada de armazenamento.



Por exemplo, se for necessário obter um escritório por nome e departamento, você terá que escrever assim:



#     
interface OfficeRepository: CrudRepository<OfficeEntity, Long> {
    @Query("select o from OfficeEntity o " +
            "where o.number = :office and o.branch.number = :branch")
    fun getOffice(@Param("branch") branch: String,
                  @Param("office") office: String): OfficeEntity?
 ...


E no caso do Active Record, tudo é muito mais simples:



Office.objects.get(name=’Name’, branch=’Branch’)


Não é tão simples, mesmo que a entidade comercial esteja realmente armazenada de uma forma não trivial (em várias tabelas, em diferentes serviços, etc.). Para implementar isso bem (e corretamente) - para o qual este padrão foi criado - na maioria das vezes você tem que usar padrões como agregados, Unidade de trabalho e mapeadores de dados.



É difícil selecionar corretamente um agregado, observar corretamente todas as restrições impostas a ele e fazer o mapeamento dos dados corretamente. E apenas um desenvolvedor muito bom pode lidar com essa tarefa. Aquele que, no caso do Active Record, poderia fazer tudo "certo".



O que acontece com os desenvolvedores regulares? Aqueles que conhecem todos os padrões e estão firmemente convencidos de que se usarem uma arquitetura em camadas, seu código se tornará automaticamente sustentável e bom, ao contrário do Active Record. E eles criam repositórios CRUD para cada tabela. E eles funcionam no conceito de



uma placa - um repositório - uma entidade.



Não:



um repositório - um objeto de domínio.





Eles também acreditam cegamente que, se uma palavra for usada em uma classe Entity, ela reflete o modelo de domínio. Como uma palavra Modelno Active Record.



O resultado é uma camada de armazenamento mais complexa e menos flexível que possui todas as propriedades negativas dos mapeadores Active Record e Repositório / Dados.


Mas a arquitetura em camadas não termina aí. A camada de serviço também costuma ser diferenciada.



A implementação correta de tal camada de serviço também é uma tarefa difícil. E, por exemplo, desenvolvedores inexperientes criam uma camada de serviço, que é um serviço - proxy para repositórios ou ORM (DAO). Essa. os serviços são escritos de forma que não encapsulem a lógica de negócios:



#      
@Service
class AccountServiceImpl(val accountDaoService: AccountDaoService) : AccountService {
    override fun saveAccount(account: Account) =
            accountDaoService.saveAccount(convertClass(account, AccountEntity::class.java))

    override fun deleteAccount(id: Long) =
            accountDaoService.deleteAccount(id)


E há uma combinação de desvantagens tanto do Active Record quanto da camada de serviço.



Como resultado, em estruturas Java em camadas e código escrito por jovens e inexperientes amantes de padrões, o número de abstrações por unidade de lógica de negócios começa a exceder todos os limites razoáveis.





Existem camadas, mas são todas triviais e são apenas camadas para chamar a próxima camada.



A presença de padrões OOP no framework não garante sua aplicação correta e adequada.



Não há bala de prata



É bastante claro que não existe bala de prata. Soluções complexas são para problemas complexos e soluções simples são para problemas simples.



E não existem padrões bons e ruins. Em uma situação, o Active Record é bom, em outras, a arquitetura em camadas. E sim, para a grande maioria dos aplicativos de pequeno e médio porte, o Active Record funciona razoavelmente bem. E para a grande maioria dos aplicativos de pequeno e médio porte, a arquitetura em camadas (a la Spring) tem desempenho pior. E exatamente o oposto para aplicativos complexos de lógica e serviços da web.



Quanto mais simples o aplicativo ou serviço, menos camadas de abstração você precisa.



Em microsserviços, onde não há muita lógica de negócios, geralmente é inútil usar arquiteturas em camadas. Scripts transacionais comuns - scripts no controlador - podem ser perfeitamente adequados para a tarefa em questão.



Na verdade, um bom desenvolvedor difere de um mau porque não apenas conhece os padrões, mas também entende quando aplicá-los.



All Articles