Estou tornando minhas aulas muito granulares? Como o princípio da responsabilidade única deve ser aplicado?

9

Escrevo muito código que envolve três etapas básicas.

  1. Obtenha dados de algum lugar.
  2. Transforme esses dados.
  3. Coloque esses dados em algum lugar.

Normalmente, acabo usando três tipos de classes - inspirados em seus respectivos padrões de design.

  1. Fábricas - para construir um objeto a partir de algum recurso.
  2. Mediadores - para usar a fábrica, execute a transformação e use o comandante.
  3. Comandantes - para colocar esses dados em outro lugar.

Minhas aulas tendem a ser muito pequenas, geralmente um método único (público), por exemplo, obter dados, transformar dados, trabalhar, salvar dados. Isso leva a uma proliferação de classes, mas em geral funciona bem.

Onde eu luto é quando chego aos testes, acabo fazendo testes fortemente acoplados. Por exemplo;

  • Fábrica - lê arquivos do disco.
  • Comandante - grava arquivos no disco.

Não posso testar um sem o outro. Eu poderia escrever código 'teste' adicional para fazer leitura / gravação em disco também, mas depois estou me repetindo.

Olhando para .Net, a classe File adota uma abordagem diferente, ela combina as responsabilidades (da minha) fábrica e comandante. Possui funções para criar, excluir, existir e ler em um só lugar.

Devo procurar seguir o exemplo de .Net e combinar - principalmente ao lidar com recursos externos - minhas aulas juntos? O código ainda está associado, mas é mais intencional - acontece na implementação original, e não nos testes.

É minha questão aqui que apliquei o Princípio de Responsabilidade Única de maneira um tanto exagerada? Tenho aulas separadas, responsáveis ​​pela leitura e gravação. Quando eu poderia ter uma classe combinada responsável por lidar com um recurso específico, por exemplo, disco do sistema.

James Wood
fonte
6
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.- Observe que você está confundindo "responsabilidade" com "coisa a fazer". Uma responsabilidade é mais como uma "área de preocupação". A responsabilidade da classe File está executando operações de arquivo.
9788 Robert
11
Parece-me que você está em boa forma. Tudo que você precisa é de um mediador de teste (ou um para cada tipo de conversão, se você preferir). O mediador de teste pode ler os arquivos para verificar sua correção, usando a classe File do .net. Não há problema com isso da perspectiva do SOLID.
Martin Maat
11
Conforme mencionado por Robert Harvey, o SRP tem um nome de baixa qualidade, porque não se trata realmente de Responsabilidades. Trata-se de "encapsular e abstrair uma única área de preocupação complicada / difícil que pode mudar". Eu acho que o STDACMC foi muito longo. :-) Dito isto, acho que sua divisão em três partes parece razoável.
user949300
11
Um ponto importante em sua Filebiblioteca do C # é que, pelo que sabemos, a Fileclasse pode ser apenas uma fachada, colocando todas as operações de arquivo em um único local - na classe, mas poderia estar internamente usando classes de leitura / gravação semelhantes às suas, o que na verdade, contém a lógica mais complicada para manipulação de arquivos. Essa classe (the File) ainda aderiria ao SRP, porque o processo de realmente trabalhar com o sistema de arquivos seria abstraído por trás de outra camada - provavelmente com uma interface unificadora. Não estou dizendo que é o caso, mas poderia ser. :)
Andy

Respostas:

5

Seguir o princípio de responsabilidade única pode ter sido o que o guiou até aqui, mas onde você está tem um nome diferente.

Segregação de responsabilidade de consulta de comando

Vá estudá-lo e acho que você o seguirá de acordo com um padrão familiar e que não ficará sozinho pensando em até que ponto levar isso. O teste de ácido é se, ao seguir isso, você obtém benefícios reais ou se é apenas um mantra cego que você segue, para que você não precise pensar.

Você expressou preocupação com os testes. Eu não acho que seguir o CQRS se impeça de escrever código testável. Você pode simplesmente seguir o CQRS de uma maneira que torne seu código não testável.

Ajuda a saber como usar o polimorfismo para inverter as dependências do código fonte sem precisar alterar o fluxo de controle. Não tenho muita certeza de onde está seu conjunto de habilidades para escrever testes.

Uma palavra de cautela: seguir os hábitos encontrados nas bibliotecas não é o ideal. As bibliotecas têm suas próprias necessidades e são francamente antigas. Assim, mesmo o melhor exemplo é apenas o melhor exemplo da época.

Isso não quer dizer que não haja exemplos perfeitamente válidos que não sigam o CQRS. Depois disso sempre será um pouco doloroso. Nem sempre vale a pena pagar. Mas se você precisar, ficará feliz em usá-lo.

Se você o usar, observe esta palavra de aviso:

Em particular, o CQRS deve ser usado apenas em partes específicas de um sistema (um BoundedContext na linguagem DDD) e não no sistema como um todo. Dessa maneira, cada contexto vinculado precisa de suas próprias decisões sobre como deve ser modelado.

Martin Flowler: CQRS

candied_orange
fonte
Interessante não visto CQRS antes. O código é testável, trata-se mais de tentar encontrar uma maneira melhor. Eu uso zombarias e injeção de dependência quando posso (que eu acho que é o que você está se referindo).
James Wood
Ao ler sobre isso pela primeira vez, identifiquei algo semelhante através do meu aplicativo: lidar com pesquisas flexíveis, vários campos filtráveis ​​/ classificáveis ​​(Java / JPA) é uma dor de cabeça e leva a toneladas de código padrão, a menos que você crie um mecanismo de pesquisa básico que vai lidar com essas coisas para você (eu uso rsql-jpa). Embora eu tenha o mesmo modelo (digamos as mesmas entidades JPA para ambos), as pesquisas são extraídas em um serviço genérico dedicado e a camada de modelo não precisa mais lidar com isso.
Walfrat 15/11
3

Você precisa de uma perspectiva mais ampla para determinar se o código está em conformidade com o Princípio da Responsabilidade Única. Ele não pode ser respondido apenas analisando o código em si; é necessário considerar quais forças ou atores podem fazer com que os requisitos mudem no futuro.

Digamos que você armazene dados do aplicativo em um arquivo XML. Quais fatores podem fazer com que você altere o código relacionado à leitura ou gravação? Algumas possibilidades:

  • O modelo de dados do aplicativo pode mudar quando novos recursos são adicionados ao aplicativo.
  • Novos tipos de dados - por exemplo, imagens - podem ser adicionados ao modelo
  • O formato de armazenamento pode ser alterado independentemente da lógica do aplicativo: digamos de XML para JSON ou para um formato binário, devido a problemas de interoperabilidade ou desempenho.

Em todos estes casos, o que você vai ter que mudar tanto a leitura e a lógica da escrita. Em outras palavras, eles não são responsabilidades separadas.

Mas vamos imaginar um cenário diferente: seu aplicativo faz parte de um pipeline de processamento de dados. Ele lê alguns arquivos CSV gerados por um sistema separado, realiza algumas análises e processamento e, em seguida, gera um arquivo diferente para ser processado por um terceiro sistema. Nesse caso, a leitura e a escrita são responsabilidades independentes e devem ser dissociadas.

Conclusão: em geral, você não pode dizer se a leitura e gravação de arquivos são responsabilidades separadas, isso depende das funções no aplicativo. Mas, com base na sua dica sobre testes, acho que é uma responsabilidade única no seu caso.

JacquesB
fonte
2

Geralmente você tem a ideia certa.

Obtenha dados de algum lugar. Transforme esses dados. Coloque esses dados em algum lugar.

Parece que você tem três responsabilidades. OMI, o "Mediador" pode estar fazendo muito. Eu acho que você deve começar modelando suas três responsabilidades:

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

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

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

Em seguida, um programa pode ser expresso como:

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

Isso leva a uma proliferação de classes

Eu não acho que isso seja um problema. Muitas IMO pequenas classes coesas e testáveis ​​são melhores do que as classes grandes e menos coesas.

Onde eu luto é quando chego aos testes, acabo fazendo testes fortemente acoplados. Não posso testar um sem o outro.

Cada peça deve ser testada independentemente. Modelado acima, você pode representar a leitura / gravação em um arquivo 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
}

Você pode escrever testes de integração para testar essas classes e verificar se elas lêem e gravam no sistema de arquivos. O restante da lógica pode ser escrito como transformações. Por exemplo, se os arquivos estiverem no formato JSON, você poderá transformar os String.

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

Então você pode transformar em objetos adequados:

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

Cada um destes é testável independentemente. Você também pode teste de unidade programacima zombando reader, transformere writer.

Samuel
fonte
É praticamente onde estou agora. Eu posso testar cada função individualmente, porém, testando-as, elas se tornam acopladas. Por exemplo, para que o FileWriter seja testado, algo mais precisa ler o que foi escrito, a solução óbvia é o FileReader. Fwiw, o mediador geralmente faz outra coisa como aplicar a lógica de negócios ou talvez seja representado pela função principal do aplicativo básico.
James Wood
11
@JamesWood costuma ser o caso com testes de integração. Você não precisa unir as classes no teste. Você pode testar FileWriterlendo diretamente do sistema de arquivos em vez de usar FileReader. É realmente você quem decide seus objetivos. Se você usar FileReader, o teste será interrompido se um FileReaderou FileWriterestiver quebrado - o que pode levar mais tempo para depurar.
Samuel
Veja também stackoverflow.com/questions/1087351/... pode ajudar a tornar os testes mais agradável
Samuel
É nesse ponto que estou agora - isso não é 100% verdade. Você disse que está usando o padrão Mediador. Eu acho que isso não é útil aqui; esse padrão é usado quando você tem muitos objetos diferentes interagindo entre si em um fluxo muito confuso; você coloca um mediador lá para facilitar todos os relacionamentos e implementá-los em um só lugar. Este parece não ser o seu caso; você tem pequenas unidades muito bem definidas. Além disso, como o comentário acima por @Samuel, você deve testar uma unidade, e fazer a sua afirma sem chamar outras unidades
Emerson Cardoso
@EmersonCardoso; Simplifiquei um pouco o cenário na minha pergunta. Enquanto alguns dos meus mediadores são bastante simples, outros são mais complicados e costumam usar várias fábricas / comandantes. Estou tentando evitar os detalhes de um único cenário, estou mais interessado na arquitetura de design de nível superior que pode ser aplicada a vários cenários.
James Wood
2

Eu acabarei testes fortemente acoplados. Por exemplo;

  • Fábrica - lê arquivos do disco.
  • Comandante - grava arquivos no disco.

Portanto, o foco aqui é o que os une . Você passa um objeto entre os dois (como um File?) Então é o arquivo ao qual eles estão acoplados, não um ao outro.

Do que você disse, você separou suas aulas. A armadilha é que você os está testando juntos porque é mais fácil ou 'faz sentido' .

Por que você precisa que a entrada Commandervenha de um disco? Tudo o que importa é escrever usando uma determinada entrada, e você pode verificar se ele escreveu o arquivo corretamente usando o que está no teste .

A parte real que você está testando Factoryé 'ele lerá esse arquivo corretamente e produzirá a coisa certa'? Portanto, zombe do arquivo antes de lê-lo no teste .

Como alternativa, é bom testar se o Factory e o Commander funcionam quando acoplados - ele se alinha muito bem com o Teste de integração. A questão aqui é mais uma questão de saber se a sua unidade pode ou não testá-los separadamente.

Erdrik Ironrose
fonte
Nesse exemplo em particular, o que os une é o recurso - por exemplo, o disco do sistema. Caso contrário, não há interação entre as duas classes.
James Wood
1

Obtenha dados de algum lugar. Transforme esses dados. Coloque esses dados em algum lugar.

É uma abordagem processual típica, sobre a qual David Parnas escreveu em 1972. Você se concentra em como as coisas estão indo. Você considera a solução concreta do seu problema como um padrão de nível superior, que está sempre errado.

Se você seguir uma abordagem orientada a objetos, prefiro me concentrar no seu domínio . Sobre o que é tudo isso? Quais são as principais responsabilidades do seu sistema? Quais são os principais conceitos presentes no idioma dos seus especialistas em domínio? Portanto, entenda seu domínio, decomponha-o, trate áreas de responsabilidade de nível superior como seus módulos , trate conceitos de nível inferior representados como substantivos como seus objetos. Aqui está um exemplo que forneci para uma pergunta recente, é muito relevante.

E há um evidente problema de coesão, você mesmo mencionou. Se você fizer alguma modificação é uma lógica de entrada e escrever testes nela, isso não prova que sua funcionalidade funciona, pois você pode esquecer de passar esses dados para a próxima camada. Veja, essas camadas são acopladas intrinsecamente. E um desacoplamento artificial torna as coisas ainda piores. Eu sei disso: projeto de sete anos com 100 homens-ano atrás dos ombros, escrito completamente nesse estilo. Fuja disso, se puder.

E em toda a coisa SRP. É tudo sobre coesão aplicada ao seu espaço problemático, ou seja, domínio. Esse é o princípio fundamental por trás do SRP. Isso resulta em objetos inteligentes e na implementação de suas responsabilidades por si mesmos. Ninguém os controla, ninguém os fornece dados. Eles combinam dados e comportamento, expondo apenas os últimos. Portanto, seus objetos combinam validação de dados brutos, transformação de dados (ou seja, comportamento) e persistência. Pode parecer com o seguinte:

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, existem várias classes coesas que representam alguma funcionalidade. Observe que a validação normalmente vai para objetos de valor - pelo menos na abordagem DDD .

Vadim Samokhin
fonte
1

Onde eu luto é quando chego aos testes, acabo fazendo testes fortemente acoplados. Por exemplo;

  • Fábrica - lê arquivos do disco.
  • Comandante - grava arquivos no disco.

Cuidado com as abstrações com vazamentos ao trabalhar com o sistema de arquivos - eu o vi negligenciado com muita frequência e tem os sintomas que você descreveu.

Se a classe operar com dados provenientes de / entra nesses arquivos, o sistema de arquivos se torna detalhes de implementação (E / S) e deve ser separado dele. Essas classes (fábrica / comandante / mediador) não devem estar cientes do sistema de arquivos, a menos que seu único trabalho seja armazenar / ler os dados fornecidos. As classes que lidam com o sistema de arquivos devem encapsular parâmetros específicos do contexto, como caminhos (podem ser passados ​​pelo construtor), para que a interface não revele sua natureza (a palavra "Arquivo" no nome da interface é um cheiro na maioria das vezes).

estremecer
fonte
"Essas classes (fábrica / comandante / mediador) não devem estar cientes do sistema de arquivos, a menos que seu único trabalho seja armazenar / ler os dados fornecidos." Neste exemplo em particular, é tudo o que eles estão fazendo.
James Wood
0

Na minha opinião, parece que você começou a seguir o caminho certo, mas não o levou longe o suficiente. Eu acho que dividir a funcionalidade em diferentes classes que fazem uma coisa e fazem bem é correto.

Para dar um passo adiante, você deve criar interfaces para as classes Factory, Mediador e Commander. Em seguida, você pode usar versões simuladas dessas classes ao escrever seus testes de unidade para as implementações concretas das outras. Com as zombarias, você pode validar se os métodos são chamados na ordem correta e com os parâmetros corretos e se o código em teste se comporta adequadamente com diferentes valores de retorno.

Você também pode abstrair a leitura / gravação dos dados. Você está acessando um sistema de arquivos agora, mas pode querer acessar um banco de dados ou mesmo um soquete em algum momento no futuro. Sua classe de mediador não precisa ser alterada se a origem / destino dos dados mudar.

Richard Wells
fonte
11
YAGNI é algo que você deve pensar.
Whatsisname