O DDD atende ao OOP: como implementar um repositório orientado a objetos?

12

Uma implementação típica de um repositório DDD não parece muito OO, por exemplo, um save()método:

package com.example.domain;

public class Product {  /* public attributes for brevity */
    public String name;
    public Double price;
}

public interface ProductRepo {
    void save(Product product);
} 

Parte da infraestrutura:

package com.example.infrastructure;
// imports...

public class JdbcProductRepo implements ProductRepo {
    private JdbcTemplate = ...

    public void save(Product product) {
        JdbcTemplate.update("INSERT INTO product (name, price) VALUES (?, ?)", 
            product.name, product.price);
    }
} 

Essa interface espera Productque seja um modelo anêmico, pelo menos com getters.

Por outro lado, o OOP diz que um Productobjeto deve saber como se salvar.

package com.example.domain;

public class Product {
    private String name;
    private Double price;

    void save() {
        // save the product
        // ???
    }
}

O problema é que, quando ele Productsabe como se salvar, significa que o código da infraestrutura não é separado do código do domínio.

Talvez possamos delegar a gravação em outro objeto:

package com.example.domain;

public class Product {
    private String name;
    private Double price;

    void save(Storage storage) {
        storage
            .with("name", this.name)
            .with("price", this.price)
            .save();
    }
}

public interface Storage {
    Storage with(String name, Object value);
    void save();
}

Parte da infraestrutura:

package com.example.infrastructure;
// imports...

public class JdbcProductRepo implements ProductRepo {        
    public void save(Product product) {
        product.save(new JdbcStorage());
    }
}

class JdbcStorage implements Storage {
    private final JdbcTemplate = ...
    private final Map<String, Object> attrs = new HashMap<>();

    private final String tableName;

    public JdbcStorage(String tableName) {
        this.tableName = tableName;
    }

    public Storage with(String name, Object value) {
        attrs.put(name, value);
    }
    public void save() {
        JdbcTemplate.update("INSERT INTO " + tableName + " (name, price) VALUES (?, ?)", 
            attrs.get("name"), attrs.get("price"));
    }
}

Qual é a melhor abordagem para conseguir isso? É possível implementar um repositório orientado a objetos?

ttulka
fonte
6
OOP diz que um objeto Product deve saber como se salvar - não tenho certeza se isso está realmente correto ... OOP em si não dita isso, é mais um problema de design / padrão (que é onde DDD / o que você quiser) -Use vem em)
jleach
1
Lembre-se de que, no contexto de POO, está falando sobre objetos. Apenas objetos, não persistência de dados. Sua declaração indica que o estado de um objeto não deve ser gerenciado fora de si, o que eu concordo. Um repositório é responsável por carregar / salvar de alguma camada de persistência (que está fora do domínio do OOP). As propriedades e métodos da classe devem manter sua própria integridade, sim, mas isso não significa que outro objeto não possa ser responsável pela persistência do estado. E, getters e setters devem garantir a integridade dos dados de entrada / saída do objeto.
jleach
1
"isso não significa que outro objeto não possa ser responsável pela persistência do estado." Eu não disse isso. A afirmação importante é que um objeto deve estar ativo . Isso significa que o objeto (e ninguém mais) pode delegar essa operação para outro objeto, mas não o contrário: nenhum objeto deve apenas coletar informações de um objeto passivo para processar sua própria operação egoísta (como um repositório faria com getters) . Tentei implementar essa abordagem nos trechos acima.
ttulka
1
@jleach Você está certo, nossa compreensão do OOP é diferente, para mim, getters + setters não são OOP, caso contrário, minha pergunta não fazia sentido. Obrigado mesmo assim! :-)
ttulka
1
Aqui está um artigo sobre o meu argumento: martinfowler.com/bliki/AnemicDomainModel.html Não conheço o modelo anêmico em todos os casos, por exemplo, é uma boa estratégia para a programação funcional. Apenas não OOP.
ttulka 9/02/19

Respostas:

7

Você escreveu

Por outro lado, o OOP diz que um objeto Produto deve saber como se salvar

e em um comentário.

... deve ser responsável por toda a operação realizada com ele

Este é um mal-entendido comum. Producté um objeto de domínio, por isso deve ser responsável pelas operações de domínio que envolvem um único objeto de produto, nem menos, nem mais - então definitivamente não é para todas as operações. Normalmente, a persistência não é vista como uma operação de domínio. Muito pelo contrário, em aplicativos corporativos, não é incomum tentar obter ignorância da persistência no modelo de domínio (pelo menos até certo ponto), e manter a mecânica da persistência em uma classe de repositório separada é uma solução popular para isso. "DDD" é uma técnica que visa esse tipo de aplicação.

Então, o que poderia ser uma operação de domínio sensata para a Product? Isso depende, na verdade, do contexto do domínio do sistema de aplicativos. Se o sistema é pequeno e suporta apenas operações CRUD exclusivamente, então, de fato, a Productpode permanecer bastante "anêmico", como no seu exemplo. Para esse tipo de aplicativo, pode ser discutível se colocar as operações do banco de dados em uma classe de repo separada ou usar DDD, vale a pena.

No entanto, assim que seu aplicativo suporta operações de negócios reais, como comprar ou vender produtos, mantê-los em estoque e gerenciá-los ou calcular impostos para eles, é bastante comum que você comece a descobrir operações que podem ser colocadas de maneira sensata em uma Productclasse. Por exemplo, pode haver uma operação CalcTotalPrice(int noOfItems)que calcule o preço de `n itens de um determinado produto ao considerar os descontos por volume.

Portanto, quando você cria classes, precisa pensar em seu contexto, em qual dos cinco mundos de Joel Spolsky você está e se o sistema contém lógica de domínio suficiente para que o DDD seja benéfico. Se a resposta for sim, é bastante improvável que você acabe com um modelo anêmico apenas porque mantém a mecânica de persistência fora das classes de domínio.

Doc Brown
fonte
Seu ponto de vista parece muito sensato para mim. Portanto, o produto se torna uma estrutura de dados anêmica ao atravessar uma borda de um contexto de estruturas de dados anêmicas (banco de dados) e o repositório é um gateway. Mas isso ainda significa que eu tenho que fornecer acesso à estrutura interna do objeto via getter e setters, que então se tornam parte de sua API e podem ser facilmente mal utilizados por outro código, que não tem nada a ver com persistência. Existe uma boa prática como evitar isso? Obrigado!
ttulka 9/02/19
"Mas isso ainda significa que eu tenho que fornecer acesso à estrutura interna do objeto via getter e setters" - improvável. O estado interno de um objeto de domínio ignorante à persistência geralmente é fornecido exclusivamente por um conjunto de atributos relacionados ao domínio. Para esses atributos, getters e setters (ou uma inicialização do construtor) devem existir, caso contrário, nenhuma operação de domínio "interessante" seria possível. Em várias estruturas, também existem recursos de persistência disponíveis que permitem persistir atributos privados por reflexão; portanto, o encapsulamento é quebrado apenas para esse mecanismo, não para "outro código".
Doc Brown
1
Concordo que a persistência geralmente não faz parte das operações do domínio, mas deve fazer parte das operações de domínio "reais" dentro do objeto que precisa. Por exemplo, Account.transfer(amount)deve persistir a transferência. Como isso é responsabilidade do objeto, não de alguma entidade externa. A exibição do objeto, por outro lado, geralmente é uma operação de domínio! Os requisitos geralmente descrevem em grande detalhe como as coisas devem parecer. Faz parte do idioma entre os membros do projeto, negócios ou outros.
Robert Bräutigam
@ RobertBräutigam: o clássico Account.transfergeralmente envolve dois objetos de conta e uma unidade de objeto de trabalho. A operação persistente transacional pode fazer parte desta última (juntamente com chamadas para repos relacionados), portanto, fica fora do método "transfer". Dessa forma, Accountpode permanecer ignorante a persistência. Não estou dizendo que isso seja necessariamente melhor do que sua suposta solução, mas a sua também é apenas uma das várias abordagens possíveis.
Doc Brown
1
@ RobertBräutigam Tenho certeza de que você está pensando demais na relação entre o objeto e a mesa. Pense no objeto como tendo um estado para si mesmo, tudo na memória. Depois de fazer as transferências nos objetos da sua conta, você ficará com objetos com novo estado. É isso que você gostaria de persistir e, felizmente, os objetos da conta fornecem uma maneira de informar sobre o estado deles. Isso não significa que o estado deles deva ser igual às tabelas no banco de dados - ou seja, a quantia transferida pode ser um objeto monetário contendo a quantia bruta e a moeda.
Steve Chamaillard
5

Pratique a teoria dos trunfos.

A experiência nos ensina que Product.Save () leva a muitos problemas. Para contornar esses problemas, inventamos o padrão de repositório.

Claro que quebra a regra OOP de ocultar os dados do produto. Mas funciona bem.

É muito mais difícil criar um conjunto de regras consistentes que cubram tudo do que criar algumas boas regras gerais com exceções.

Ewan
fonte
3

DDD encontra OOP

É bom lembrar que não há a intenção de haver tensão entre essas duas idéias - objetos de valor, agregados, repositórios são uma série de padrões usados, o que alguns consideram ser OOP feito corretamente.

Por outro lado, o OOP diz que um objeto Produto deve saber como se salvar.

Não tão. Os objetos encapsulam suas próprias estruturas de dados. Sua representação na memória de um Produto é responsável por exibir comportamentos do produto (sejam eles quais forem); mas o armazenamento persistente está lá (atrás do repositório) e tem seu próprio trabalho a fazer.

É necessário que haja alguma maneira de copiar dados entre a representação em memória do banco de dados e sua lembrança persistente. No limite , as coisas tendem a ficar bem primitivas.

Fundamentalmente, os bancos de dados somente de gravação não são particularmente úteis e seus equivalentes na memória não são mais úteis que a classificação "persistente". Não há sentido em colocar informações em um Productobjeto se você nunca vai tirar essas informações. Você não usará necessariamente "getters" - não está tentando compartilhar a estrutura de dados do produto e certamente não deve compartilhar acesso mutável à representação interna do Produto.

Talvez possamos delegar a gravação em outro objeto:

Isso certamente funciona - seu armazenamento persistente se torna efetivamente um retorno de chamada. Eu provavelmente tornaria a interface mais simples:

interface ProductStorage {
    onProduct(String name, double price);
}

Não está indo a ser acoplamento entre a representação em memória e o mecanismo de armazenamento, porque a informação precisa para chegar daqui para lá (e vice-versa). Alterar as informações a serem compartilhadas afetará os dois lados da conversa. Então, podemos também deixar isso explícito onde pudermos.

Essa abordagem - passando dados por retornos de chamada, desempenhou um papel importante no desenvolvimento de zombarias no TDD .

Observe que passar as informações para o retorno de chamada tem as mesmas restrições que retornar as informações de uma consulta - você não deve passar cópias mutáveis ​​de suas estruturas de dados.

Essa abordagem é um pouco contrária ao que Evans descreveu no Blue Book, onde o retorno de dados por meio de uma consulta era a maneira normal de realizar as coisas, e os objetos de domínio foram projetados especificamente para evitar misturar "preocupações de persistência".

Eu entendo o DDD como uma técnica OOP e, portanto, quero entender completamente essa aparente contradição.

Uma coisa a ter em mente - o Blue Book foi escrito quinze anos atrás, quando o Java 1.4 vagava pela terra. Em particular, o livro é anterior aos genéricos de Java - temos muito mais técnicas disponíveis para nós agora do que quando Evans estava desenvolvendo suas idéias.

VoiceOfUnreason
fonte
2
Também vale a pena mencionar: "salvar a si mesmo" sempre exigiria interação com outros objetos (um objeto do sistema de arquivos, um banco de dados ou um serviço da Web remoto, alguns deles também podem exigir que uma sessão seja estabelecida para controle de acesso). Portanto, esse objeto não seria autônomo e independente. Portanto, o POO não pode exigir isso, pois sua intenção é encapsular o objeto e reduzir o acoplamento.
Christophe
Obrigado por uma ótima resposta. Primeiro, projetei a Storageinterface da mesma maneira que você, depois considerei o acoplamento alto e mudei. Mas você está certo, há um acoplamento inevitável de qualquer maneira, então por que não torná-lo mais explícito?
ttulka 9/02/19
1
"Essa abordagem é um pouco contrária ao que Evans descreveu no Blue Book" - então existe alguma tensão :-) Esse foi realmente o ponto da minha pergunta, eu entendo o DDD como uma técnica OOP e, portanto, quero entender completamente essa aparente contradição.
ttulka 9/02/19
1
Na minha experiência, cada uma dessas coisas (OOP em geral, DDD, TDD, escolha seu acrônimo) soa bem e por si só, mas sempre que se trata de implementação no "mundo real", sempre há alguma troca ou menos que o idealismo que deve ser para que funcione.
jleach
Não concordo com a noção de que a persistência (e a apresentação) sejam de alguma forma "especiais". Eles não são. Eles devem fazer parte da modelagem para estender a demanda de requisitos. Não é necessário que haja um limite artificial (baseado em dados) dentro do aplicativo, a menos que haja requisitos reais em contrário.
Robert Bräutigam
1

Muito boas observações, concordo plenamente com você. Aqui está uma palestra minha (correção: apenas slides) sobre exatamente esse assunto: Design Orientado a Domínio Orientado a Objetos .

Resposta curta: não. Não deve haver um objeto em seu aplicativo que seja puramente técnico e não tenha relevância no domínio. É como implementar a estrutura de log em um aplicativo de contabilidade.

Seu Storageexemplo de interface é excelente, assumindo que, Storageentão, é considerada alguma estrutura externa, mesmo se você a escrever.

Além disso, save()em um objeto só deve ser permitido se isso fizer parte do domínio (o "idioma"). Por exemplo, eu não deveria ser obrigado a "salvar" explicitamente Accountdepois que eu ligar transfer(amount). Eu deveria esperar, com razão, que a função comercial transfer()persistisse na minha transferência.

Em suma, acho que as idéias do DDD são boas. Usando linguagem onipresente, exercitando o domínio com conversas, contextos limitados, etc. Os blocos de construção, no entanto, precisam de uma revisão séria para serem compatíveis com a orientação a objetos. Veja o deck vinculado para detalhes.

Robert Bräutigam
fonte
A sua palestra está em algum lugar para assistir? (Eu vejo apenas slides no link). Obrigado!
ttulka 9/02/19
Eu só tenho uma gravação em alemão da palestra, aqui: javadevguy.wordpress.com/2018/11/26/…
Robert Bräutigam
Ótima conversa! (Felizmente eu falo alemão). Acho que vale a pena ler todo o seu blog ... Obrigado pelo seu trabalho!
ttulka
Controle deslizante muito perspicaz Robert. Achei isso muito ilustrativo, mas tive a sensação de que, no final, muitas das soluções direcionadas para não quebrar o encapsulamento e o LoD são baseadas em dar muitas responsabilidades ao objeto do domínio: impressão, serialização, formatação da interface do usuário etc. Isso aumenta o acoplamento entre o domínio e o técnico (detalhes da implementação)? Por exemplo, o AccountNumber juntamente com a API do Apache Wicket. Ou conta com qualquer objeto Json? Você acha que vale a pena ter um acoplamento?
LAIV
@Laiv A gramática da sua pergunta sugere que há algo errado com o uso da tecnologia para implementar funções de negócios? Vamos colocar desta maneira: não é o acoplamento entre domínio e tecnologia que é o problema, é o acoplamento entre diferentes níveis de abstração. Por exemplo, AccountNumber deve saber que pode ser representado como a TextField. Se outros (como uma "Visualização") souberem disso, isso é acoplamento que não deveria existir, porque esse componente precisaria saber o que AccountNumberconsiste, ou seja, os internos.
Robert Bräutigam
1

Talvez possamos delegar a gravação em outro objeto

Evite espalhar o conhecimento dos campos desnecessariamente. Quanto mais coisas soubermos sobre um campo individual, mais difícil será adicionar ou remover um campo:

public class Product {
    private String name;
    private Double price;

    void save(Storage storage) {
        storage.save( toString() );
    }
}

Aqui, o produto não faz ideia se você está salvando em um arquivo de log ou em um banco de dados ou em ambos. Aqui, o método save não faz ideia se você possui 4 ou 40 campos. Isso é fracamente acoplado. É uma coisa boa.

Claro que este é apenas um exemplo de como você pode alcançar esse objetivo. Se você não gosta de criar e analisar uma string para usar como seu DTO, também pode usar uma coleção. LinkedHashMapé um dos meus favoritos antigos, pois preserva a ordem e é toString () que fica bem em um arquivo de log.

Seja como for, não espalhe o conhecimento dos campos. Essa é uma forma de acoplamento que as pessoas geralmente ignoram até que seja tarde demais. Quero que o mínimo de coisas saiba estaticamente quantos campos meu objeto tem quanto possível. Dessa forma, adicionar um campo não envolve muitas edições em muitos lugares.

candied_orange
fonte
Na verdade, esse é o código que eu postei na minha pergunta, certo? Eu usei um Map, você propõe um Stringou um List. Mas, como o @VoiceOfUnreason mencionado em sua resposta, o acoplamento ainda está lá, mas não é explícito. Ainda é desnecessário conhecer a estrutura de dados do produto para salvá-lo em um banco de dados ou em um arquivo de log, pelo menos quando lidos novamente como um objeto.
ttulka 9/02/19
Eu mudei o método save, mas, caso contrário, sim, é o mesmo. A diferença é que o acoplamento não é mais estático, permitindo que novos campos sejam adicionados sem forçar uma alteração de código no sistema de armazenamento. Isso torna o sistema de armazenamento reutilizável em muitos produtos diferentes. Isso apenas o força a fazer coisas que parecem um pouco não naturais, como transformar um duplo em uma corda e voltar a um duplo. Mas isso também pode ser contornado se for realmente um problema.
Candied_orange 9/02/19
Veja a coleção hetrógena de
candied_orange
Mas, como eu disse, vejo o acoplamento ainda lá (analisando), apenas como não ser estático (explícito) traz a desvantagem de não poder ser verificado por um compilador e, portanto, mais propenso a erros. O Storagefaz parte do domínio (assim como a interface do repositório) e cria uma API de persistência. Quando alterado, é melhor informar os clientes no tempo de compilação, porque eles precisam reagir de qualquer maneira para não serem interrompidos no tempo de execução.
ttulka 9/02/19
Isso é um equívoco. O compilador não pode verificar um arquivo de log ou um banco de dados. Tudo o que verifica é se um arquivo de código é consistente com outro arquivo de código que também não garante a consistência com o arquivo de log ou o banco de dados.
Candied_orange 9/02/19
0

Existe uma alternativa aos padrões já mencionados. O padrão Memento é ótimo para encapsular o estado interno de um objeto de domínio. O objeto memento representa uma captura instantânea do estado público do objeto de domínio. O objeto de domínio sabe como criar esse estado público a partir do seu estado interno e vice-versa. Um repositório então trabalha apenas com a representação pública do estado. Com isso, a implementação interna é dissociada de quaisquer detalhes de persistência e apenas precisa manter o contrato público. Além disso, seu objeto de domínio não deve expor nenhum getter que de fato o tornaria um pouco anêmico.

Para mais informações sobre este tópico, recomendo o grande livro: "Padrões, Princípios e Práticas de Design Orientado a Domínios", de Scott Millett e Nick Tune

Roman Weis
fonte