Vitor Bellini
Todos os posts

Composition Over Inheritance na prática com Laravel

5 min de leitura #laravel #boas-praticas

Há alguns anos venho preferindo uma forma diferente de pensar sobre como estruturo meus modelos e comportamentos.

Em vez de começar pensando em hierarquias e em qual classe deveria herdar de qual, gosto de pensar nas capacidades que cada objeto precisa ter.

Foi assim que comecei a gostar cada vez mais de Composition Over Inheritance.

No Laravel isso funciona especialmente bem porque o framework já nos dá várias ferramentas que combinam muito com essa ideia.

Vou usar comentários como exemplo porque é um caso simples, mas que mostra muito bem como gosto de estruturar esse tipo de código.

O problema

Imagine que tenho um Post e quero permitir que ele receba comentários.

A solução mais óbvia seria colocar o relacionamento diretamente no model:

class Post extends Model
{
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

Não existe nada de errado nisso.

Inclusive, para um projeto pequeno, provavelmente seria exatamente o que eu faria.

O problema começa quando outros objetos do sistema também passam a precisar desse comportamento.

Hoje é Post.

Amanhã pode ser Video.

Depois pode ser Product, Ticket, Photo ou qualquer outra coisa do domínio.

Nesse momento, eu começo a perceber que comments() não é necessariamente um comportamento exclusivo de Post.

É uma capacidade que diferentes objetos podem possuir.

E essa mudança de perspectiva faz bastante diferença para mim.

Eu gosto de pensar em capacidades

Quando percebo que determinado comportamento pode ser utilizado por diferentes modelos, gosto de separar duas coisas:

  1. o contrato daquele comportamento;
  2. a implementação daquele comportamento.

Nesse caso, crio uma interface:

interface HasComments
{
    /**
     * @return MorphMany<Comment, covariant Model>
     */
    public function comments(): MorphMany;
}

Essa interface representa uma coisa muito simples:

Este objeto sabe trabalhar com comentários.

Não estou dizendo que ele é um CommentableModel.

Não estou criando uma categoria dentro da minha hierarquia de models.

Estou apenas dizendo que ele possui uma determinada capacidade.

Essa diferença parece pequena, mas muda bastante a forma como penso sobre o código.

O comportamento fica em uma trait

Depois de definir o contrato, preciso de uma implementação.

É aqui que entra uma trait:

trait InteractsWithComments
{
    /**
     * @return MorphMany<Comment, $this>
     */
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

Agora o comportamento pode ser composto por qualquer model que precise dele.

Meu Post fica assim:

#[Fillable(['title', 'body'])]
#[UseFactory(PostFactory::class)]
class Post extends Model implements HasComments
{
    /** @use HasFactory<PostFactory> */
    use HasFactory, InteractsWithComments;
}

Gosto bastante dessa forma de escrever porque consigo olhar para o model e entender rapidamente quais são as suas capacidades.

O Post é um model.

Além disso, ele implementa HasComments.

E o comportamento necessário para isso vem de InteractsWithComments.

Não precisei criar uma nova classe base.

Não precisei criar uma hierarquia.

Simplesmente compus um comportamento no objeto.

Herança não é para reaproveitar código

Não estou dizendo que herança é ruim.

Eu uso herança quando existe uma relação de fato entre os objetos e quando ela ajuda a representar o domínio.

O que eu evito é usar herança apenas como uma ferramenta de reutilização de código.

Se dois objetos precisam compartilhar um comportamento, isso não significa necessariamente que eles precisam estar na mesma hierarquia.

É justamente nesses casos que eu prefiro composição.

Em vez de criar uma classe base apenas para colocar métodos que serão reutilizados por outras classes, prefiro pensar se aquele comportamento pode ser uma capacidade independente que cada objeto pode escolher ter.

O mais importante: quem usa o comportamento não precisa conhecer o model

Aqui está uma parte que considero ainda mais importante nesse exemplo.

Eu não quero que minha regra de adicionar comentários precise conhecer Post.

Por isso crio uma action:

final readonly class AddCommentAction
{
    public function __invoke(HasComments $commentable, CommentDTO $dto): Comment
    {
        return $commentable->comments()->create($dto->toArray());
    }
}

Gosto bastante dessa abordagem.

A AddCommentAction não sabe o que é um Post.

Ela não sabe o que é um Video.

Ela não sabe o que é um Product.

Ela só sabe que recebeu alguma coisa que implementa HasComments.

E isso é suficiente.

Essa é a parte mais interessante da composição:

Eu consigo escrever uma regra de negócio baseada em uma capacidade, e não baseada em uma classe concreta.

A Action não depende de Post

Imagine que hoje eu tenha:

$post = Post::findOrFail($id);
$addCommentAction($post, $data);

Tudo funciona.

Agora surge um Video:

class Video extends Model implements HasComments
{
    use InteractsWithComments;
}

Eu não preciso alterar a AddCommentAction.

Posso simplesmente fazer:

$video = Video::findOrFail($id);
$addCommentAction($video, $data);

A action continua exatamente igual.

Isso é o que eu procuro quando penso em código extensível.

Não significa que nunca vou precisar alterar uma classe.

Significa que, quando aparece uma nova implementação de um comportamento já conhecido pelo sistema, tento fazer com que a extensão aconteça adicionando algo, e não modificando tudo que já existia.

Não estou tentando abstrair tudo

Também acho importante deixar claro que não vejo composição como uma regra que precisa ser aplicada em qualquer lugar.

Para mim, abstração precisa aparecer quando existe um conceito real no domínio.

Se apenas Post possui comentários e não existe nenhuma indicação de que esse comportamento será compartilhado, talvez eu simplesmente coloque o relacionamento no Post.

Não vejo problema nenhum nisso.

A composição começa a fazer sentido quando percebo que existe um comportamento independente que pode ser atribuído a diferentes objetos.

Ou seja, eu não tento prever todas as possíveis extensões do sistema.

Eu abstraio quando encontro uma capacidade que faz sentido existir por si só.

1 curtida