DDD cumple con OOP: ¿Cómo implementar un repositorio orientado a objetos?

12

Una implementación típica de un repositorio DDD no se ve muy OO, por ejemplo, un 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 de infraestructura:

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);
    }
} 

Dicha interfaz espera que Producta sea un modelo anémico, al menos con getters.

Por otro lado, OOP dice que un Productobjeto debe saber cómo salvarse.

package com.example.domain;

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

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

La cuestión es que, cuando Productsabe cómo salvarse, significa que el código de infraestructura no está separado del código de dominio.

Tal vez podamos delegar el guardado a otro 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 de infraestructura:

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"));
    }
}

¿Cuál es el mejor enfoque para lograr esto? ¿Es posible implementar un repositorio orientado a objetos?

ttulka
fuente
66
OOP dice que un objeto Product debería saber cómo salvarse a sí mismo : no estoy seguro de que sea correcto realmente ... OOP en sí mismo realmente no dicta eso, es más un problema de diseño / patrón (que es donde DDD / lo que sea) -uso entra)
jleach
1
Recuerde que en el contexto de OOP, se trata de objetos. Solo objetos, no persistencia de datos. Su declaración indica que el estado de un objeto no debe administrarse fuera de sí mismo, con lo que estoy de acuerdo. Un repositorio es responsable de cargar / guardar desde alguna capa de persistencia (que está fuera del ámbito de OOP). Las propiedades y métodos de la clase deben mantener su propia integridad, sí, pero esto no significa que otro objeto no pueda ser responsable de persistir en el estado. Y los captadores y establecedores deben garantizar la integridad de los datos entrantes / salientes del objeto.
jleach
1
"Esto no significa que otro objeto no pueda ser responsable de persistir en el estado". No dije eso. La afirmación importante es que un objeto debe estar activo . Significa que el objeto (y nadie más) puede delegar esta operación a otro objeto, pero no al revés: ningún objeto solo debe recopilar información de un objeto pasivo para procesar su propia operación egoísta (como lo haría un repositorio con getters) . Traté de implementar este enfoque en los fragmentos anteriores.
ttulka
1
@jleach Tienes razón, nuestra comprensión de OOP es diferente, para mí getters + setters no son OOP en absoluto, de lo contrario mi pregunta no tenía sentido. ¡Gracias de todos modos! :-)
ttulka
1
Aquí hay un artículo sobre mi punto: martinfowler.com/bliki/AnemicDomainModel.html No estoy contra el modelo anémico en todos los casos, por ejemplo, es una buena estrategia para la programación funcional. Simplemente no OOP.
ttulka

Respuestas:

7

Tu escribiste

Por otro lado, OOP dice que un objeto Producto debería saber cómo salvarse

y en un comentario

... debe ser responsable de todas las operaciones que se realicen con él.

Este es un malentendido común. Productes un objeto de dominio, por lo que debe ser responsable de las operaciones de dominio que involucran un solo objeto de producto, ni menos, ni más, por lo que definitivamente no es para todas las operaciones. Por lo general, la persistencia no se ve como una operación de dominio. Todo lo contrario, en aplicaciones empresariales, no es raro tratar de lograr la ignorancia de persistencia en el modelo de dominio (al menos hasta cierto punto), y mantener la mecánica de persistencia en una clase de repositorio separada es una solución popular para esto. "DDD" es una técnica que apunta a este tipo de aplicaciones.

Entonces, ¿qué podría ser una operación de dominio sensible para a Product? Esto depende en realidad del contexto de dominio del sistema de aplicación. Si el sistema es pequeño y solo admite operaciones CRUD exclusivamente, entonces, de hecho, Productpuede permanecer bastante "anémico" como en su ejemplo. Para este tipo de aplicaciones, puede ser discutible si vale la pena poner las operaciones de la base de datos en una clase de repositorio separada, o usar DDD.

Sin embargo, tan pronto como su aplicación admita operaciones comerciales reales, como comprar o vender productos, mantenerlos en existencia y administrarlos, o calcular impuestos para ellos, es bastante común que comience a descubrir operaciones que pueden ubicarse de manera sensata en una Productclase. Por ejemplo, podría haber una operación CalcTotalPrice(int noOfItems)que calcule el precio de `n artículos de un determinado producto cuando se tienen en cuenta los descuentos por volumen.

En resumen, cuando diseñas clases, debes pensar en tu contexto, en cuál de los cinco mundos de Joel Spolsky te encuentras y si el sistema contiene suficiente lógica de dominio, por lo que DDD será beneficioso. Si la respuesta es sí, es bastante improbable que termine con un modelo anémico solo porque mantiene la mecánica de persistencia fuera de las clases de dominio.

Doc Brown
fuente
Tu punto me suena muy sensato. Por lo tanto, el producto se convierte en una estructura de datos anémicos al cruzar un borde de un contexto de estructuras de datos anémicos (base de datos) y el repositorio es una puerta de enlace. Pero esto todavía significa que tengo que proporcionar acceso a la estructura interna del objeto a través de getter y setters, que luego se convierten en parte de su API y podrían ser mal utilizados por otro código, que no tiene nada que ver con la persistencia. ¿Existe una buena práctica para evitar esto? ¡Gracias!
ttulka
"Pero esto todavía significa que tengo que proporcionar acceso a la estructura interna del objeto a través de getter y setters" - poco probable. El estado interno de un objeto de dominio ignorante de persistencia generalmente viene dado exclusivamente por un conjunto de atributos relacionados con el dominio. Para estos atributos, deben existir getters y setters (o una inicialización de constructor), de lo contrario no sería posible una operación de dominio "interesante". En varios marcos, también hay características de persistencia disponibles que permiten persistir atributos privados por reflexión, por lo que la encapsulación solo se rompe para este mecanismo, no para "otro código".
Doc Brown
1
Estoy de acuerdo en que la persistencia generalmente no es parte de las operaciones de dominio, sin embargo, debería ser parte de las operaciones de dominio "reales" dentro del objeto que lo necesita. Por ejemplo, Account.transfer(amount)debe persistir la transferencia. Cómo lo hace es responsabilidad del objeto, no de alguna entidad externa. ¡Mostrar el objeto por otro lado suele ser una operación de dominio! Los requisitos generalmente describen con gran detalle cómo deberían verse las cosas. Es parte del lenguaje entre los miembros del proyecto, negocios u otros.
Robert Bräutigam
@ RobertBräutigam: el clásico Account.transferpara generalmente involucra dos objetos de cuenta y una unidad de objeto de trabajo. La operación persistente transaccional podría ser parte de esta última (junto con las llamadas a repositorios relacionados), por lo que queda fuera del método de "transferencia". De esa manera, Accountpuede permanecer ignorante de la persistencia. No digo que esto sea necesariamente mejor que su supuesta solución, pero la suya también es solo uno de los varios enfoques posibles.
Doc Brown
1
@ RobertBräutigam Estoy bastante seguro de que estás pensando demasiado en la relación entre el objeto y la mesa. Piensa que el objeto tiene un estado para sí mismo, todo en la memoria. Después de hacer las transferencias en los objetos de su cuenta, quedaría con objetos con un nuevo estado. Eso es lo que le gustaría persistir, y afortunadamente los objetos de la cuenta proporcionan una manera de informarle sobre su estado. Eso no significa que su estado tenga que ser igual a las tablas de la base de datos, es decir, la cantidad transferida podría ser un objeto de dinero que contenga la cantidad bruta y la moneda.
Steve Chamaillard
5

La práctica triunfa sobre la teoría.

La experiencia nos enseña que Product.Save () genera muchos problemas. Para sortear esos problemas, inventamos el patrón de repositorio.

Seguro que rompe la regla de OOP de ocultar los datos del producto. Pero funciona bien.

Es mucho más difícil hacer un conjunto de reglas consistentes que cubran todo lo que es hacer algunas buenas reglas generales que tengan excepciones.

Ewan
fuente
3

DDD cumple con OOP

Es útil tener en cuenta que no se pretende que haya tensión entre estas dos ideas: los objetos de valor, los agregados, los repositorios son una serie de patrones utilizados, lo que algunos consideran que es POO bien hecho.

Por otro lado, OOP dice que un objeto Producto debería saber cómo salvarse.

No tan. Los objetos encapsulan sus propias estructuras de datos. Su representación en memoria de un Producto es responsable de exhibir comportamientos del producto (cualesquiera que sean); pero el almacenamiento persistente está allí (detrás del repositorio) y tiene su propio trabajo que hacer.

Es necesario que haya alguna forma de copiar datos entre la representación en memoria de la base de datos y su recuerdo persistente. En el límite , las cosas tienden a ponerse bastante primitivas.

Básicamente, las bases de datos de solo escritura no son particularmente útiles, y sus equivalentes en memoria no son más útiles que el tipo "persistente". No tiene sentido poner información en un Productobjeto si nunca vas a sacar esa información. No utilizará necesariamente "captadores": no está tratando de compartir la estructura de datos del producto y ciertamente no debería compartir el acceso mutable a la representación interna del Producto.

Tal vez podamos delegar el guardado a otro objeto:

Eso ciertamente funciona: su almacenamiento persistente se convierte efectivamente en una devolución de llamada. Probablemente haría la interfaz más simple:

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

No se va a acoplar entre la representación en la memoria y el mecanismo de almacenamiento, ya que la información tiene que llegar desde aquí hasta allí (y viceversa). Cambiar la información que se va a compartir tendrá un impacto en ambos extremos de la conversación. Así que bien podríamos hacer eso explícito donde podamos.

Este enfoque, pasar datos a través de devoluciones de llamada, jugó un papel importante en el desarrollo de simulacros en TDD .

Tenga en cuenta que pasar la información a la devolución de llamada tiene las mismas restricciones que devolver la información de una consulta: no debe pasar copias mutables de sus estructuras de datos.

Este enfoque es un poco contrario a lo que Evans describió en el Libro Azul, donde la devolución de datos a través de una consulta era la forma normal de hacer las cosas, y los objetos de dominio fueron diseñados específicamente para evitar la mezcla de "preocupaciones de persistencia".

Entiendo DDD como una técnica OOP y, por lo tanto, quiero comprender completamente esa aparente contradicción.

Una cosa a tener en cuenta: el Libro Azul se escribió hace quince años, cuando Java 1.4 vagaba por la tierra. En particular, el libro es anterior a los genéricos de Java : tenemos muchas más técnicas disponibles ahora que cuando Evans estaba desarrollando sus ideas.

VoiceOfUnreason
fuente
2
También vale la pena mencionar: "salvarse a sí mismo" siempre requeriría interacción con otros objetos (ya sea un objeto del sistema de archivos, o una base de datos, o un servicio web remoto, algunos de estos podrían requerir además que se establezca una sesión para el control de acceso). Por lo tanto, dicho objeto no sería autónomo e independiente. Por lo tanto, OOP no puede requerir esto, ya que su intención es encapsular el objeto y reducir el acoplamiento.
Christophe
Gracias por una gran respuesta Primero, diseñé la Storageinterfaz de la misma manera que lo hizo, luego consideré un alto acoplamiento y lo cambié. Pero tienes razón, de todos modos hay un acoplamiento inevitable, así que ¿por qué no hacerlo más explícito?
ttulka
1
"Este enfoque es un poco contrario a lo que Evans describió en el Libro Azul" , por lo que hay cierta tensión después de todo :-) Ese fue realmente el punto de mi pregunta, entiendo DDD como una técnica OOP y por eso quiero Entiendo completamente esa aparente contradicción.
ttulka
1
En mi experiencia, cada una de estas cosas (OOP en general, DDD, TDD, pick-your-acronym) suena bien y por sí misma, pero cada vez que se trata de implementación en el "mundo real", siempre hay algo de compromiso o menos que idealismo que debe ser para que funcione.
jleach
No estoy de acuerdo con la idea de que la persistencia (y la presentación) son de alguna manera "especiales". Ellos no son. Deben ser parte del modelado para extender la demanda de requisitos. No es necesario que haya un límite artificial (basado en datos) dentro de la aplicación, a menos que existan requisitos reales en contrario.
Robert Bräutigam
1

Muy buenas observaciones, estoy completamente de acuerdo contigo en ellas. Aquí hay una charla mía (corrección: solo diapositivas) sobre exactamente este tema: Diseño orientado a dominio orientado a objetos .

Respuesta corta: no. No debe haber un objeto en su aplicación que sea puramente técnico y que no tenga relevancia de dominio. Eso es como implementar el marco de registro en una aplicación de contabilidad.

Su Storageejemplo de interfaz es excelente, suponiendo que Storagese considere un marco externo, incluso si lo escribe.

Además, save()en un objeto solo debe permitirse si es parte del dominio (el "idioma"). Por ejemplo, no se me debería pedir que "guarde" explícitamente una Accountvez que llame transfer(amount). Con razón debería esperar que la función comercial transfer()persista en mi transferencia.

En general, creo que las ideas de DDD son buenas. Uso de un lenguaje ubicuo, ejercicio del dominio con conversación, contextos limitados, etc. Sin embargo, los bloques de construcción necesitan una revisión seria para ser compatibles con la orientación a objetos. Vea el mazo vinculado para más detalles.

Robert Bräutigam
fuente
¿Tu charla es un lugar para mirar? (Veo que solo hay diapositivas debajo del enlace). ¡Gracias!
ttulka
Solo tengo una grabación alemana de la charla, aquí: javadevguy.wordpress.com/2018/11/26/…
Robert Bräutigam
Gran charla! (Afortunadamente hablo alemán). Creo que vale la pena leer todo tu blog ... ¡Gracias por tu trabajo!
ttulka
Control deslizante muy perspicaz Robert. Lo encontré muy ilustrativo, pero tuve la sensación de que al final, muchas de las soluciones dirigidas a no romper la encapsulación y la LoD se basan en dar muchas responsabilidades al objeto de dominio: impresión, serialización, formateo de la interfaz de usuario, etc. ¿Eso aumenta el acoplamiento entre el dominio y lo técnico (detalles de implementación)? Por ejemplo, AccountNumber junto con Apache Wicket API. ¿O cuenta con cualquier objeto Json? ¿Crees que es un acoplamiento que vale la pena tener?
Laiv
@Laiv ¿La gramática de su pregunta sugiere que hay algo mal con el uso de la tecnología para implementar funciones comerciales? Digámoslo de esta manera: no es el acoplamiento entre dominio y tecnología el problema, es el acoplamiento entre diferentes niveles de abstracción. Por ejemplo, AccountNumber debe saber que se puede representar como a TextField. Si otros (como una "vista") sabrían esto, que es el acoplamiento que no debería existir, ya que dicho elemento tendría que saber qué AccountNumberconsiste, es decir, las partes internas.
Robert Bräutigam
1

Tal vez podamos delegar el guardado a otro objeto

Evite difundir el conocimiento de los campos innecesariamente. Mientras más cosas sepa sobre un campo individual, más difícil será agregar o quitar un campo:

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

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

Aquí el producto no tiene idea si está guardando en un archivo de registro o una base de datos o ambos. Aquí el método de guardar no tiene idea si tiene 4 o 40 campos. Eso está vagamente acoplado. Eso es bueno.

Por supuesto, este es solo un ejemplo de cómo puede lograr este objetivo. Si no le gusta construir y analizar una cadena para usar como DTO, también puede usar una colección. LinkedHashMapes un antiguo favorito mío ya que conserva el orden y toString () se ve bien en un archivo de registro.

Como sea que lo haga, no difunda el conocimiento de los campos. Esta es una forma de acoplamiento que la gente a menudo ignora hasta que es demasiado tarde. Quiero que pocas cosas sepan estáticamente cuántos campos tiene mi objeto como sea posible. De esa manera, agregar un campo no implica muchas ediciones en muchos lugares.

naranja confitada
fuente
De hecho, este es el código que publiqué en mi pregunta, ¿verdad? Solía ​​un Map, tú propones un Stringo un List. Pero, como @VoiceOfUnreason mencionó en su respuesta, el acoplamiento sigue ahí, pero no es explícito. Todavía no es necesario conocer la estructura de datos del producto para guardarlo tanto en una base de datos como en un archivo de registro, al menos cuando se vuelve a leer como un objeto.
ttulka
Cambié el método de guardar, pero por lo demás sí, es muy parecido. La diferencia es que el acoplamiento ya no es estático, lo que permite agregar nuevos campos sin forzar un cambio de código en el sistema de almacenamiento. Eso hace que el sistema de almacenamiento sea reutilizable en muchos productos diferentes. Simplemente te obliga a hacer cosas que parecen un poco antinaturales, como convertir un doble en una cuerda y volver a ser un doble. Pero eso también se puede solucionar si realmente es un problema.
candied_orange
Vea la colección heterogénea de
candied_orange
Pero como dije, veo que el acoplamiento todavía está allí (al analizar), solo porque no ser estático (explícito) trae la desventaja de que un compilador no puede verificarlo y, por lo tanto, es más propenso a errores. El Storagees una parte del dominio (al igual que la interfaz del repositorio) y hace una API de persistencia. Cuando se cambia, es mejor informar a los clientes en tiempo de compilación, ya que de todos modos tienen que reaccionar para no romperse en el tiempo de ejecución.
ttulka
Eso es un error. El compilador no puede verificar un archivo de registro o una base de datos. Todo lo que está comprobando es si un archivo de código es coherente con otro archivo de código que tampoco garantiza que sea coherente con el archivo de registro o la base de datos.
candied_orange
0

Hay una alternativa a los patrones ya mencionados. El patrón Memento es ideal para encapsular el estado interno de un objeto de dominio. El objeto de recuerdo representa una instantánea del estado público del objeto de dominio. El objeto de dominio sabe cómo crear este estado público a partir de su estado interno y viceversa. Un repositorio solo funciona con la representación pública del estado. Con eso, la implementación interna se desacopla de los detalles de persistencia y solo tiene que mantener el contrato público. Además, su objeto de dominio no tiene que exponer ningún captador que realmente lo haga un poco anémico.

Para más información sobre este tema, recomiendo el gran libro: "Patrones, principios y prácticas del diseño impulsado por dominio" de Scott Millett y Nick Tune

Roman Weis
fuente