¿Estoy haciendo mis clases demasiado granulares? ¿Cómo debe aplicarse el principio de responsabilidad única?

9

Escribo mucho código que implica tres pasos básicos.

  1. Obtenga datos de alguna parte.
  2. Transforma esos datos.
  3. Ponga esos datos en alguna parte.

Normalmente termino usando tres tipos de clases, inspirados por sus respectivos patrones de diseño.

  1. Fábricas: para construir un objeto a partir de algún recurso.
  2. Mediadores: para usar la fábrica, realizar la transformación y luego usar el comandante.
  3. Comandantes: para poner esos datos en otro lugar.

Mis clases tienden a ser bastante pequeñas, a menudo un único método (público), por ejemplo, obtener datos, transformar datos, trabajar, guardar datos. Esto conduce a una proliferación de clases, pero en general funciona bien.

Cuando lucho es cuando vengo a las pruebas, termino con pruebas estrechamente acopladas. Por ejemplo;

  • Fábrica: lee archivos del disco.
  • Comandante: escribe archivos en el disco.

No puedo probar uno sin el otro. Podría escribir código de 'prueba' adicional para hacer lectura / escritura en disco también, pero luego me estoy repitiendo.

Mirando a .Net, la clase File adopta un enfoque diferente, combina las responsabilidades (de mi) fábrica y comandante juntos. Tiene funciones para Crear, Eliminar, Existe y Leer todo en un solo lugar.

¿Debería seguir el ejemplo de .Net y combinar, particularmente cuando se trata de recursos externos, mis clases juntas? El código aún se acopla, pero es más intencional: ocurre en la implementación original, en lugar de en las pruebas.

¿Es mi problema aquí que he aplicado el Principio de Responsabilidad Única de manera algo entusiasta? Tengo clases separadas responsables de leer y escribir. Cuando podría tener una clase combinada que es responsable de tratar con un recurso en particular, por ejemplo, el disco del sistema.

James Wood
fuente
66
Looking at .Net, the File class takes a different approach, it combines the responsibilities (of my) factory and commander together. It has functions for Create, Delete, Exists, and Read all in one place.- Tenga en cuenta que está combinando "responsabilidad" con "algo que hacer". Una responsabilidad es más como un "área de preocupación". La responsabilidad de la clase File es realizar operaciones de archivo.
Robert Harvey
1
Me parece que estás en buena forma. Todo lo que necesita es un mediador de prueba (o uno para cada tipo de conversión si le gusta más). El mediador de prueba puede leer archivos para verificar su corrección, utilizando la clase de archivo .net. No hay problema con eso desde una perspectiva SÓLIDA.
Martin Maat
1
Como lo mencionó @Robert Harvey, SRP tiene un nombre horrible porque no se trata realmente de responsabilidades. Se trata de "encapsular y abstraer un área de preocupación difícil o difícil que podría cambiar". Supongo que STDACMC fue demasiado largo. :-) Dicho esto, creo que su división en tres partes parece razonable.
user949300
1
Un punto importante en su Filebiblioteca de C # es que, por lo que sabemos, la Fileclase podría ser simplemente una fachada, colocando todas las operaciones de archivo en un solo lugar, dentro de la clase, pero podría estar usando internamente clases de lectura / escritura similares a las suyas, lo que en realidad contienen la lógica más complicada para el manejo de archivos. Dicha clase (the File) aún se adheriría al SRP, porque el proceso de trabajar realmente con el sistema de archivos se abstraería detrás de otra capa, muy probablemente con una interfaz unificadora. No digo que sea el caso, pero podría serlo. :)
Andy

Respuestas:

5

Seguir el principio de responsabilidad única puede haber sido lo que lo guió aquí, pero el lugar donde se encuentra tiene un nombre diferente.

Segmentación de responsabilidad de consulta de comando

Ve a estudiar eso y creo que lo encontrarás siguiendo un patrón familiar y que no estás solo preguntándote hasta dónde llegarás. La prueba de fuego es si seguir esto te está dando beneficios reales o si es solo un mantra ciego que sigues para que no tengas que pensar.

Has expresado preocupación por las pruebas. No creo que seguir CQRS impida escribir código comprobable. Simplemente podría estar siguiendo CQRS de una manera que haga que su código no sea verificable.

Ayuda a saber cómo usar el polimorfismo para invertir las dependencias del código fuente sin necesidad de cambiar el flujo de control. No estoy realmente seguro de dónde está su conjunto de habilidades en las pruebas de escritura.

Una advertencia: seguir los hábitos que encuentras en las bibliotecas no es óptimo. Las bibliotecas tienen sus propias necesidades y son francamente viejas. Entonces, incluso el mejor ejemplo es solo el mejor ejemplo de aquel entonces.

Esto no quiere decir que no haya ejemplos perfectamente válidos que no sigan CQRS. Seguirlo siempre será un poco doloroso. No siempre vale la pena pagar. Pero si lo necesita, se alegrará de haberlo usado.

Si lo usa, preste atención a esta palabra de advertencia:

En particular, CQRS solo debe usarse en partes específicas de un sistema (un BoundedContext en la jerga DDD) y no en el sistema en su conjunto. En esta forma de pensar, cada contexto limitado necesita sus propias decisiones sobre cómo debe modelarse.

Martin Flowler: CQRS

naranja confitada
fuente
Interesante no visto CQRS antes. El código es comprobable, se trata más de tratar de encontrar una mejor manera. Uso simulacros e inyección de dependencia cuando puedo (que creo que es a lo que te refieres).
James Wood
La primera vez que leí esto, identifiqué algo similar a través de mi aplicación: manejar búsquedas flexibles, campos múltiples filtrables / ordenables, (Java / JPA) es un dolor de cabeza y genera toneladas de código repetitivo, a menos que cree un motor de búsqueda básico que manejará estas cosas por usted (yo uso rsql-jpa). Aunque tengo el mismo modelo (digamos las mismas Entidades JPA para ambos), las búsquedas se extraen en un servicio genérico dedicado y la capa del modelo ya no tiene que manejarlo.
Walfrat
3

Necesita una perspectiva más amplia para determinar si el código se ajusta al Principio de responsabilidad única. No se puede responder simplemente analizando el código en sí, debe considerar qué fuerzas o actores podrían hacer que los requisitos cambien en el futuro.

Digamos que almacena los datos de la aplicación en un archivo XML. ¿Qué factores podrían hacer que cambie el código relacionado con la lectura o la escritura? Algunas posibilidades:

  • El modelo de datos de la aplicación podría cambiar cuando se agreguen nuevas características a la aplicación.
  • Se podrían agregar al modelo nuevos tipos de datos, por ejemplo, imágenes.
  • El formato de almacenamiento podría cambiar independientemente de la lógica de la aplicación: digamos de XML a JSON o a un formato binario, debido a problemas de interoperabilidad o rendimiento.

En todos estos casos, deberá cambiar tanto la lógica de lectura como la de escritura. En otras palabras, son no responsabilidades separadas.

Pero imaginemos un escenario diferente: su aplicación es parte de una tubería de procesamiento de datos. Lee algunos archivos CSV generados por un sistema separado, realiza algunos análisis y procesamiento y luego genera un archivo diferente para que sea procesado por un tercer sistema. En este caso, la lectura y la escritura son responsabilidades independientes y deben desacoplarse.

En pocas palabras: en general, no puede decir si leer y escribir archivos son responsabilidades separadas, depende de los roles en la aplicación. Pero según su sugerencia sobre las pruebas, supongo que es una responsabilidad única en su caso.

JacquesB
fuente
2

Generalmente tienes la idea correcta.

Obtenga datos de alguna parte. Transforma esos datos. Ponga esos datos en alguna parte.

Parece que tienes tres responsabilidades. OMI, el "Mediador" puede estar haciendo demasiado. Creo que deberías comenzar modelando tus tres responsabilidades:

interface Reader[T] {
    def read(): T
}

interface Transformer[T, U] {
    def transform(t: T): U
}

interface Writer[T] {
    def write(t: T): void
}

Entonces un programa puede expresarse como:

def program[T, U](reader: Reader[T], 
                  transformer: Transformer[T, U], 
                  writer: Writer[U]): void =
    writer.write(transformer.transform(reader.read()))

Esto conduce a una proliferación de clases.

No creo que esto sea un problema. Muchas clases IMO cohesivas y comprobables son mejores que las clases grandes y menos cohesivas.

Cuando lucho es cuando vengo a las pruebas, termino con pruebas estrechamente acopladas. No puedo probar uno sin el otro.

Cada pieza debe ser independientemente comprobable. Modelado anteriormente, puede representar la lectura / escritura en un archivo como:

class FileReader(fileName: String) implements Reader[String] {
    override read(): String = // read file into string
}

class FileWriter(fileName: String) implements Writer[String] {
    override write(str: String) = // write str to file
}

Puede escribir pruebas de integración para probar estas clases y verificar que leen y escriben en el sistema de archivos. El resto de la lógica se puede escribir como transformaciones. Por ejemplo, si los archivos tienen formato JSON, puede transformar el Strings.

class JsonParser implements Transformer[String, Json] {
    override transform(str: String): Json = // parse as json
}

Entonces puedes transformarte en objetos apropiados:

class FooParser implements Transformer[Json, Foo] {
    override transform(json: Json): Foo = // ...
}

Cada uno de estos es independientemente comprobable. También puede probar la unidad programanterior al burlarse reader, transformery writer.

Samuel
fuente
Eso es más o menos donde estoy ahora. Puedo probar cada función individualmente, sin embargo, al probarlas, se acoplan. Por ejemplo, para que FileWriter se pruebe, entonces algo más tiene que leer lo que se escribió, la solución obvia es usar FileReader. Por otro lado, el mediador a menudo hace algo más, como aplicar lógica de negocios o tal vez está representado por la función principal de la aplicación básica.
James Wood el
1
@JamesWood que suele ser el caso con las pruebas de integración. Sin embargo , no tiene que acoplar las clases en el examen. Puede probar FileWriterleyendo directamente desde el sistema de archivos en lugar de usar FileReader. Realmente depende de usted cuáles son sus objetivos en la prueba. Si lo usa FileReader, la prueba se interrumpirá si se rompe FileReadero no FileWriter, lo que puede llevar más tiempo depurar.
Samuel
También vea stackoverflow.com/questions/1087351/… puede ayudar a que sus pruebas sean más agradables
Samuel
Eso es más o menos donde estoy ahora , eso no es 100% cierto. Dijiste que estás usando el patrón Mediador. Creo que esto no es útil aquí; Este patrón se utiliza cuando hay muchos objetos diferentes que interactúan entre sí en un flujo muy confuso; pones un mediador allí para facilitar todas las relaciones e implementarlas en un solo lugar. Este no parece ser tu caso; tienes unidades pequeñas muy bien definidas. Además, al igual que el comentario anterior de @Samuel, debe probar una unidad y hacer sus afirmaciones sin llamar a otras unidades
Emerson Cardoso
@EmersonCardoso; He simplificado un poco el escenario en mi pregunta. Mientras que algunos de mis mediadores son bastante simples, otros son más complicados y a menudo usan múltiples fábricas / comandantes. Estoy tratando de evitar el detalle de un solo escenario, estoy más interesado en la arquitectura de diseño de nivel superior que se puede aplicar a múltiples escenarios.
James Wood
2

Termino con pruebas estrechamente acopladas. Por ejemplo;

  • Fábrica: lee archivos del disco.
  • Comandante: escribe archivos en el disco.

Entonces, el enfoque aquí está en lo que los une . ¿Pasa un objeto entre los dos (como un File?) Entonces es el archivo con el que están acoplados, no entre sí.

De lo que has dicho, has separado tus clases. La trampa es que los estás probando juntos porque es más fácil o 'tiene sentido' .

¿Por qué necesita la entrada Commanderpara provenir de un disco? Lo único que le importa es escribir usando una determinada entrada, luego puede verificar que escribió el archivo correctamente usando lo que está en la prueba .

La parte real que está probando Factoryes "¿leerá este archivo correctamente y generará lo correcto"? Así que mofa el archivo antes de leerlo en la prueba .

Alternativamente, probar que la Fábrica y el Comandante funcionan cuando están acoplados está bien: coincide bastante bien con las Pruebas de integración. La pregunta aquí es más una cuestión de si su unidad puede probarlos por separado o no.

Erdrik Ironrose
fuente
En ese ejemplo particular, lo que los une es el recurso, por ejemplo, el disco del sistema. De lo contrario, no hay interacción entre las dos clases.
James Wood
1

Obtenga datos de alguna parte. Transforma esos datos. Ponga esos datos en alguna parte.

Es un enfoque de procedimiento típico, sobre el que David Parnas escribió en 1972. Te concentras en cómo van las cosas. Tomas la solución concreta de tu problema como un patrón de nivel superior, que siempre está mal.

Si persigue un enfoque orientado a objetos, prefiero concentrarme en su dominio . ¿Que es todo esto? ¿Cuáles son las principales responsabilidades de su sistema? ¿Cuáles son los conceptos principales que se presentan en el lenguaje de los expertos de su dominio? Entonces, comprenda su dominio, descomponga, trate las áreas de responsabilidad de nivel superior como sus módulos , trate los conceptos de nivel inferior representados como sustantivos como sus objetos. Aquí hay un ejemplo que proporcioné a una pregunta reciente, es muy relevante.

Y hay un problema evidente con la cohesión, usted mismo lo ha mencionado. Si realiza alguna modificación es una lógica de entrada y escribe pruebas en ella, de ninguna manera demuestra que su funcionalidad funciona, ya que podría olvidarse de pasar esos datos a la siguiente capa. Ver, estas capas están intrínsecamente acopladas. Y un desacoplamiento artificial empeora las cosas. Lo sé yo mismo: proyecto de 7 años con 100 años-hombre detrás de mis hombros, escrito completamente en este estilo. Huye de él si puedes.

Y sobre todo el asunto SRP. Se trata de cohesión aplicada a su espacio problemático, es decir, dominio. Ese es el principio fundamental detrás de SRP. Esto da como resultado que los objetos sean inteligentes e implementen sus responsabilidades por sí mismos. Nadie los controla, nadie les proporciona datos. Combinan datos y comportamiento, exponiendo solo lo último. Por lo tanto, sus objetos combinan validación de datos sin procesar, transformación de datos (es decir, comportamiento) y persistencia. Podría verse así:

class FinanceTransaction
{
    private $id;
    private $storage;

    public function __construct(UUID $id, DataStorage $storage)
    {
        $this->id = $id;
        $this->storage = $storage;
    }

    public function perform(
        Order $order,
        Customer $customer,
        Merchant $merchant
    )
    {
        if ($order->isExpired()) {
            throw new Exception('Order expired');
        }

        if ($customer->canNotPurchase($order)) {
            throw new Exception('It is not legal to purchase this kind of stuff by this customer');
        }

        $this->storage->save($this->id, $order, $customer, $merchant);
    }
}

(new FinanceTransaction())
    ->perform(
        new Order(
            new Product(
                $_POST['product_id']
            ),
            new Card(
                new CardNumber(
                    $_POST['card_number'],
                    $_POST['cvv'],
                    $_POST['expires_at']
                )
            )
        ),
        new Customer(
            new Name(
                $_POST['customer_name']
            ),
            new Age(
                $_POST['age']
            )
        ),
        new Merchant(
            new MerchantId($_POST['merchant_id'])
        )
    )
;

Como resultado, hay bastantes clases cohesivas que representan alguna funcionalidad. Tenga en cuenta que la validación generalmente va a los objetos de valor, al menos en el enfoque DDD .

Vadim Samokhin
fuente
1

Cuando lucho es cuando vengo a las pruebas, termino con pruebas estrechamente acopladas. Por ejemplo;

  • Fábrica: lee archivos del disco.
  • Comandante: escribe archivos en el disco.

Tenga cuidado con las abstracciones con fugas cuando trabaje con el sistema de archivos: lo vi descuidado con demasiada frecuencia y tiene los síntomas que ha descrito.

Si la clase funciona con datos que provienen / entran en estos archivos, el sistema de archivos se convierte en detalles de implementación (E / S) y debe separarse de ellos. Estas clases (fábrica / comandante / mediador) no deben conocer el sistema de archivos a menos que su único trabajo sea almacenar / leer los datos proporcionados. Las clases que se ocupan del sistema de archivos deben encapsular parámetros específicos del contexto, como las rutas (podrían pasar a través del constructor), por lo que la interfaz no reveló su naturaleza (la palabra "Archivo" en el nombre de la interfaz es un olor casi siempre).

estremecimiento
fuente
"Estas clases (fábrica / comandante / mediador) no deberían conocer el sistema de archivos a menos que su único trabajo sea almacenar / leer los datos proporcionados". En este ejemplo particular, eso es todo lo que están haciendo.
James Wood
0

En mi opinión, parece que has comenzado a seguir el camino correcto, pero no lo has llevado lo suficientemente lejos. Creo que dividir la funcionalidad en diferentes clases que hacen una cosa y lo hacen bien es correcto.

Para ir un paso más allá, debe crear interfaces para sus clases Factory, Mediator y Commander. Luego puede usar versiones simuladas de esas clases al escribir sus pruebas unitarias para las implementaciones concretas de las otras. Con los simulacros puede validar que los métodos se invocan en el orden correcto y con los parámetros correctos y que el código bajo prueba se comporta correctamente con diferentes valores de retorno.

También podría considerar abstraer la lectura / escritura de los datos. Vas a un sistema de archivos ahora pero quizás quieras ir a una base de datos o incluso a un socket en algún momento en el futuro. Su clase de mediador no debería tener que cambiar si el origen / destino de los datos cambia.

Richard Wells
fuente
1
YAGNI es algo en lo que debes pensar.
cuál es el