DEV Community

Terminal Coffee
Terminal Coffee

Posted on

Tell Don't Ask: a arte da comunicação entre objetos.

Um tópico que me manteve muito interessado recentemente foi o princípio Tell Don't Ask, citado originalmente no livro Smalltalk By Example (Alec Sharp), o autor destaca que um aspecto que difere muito o código orientado a objetos do código procedural é que usando objetos se diz para eles agirem, enquanto usando funções se pergunta pelos dados primeiro para depois decidir como se agir.

O princípio se tornou conhecido graças a Andy Hut e Dave Thomas, autores do livro The Pragimatic Programmer, onde, novamente, o conceito é destacado como algo fundamental para a aplicação das ideias orientadas a objeto no código.

Por fim, minha terceira fonte de inspiração para mergulhar de cabeça nesse assunto foram dois artigos incríveis abordando o tema para além de uma variação do artigo escrito pelo Fowler:

  • A Better Way to Handle Validation Errors: que mostra o potencial desse estilo no tratamento de erros, o que nos dá uma dica sobre como objetos se informam das coisas sem a necessidade de retornar valores ou uso de ifs;
  • Map, don't ask: que traça uma comparação muito interessante do estilo orientado a objetos de resolver problemas (tell don't ask), com o estilo funcional (monâdas representando efeitos colaterais);

Sendo assim, tenho pensado muito sobre como poderíamos escrever software apenas nos baseando nessa ideia, então hoje vou compartilhar com vocês, caros leitores, minhas reflexões sobre o assunto, na forma das várias interações que podemos observar entre objetos, e como lidamos com elas no tell style ao invés do ask style.

Tell Don't Ask

Antes de irmos de fato para o assunto de hoje, para manter todos na mesma página, vamos relembrar a definição do princípio:

Você envia comandos aos objetos dizendo para eles o que você quer feito. Nós explicitamente não queremos perguntar ao objeto sobre o seu estado, tomar uma decisão, e então dizer ao objeto o que fazer.

~ Andy Hunt e Dave Thomas (adaptação livre)

Situação 01: Quando o objeto possui todos os dados necessários

Aqui seria o exemplo mais simples do uso do Tell Don't Ask (TDA), vamos pensar em lidar com um objeto apenas, por exemplo num caso de uso de sacar dinheiro de uma conta bancária. Se nós seguirmos o ask style, teríamos o seguinte resultado:

class Account {
  constructor(
    private number: string,
    private balance: number
  ) {}

  getBalance(): number {
    return this.balance;
  }

  setBalance(balance: number): void {
    this.balance = balance;
  }
}

type WithdrawCommand = {
  account: Account;
  amount: number;
}

function useCase({ account, amount }: WithdrawCommand): void {
  if (account.getBalance() < amount) {
    throw new Error("Insufficient balance in the account");
  }

  account.setBalance(account.getBalance() - amount);
}

useCase({ account: new Account('12345-6', 100), amount: 50 });
Enter fullscreen mode Exit fullscreen mode

Toda a lógica de negócio ficou acumulada no caso de uso, e pior de tudo, ela apenas usa dados presentes na classe Account, o que significa que ela poderia ter dado conta de fazer isso ela mesma, mas quem fez foi o caso de uso, logo, o princípio do encapsulamento, onde nos benefíciamos dos dados viverem ao lado das computações realizadas neles, está sendo gravemente violado aqui.

Podemos mudar isso ao reescrever no tell style:

class Account {
  constructor(
    private number: string,
    private balance: number
  ) {}

  withdraw(amount: number): void {
    if (this.balance < amount) {
      throw new Error("Insufficient balance in the account");
    }

    this.balance -= amount;
  }
}

type WithdrawCommand = {
  account: Account;
  amount: number;
}

function useCase({ account, amount }: WithdrawCommand): void {
  account.withdraw(amount);
}

useCase({ account: new Account('12345-6', 100), amount: 50 });
Enter fullscreen mode Exit fullscreen mode

Temos que o código foi drasticamente simplificado para apenas:

account.withdraw(amount);
Enter fullscreen mode Exit fullscreen mode

Logo, lidando com um único objeto, caso você estiver se pegando usando muitas queries antes de decidir fazer alguma coisa, considere se a lógica que você está escrevendo não poderia ser um método do objeto que é dono desses dados, assim você pode dizer para ele realizar a tarefa ao invés de perguntar se ele tem os dados para realizar a tarefa.

Situação 02: Quando um objeto precisa de informações de outro para agir

Se tudo pudesse ser modelado com os dados pertencendo a um objeto, e somente a ele, o mundo seria muito mais fácil, afinal, aplicaríamos sempre a regra que vimos anteriormente, e ai tudo sempre ia acabar se encaixando no método de um objeto, e o dono daquele método seria óbvio, entretanto existem situações onde uma operação exige a colaboração entre dois objetos, pois eles "operam no mesmo dado", ao mesmo tempo que precisam se manter independentes um do outro, pelo menos em termos de conhecimento de seus detalhes de implementação.

Por exemplo, se tivermos um caso de uso onde um cliente compra um produto, o cliente precisa pagar um preço que é uma propriedade de produto, sendo assim, ele precisa de um dado que não é dele para realizar suas operações.

Caso nós estejamos pensando de forma procedural, uma solução simples seria perguntar ao produto qual o seu preço, e então modificar a quantidade de dinheiro no cliente, ex:

class Customer {
  constructor(private name: string, private money: number) {}

  setMoney(money: number): void {
    this.money = money;
  }
}

class Product {
  constructor(private name: string, private price: number) {}

  getPrice(): number {
    return this.price;
  }
}

function useCase(customer: Customer, product: Product): void {
  if (customer.getMoney() < product.getPrice()) {
    throw new Error("Customer cannot pay for the Product");
  }

  customer.setMoney(customer.getMoney() - product.getPrice());
}
Enter fullscreen mode Exit fullscreen mode

A princípio, parece que temos uma situação semelhante a do nosso primeiro exemplo, entretanto note a linha que contém a condicional:

if (customer.getMoney() < product.getPrice()) {
Enter fullscreen mode Exit fullscreen mode

Ela não envolve só os dados de customer, por isso não podemos simplesmente converter ela num método, como podemos lidar com esse dado que não pertence ao cliente? A resposta é bem simples, se é algo de fora do objeto, então tem que vir de fora, por isso o preço é convertido num parâmetro, ex:

class Customer {
  constructor(private name: string, private money: number) {}

  pay(amount: number): void {
    if (this.money < amount) {
      throw new Error("Customer cannot pay for this amount");
    }

    this.money -= amount;
  }
}

class Product {
  constructor(private name: string, private price: number) {}

  getPrice(): number {
    return this.price;
  }
}

function useCase(customer: Customer, product: Product): void {
  customer.pay(product.getPrice());
}
Enter fullscreen mode Exit fullscreen mode

Com isso o TDA foi aplicado com sucesso no cliente, mas o nosso objeto produto continua sendo só um saco de dados, então será que existe alguma forma de aplicar o TDA nele também?

A resposta é sim, pois ele tem uma ação relacionada com ele, que é a venda, da mesma forma que a gente tem uma ação faltando no cliente também, que é a compra, o que nos permite modelar a situação da seguinte maneira:

class Customer {
  constructor(private name: string, private money: number) {}

  pay(amount: number): void {
    if (this.money < amount) {
      throw new Error("Customer cannot pay for this amount");
    }

    this.money -= amount;
  }

  buy(product: Product) {
    product.sellItselfTo(this);
  }
}

class Product {
  constructor(private name: string, private price: number) {}

  sellItselfTo(customer: Customer) {
    customer.pay(this.price);
  }
}

function useCase(customer: Customer, aProduct: Product): void {
  customer.buy(aProduct);
}
Enter fullscreen mode Exit fullscreen mode

Parece meio confuso a primeira vista, mas o que está acontecendo nesse código é que ao aplicar o TDA em todas as operações que devem acontecer para que a compra de um produto seja realizada, nós acabamos numa situação onde para implementar uma ação, nós repetimos o processo de descrever as ações que ela precisa realizar para ser executada também, o que nos leva a aplicar o TDA nessas sub-tarefas também, entrando num ciclo de adiar a resolução do problema o máximo possível, sempre empurrando a batata para frente até alguém conseguir resolver o problema. Em certo sentido, é semelhante a como a gente treina para escrever algoritmos recursivos.

Entrando mais em detalhes, o truque aqui é que a gente troca quem está no controle da execução do código entre ambos os objetos conforme a necessidade de um dado que não pertence ao objeto for surgindo. A lógica é a seguinte:

  1. Para que o cliente possa comprar o produto, o produto precisa ser vendido para ele, então o cliente pede ao produto para que a ação de venda aconteça;
  2. Para que o Produto possa se vender, ele precisa saber a quem ele está sendo vendido, então ele requere isso via um parâmetro;
  3. Como Cliente sabe quem quer comprar o produto (ele mesmo), ele informa ao produto quem é o comprador se passando como parâmetro;
  4. Sabendo para quem ele vai se vender, o Produto precisa que o Cliente pague por ele, então ele pede ao cliente para realizar o pagamento;
  5. Para que o cliente pague, ele exige saber o valor a ser pago, então ele requere isso via um parâmetro;
  6. O produto sabe qual valor deve ser pago (pois é o seu preço), então ele passa o preço como parâmetro, e o cliente consegue executar a ação de pagar, encerrando o caso de uso;

Visualizando a coisa acontecendo, o fluxo de execução seria algo como:

customer.buy(product);
product.sellItselfTo(customer);
customer.pay(product.price);
Enter fullscreen mode Exit fullscreen mode

O truque para fazer com que o controle da execução seja trocado conforme ela for acontecendo, é ceder o controle para o outro objeto chamando um de seus métodos, e se passando como parâmetro, assim quando o outro objeto terminar de fazer o que ele precisa, ele pode devolver o controle da execução para o original ao chamar um de seus métodos.

Situação 03: Quando um objeto precisa reagir a eventos em outro

type Nothing = null | undefined | Promise<null | undefined>;

type Credentials = {
  email: string;
  password: string;
};

interface SignInOutputBoundary {
  emailNotFound(email: string): Nothing;
  invalidCredentials(credentials: Credentials): Nothing;
  userAuthenticated(user: User): Nothing;
}

class SignIn {
  constructor(
    private users: UserRepository,
    private encrypter: Encrypter
  ) {}

  async attemptFor(
    input: Credentials,
    andReportTo: SignInOutputBoundary
  ): Promise<void> {
    const output = andReportTo;
    const user = await this.users.userOfEmail(input.email);

    if (!user) {
      return output.emailNotFound(input.email);
    }

    if (!user.matchPassword(input.password, this.encrypter)) {
      return output.invalidCredentials(input);
    }

    return output.userAuthenticated(user);
  }
}

class SignInController implements SignInOutputBoundary {
  constructor(private signIn: SignIn, private res: ResponseBuilder) {}

  emailNotFound(email: string) {
    this.res.status(401).json({ error: `Email: ${email} not found` });
  }

  invalidCredentials(credentials: Credentials) {
    this.res.status(401).json({ error: `Invalid Credentials` });
  }

  userAuthenticated(user: User) {
    this.res.status(200).json({
      message: "User Authenticated",
      token: new Token(user).toString()
    });
  }

  private validateCredentials(data: unknow) {
    // lógica para validar o body...
  }

  async handle(req: Request): Response {
    this.validateCredentials(req.body);
    await this.signIn.attemptFor(req.body, this);
    return this.res.build();
  }
}
Enter fullscreen mode Exit fullscreen mode

Neste exemplo não aplicamos o TDA em 100% do código para não deixa-lo tão complexo, feitas essas considerações, se você já entendeu a ideia de "trocar o controle da execução", então já deve ter matado a charada aqui também, basicamente quando precisamos notificar um objeto de coisas que aconteceram em outro objeto, podemos mandar uma mensagem para o objeto interessado avisando que o que ele queria saber aconteceu, e o que seriam mensagens senão métodos?

Por isso, para mandar uma mensagem para o objeto interessado, ele precisa ser capaz de receber essas mensagens para início de conversa, ou seja, ele precisa ter os métodos que vão responder a cada uma dessas situações implementados dentro dele, sendo uma solução bem interessante para lidar com lógica condicional num geral usando apenas troca de mensagens.

A aplicação do TDA aqui se dá pois ao invés de fazermos algo como o retornar um código de status ou, numa abordagem mais moderna, um objeto Result representando o resultado da execução do caso de uso, e depois decidindo o que fazer, ou seja, perguntando primeiro ao caso de uso qual foi o seu resultado, e depois decidindo o que fazer, sendo que o próprio caso de uso, que tem controle sobre essas informações, pode conversar com o controller, e dizer para ele o que fazer diretamente sem esse passo intermediário.

Em resumo, o truque aqui é que o objeto interessado em saber de coisas de outro objeto, deve declarar métodos para cada coisa que ele quiser saber desse segundo objeto, dessa forma o segundo objeto pode depender do interessado e chamar os seus métodos quando as condições para eles serem chamados forem atingidas. E se essa explicação soou familiar para você, é porque se nós implementarmos essa ideia de forma ainda mais desacoplada, o que nós temos é basicamente o padrão de projetos Observer.

Situação 04: Quando ações diferentes são necessárias para tipos diferentes de objetos

Continuando na ideia de aprender com padrões de projeto, o padrão Visitor nos ensina que podemos realizar a comunicação entre objetos mesmo quando a informação que precisamos é uma meta informação, por exemplo de qual tipo é o objeto.

No caso de se ter uma única operação ao longo de vários objetos, mas com implementações diferentes para cada um, podemos simplesmente usar polimorfismo simples para resolver o problema, mas e se nós precisarmos executar operações diferentes para tipos diferentes de objetos, quando essas operações podem ter implementações diferentes?

Por exemplo, podemos escrever o exemplo anterior de uma forma diferente caso se prefira lançar erros e tratar eles separadamente num bloco catch, ao invés de acoplar o caso de uso no presenter:

interface ExceptionVisitor {
  unknown(error: unknown): void;
  error(error: Error): void;
  productNotFound(error: ProductNotFoundException): void;
  outOfStock(error: OutOfStockException): void;
  insufficentStock(error: InsufficentStockException): void;
}

interface Exception {
  accept(visitor: ExceptionVisitor): void;
}

abstract class DomainException extends Error implements Exception {
  abstract accept(visitor: ExceptionVisitor): void;
}

class UnknownException implements Exception {
  constructor(private value: unknown) {}

  accept(visitor: ExceptionVisitor): void {
    visitor.unknown(this.value);
  }
}

class ErrorException implements Exception {
  constructor(private error: Error) {}

  accept(visitor: ExceptionVisitor): void {
    visitor.error(this.error);
  }
}

class ProductNotFoundException extends DomainException {
  constructor() {
    super("Product not found");
  }

  accept(visitor: ExceptionVisitor): void {
    visitor.productNotFound(this);
  }
}

class OutOfStockException extends DomainException {
  constructor() {
    super("Product out of stock");
  }

  accept(visitor: ExceptionVisitor): void {
    visitor.outOfStock(this);
  }
}

class InsufficentStockException extends DomainException {
  constructor() {
    super("Insufficient Stock of Product");
  }

  accept(visitor: ExceptionVisitor): void {
    visitor.insufficentStock(this);
  }
}

function exceptionFactory(value: unknown): Exception {
  if (value instanceof DomainException) {
    return value;
  }

  if (value instanceof Error) {
    return new ErrorException(value);
  }

  return new UnknownException(value);
}

function tryExecute<F extends (...args: any[]) => any>(
  fn: F,
  orHandleWith: ExceptionVisitor
): ReturnType<F> | void {
  try {
    return fn();
  } catch (error) {
    return exceptionFactory(error).accept(orHandleWith);
  }
}

class OrderController {
  constructor(private placeOrder: PlaceOrder) {}

  place(req: Request, res: Response) {
    tryExecute(() => this.placeOrder.execute(req.body), {
      unknown() {
        res.status(500).json({ error: "Unknown error" });
      },
      error(error) {
        res.status(500).json({ error: error.message });
      },
      insufficentStock() {
        res
          .status(400)
          .json({ error: "Cannot place an order due to insufficient stock" });
      },
      outOfStock() {
        res.status(400).json({
          error: "Cannot place an order for an product that is out of stock",
        });
      },
      productNotFound() {
        res.status(400).json({
          error: "Cannot place an order for a product that doesn't exist",
        });
      },
    });
  }
}

Enter fullscreen mode Exit fullscreen mode

Nesse caso, com a ajuda de um pequeno helper para converter um valor unknow numa Exception que pode ser visitada, nós conseguimos implementar uma forma de lidar com todas as exceções lançadas pelo caso de uso PlaceOrder sem depender de um switch dentro do Controller.

Dessa forma, ao invés de ter um instanceof para cada tipo de Erro, nós limitamos a abordagem de perguntar pelas coisas apenas ao que está fora de nosso controle (valores unknow, e erros fora de DomainException), de forma a adaptar esses valores para objetos dentro de nosso controle novamente, e então usar apenas da abordagem tell para lidar com o problema, visto que dizemos para a Exceção aceitar um Visitor, e cada erro pode decidir por si mesmo como usar o Visitor para lidar com si mesmo, o que seria o contrário da abordagem ask, onde o Visitor teria que perguntar a cada Erro quem ele é para depois decidir qual código executar.

Conclusão

Tentar usar o Tell Don't Ask para tudo pode até não ser a abordagem mais pragmática possível, entretanto para que nós de fato consigamos aprender um paradigma, devemos tentar nos blindar o máximo possível de nossos vícios, enquanto tentamos abraçar ao máximo as premissas do paradigma, o que é uma experiência bem difícil, e que nos desafia a pensar fora da caixinha o máximo possível, mas os resultados são recompensadores, visto que depois de conquistar essa nova maneira de pensar, nosso cérebro fica cada vez mais flexível e aberto a novos tipos de soluções, o que melhora a nossa capacidade de resolver problemas.

Você leitor já havia olhado para a Orientação a Objetos por este ângulo da conversa entre objetos? Achou interessante? Conta para a gente sua opinião sobre o assunto aqui nos comentários.

Até a próxima!

ASS: Suporte Cansado...

Top comments (0)