DEV Community

Guilherme Siquinelli
Guilherme Siquinelli

Posted on • Updated on

SOLID explicado com TypeScript

Alguns princípios foram criados para ajudar pessoas desenvolvedoras a criar sistemas que sejam fáceis de entender, manter e evoluir ao longo do tempo.

Princípios

  1. Single Responsibility Principle ou Princípio da Responsabilidade Única diz que uma classe deve ter apenas uma única responsabilidade e assim, um único motivo para mudar.
  2. Open-Closed Principle ou Princípio do Aberto-Fechado diz que uma classe deve estar aberta para novos cenários, mas fechada para modificação.
  3. Liskov Substitution Principle ou Princípio da Substituição de Liskov diz que os objetos de uma classe derivada devem ser substituíveis por objetos de sua classe base sem alterar o comportamento do software.
  4. Interface Segregation Principle ou Princípio da Segregação de Interface diz que interfaces devem ser separadas para que classes possam depender apenas dos métodos que utilizam.
  5. Dependency Inversion Principle ou Princípio da Inversão de Dependência diz que módulos de alto nível não devem depender de módulos de baixo nível. Ambos devem depender de abstrações.

Ao seguir esses princípios, conseguimos criar sistemas mais flexíveis, robustos e fáceis de manter, alterar e evoluir ao longo do tempo.

Vamos mergulhar um pouco mais fundo.

Single Responsibility Principle ou Princípio da Responsabilidade Única

Ter uma responsabilidade única significa que uma classe deve ter apenas um motivo para mudar. Se uma classe tiver mais de uma responsabilidade, mudanças em uma responsabilidade podem afetar outras responsabilidades, o que pode levar a um código mais complexo e difícil de manter.

Darei um exemplo mais claro sobre o que podem ser consideradas responsabilidades diferentes.

Suponha que tenhamos uma classe chamada User que é responsável tanto pela autenticação do usuário quanto pelo envio de notificações por e-mail.

abstract class User {
  abstract login(email: string, password: string): void
  abstract sendCode(email: string, code: string): void
}
Enter fullscreen mode Exit fullscreen mode

Ela possui responsabilidades diferentes e não segue o princípo SRP. Podemos dividir as responsabilidades em duas classes diferentes.

abstract class Auth {
  abstract login(email: string, password: string): void
}

abstract class Email {
  abstract send(email: string, message: string): void
}
Enter fullscreen mode Exit fullscreen mode

Assim, caso haja uma mudança em uma das responsabilidades, a outra não será afetada, o código estará mais organizado, fácil de entender, testar e manter. O que facilita sua reutilização, pois as classes que têm apenas uma responsabilidade podem ser usadas em diferentes partes.

Open-Closed Principle ou Princípio do Aberto-Fechado

Uma classe deve permitir ser estendida para atender a novos requisitos, sem necessidade de alteração, ou seja, deve permitir adicionar novas funcionalidades usando herança, sem afetar o comportamento existente. Por exemplo, temos uma classe abstrata contendo alguns métodos básicos para comunicação com alguma fonte de dados.

abstract class Repository<T, K extends keyof T> {
  abstract findAll(): T[];
  abstract findOne(key: T[K]): T[];
  abstract create(entity: Omit<T, K>): void;
  abstract update(key: T[K], entity: Partial<T>): void;
  abstract remove(key: T[K]): void;
}
Enter fullscreen mode Exit fullscreen mode

Então criamos uma abstração específica com um método contextual, sem alterar Repository.

abstract class UserRepository extends Repository<User, 'id'> {
  abstract findOneByEmail(email: string): User
}
Enter fullscreen mode Exit fullscreen mode

Para seguir o OCP, a classe deve depender de abstrações em vez de implementações concretas. Isso significa que a classe deve depender de interfaces ou classes abstratas em vez de depender diretamente de classes concretas. As extensões podem ser adicionadas por meio da criação de novas implementações de interfaces ou classes abstratas, sem afetar o existente.

Liskov Substitution Principle ou Princípio da Substituição de Liskov

Uma classe derivada deve ser capaz de substituir sua classe base sem introduzir erros ou comportamentos inesperados. Classes derivadas devem manter a mesma semântica que suas classes base, ou seja, implementações devem cumprir os mesmos contratos definidos em suas abstrações. Caso uma subclasse não possa cumprir essas condições, ela não deve ser considerada apta de implementação da classe base.

Por exemplo, criamos um caso de uso para autenticação.

class SignInUseCase {
  constructor(private repository: AuthRepository) {}

  execute(credential: UserCredential) {
    return this.repository.signIn(credential);
  }
}
Enter fullscreen mode Exit fullscreen mode

Em seguida, criamos uma abstração que será o contrato para garantir que a classe concreta de AuthRepository cumpra o acordo de implementação para o método signIn.

abstract class AuthRepository {
  abstract signIn(value: UserCredential): Observable<AuthResponse>;
}
Enter fullscreen mode Exit fullscreen mode

Agora podemos criar implementações diferentes para AuthRepository que cumpram o contrato, recebendo UserCredential e retornando Observable<AuthResponse>.

Como uma implementação que envia uma requisição HTTP.

class AuthHttpRepositoryImpl implements AuthRepository {
  constructor(private http: HttpClient) {}

  signIn(value: UserCredential) {
    return this.http.post<AuthResponse>('/api/auth', value);
  }
}
Enter fullscreen mode Exit fullscreen mode

E outra como stub, usada apenas durante a execução de testes automatizados.

class AuthStubRepositoryImpl implements AuthRepository {
  signIn(value: UserCredential) {
    return of(
      { accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm...' }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Então se uma classe A depende da classe base B, então uma classe derivada C de B deve ser capaz de ser usada como B, sem afetar o software.

  • A: SignInUseCase
  • B: AuthRepository
  • C: AuthMockRepositoryImpl e AuthMockRepositoryImpl

Assim o código se torna mais flexível e escalável, pois novas classes derivadas podem ser adicionadas sem efeitos colaterais indesejados, além de ajudar na prevenção de bugs, pois classes derivadas devem ser testadas com relação aos contratos definidos nas classes base.

Princípio da Segregação de Interface

Uma classe deve ter interfaces específicas para as suas funcionalidades, em vez de depender de uma única interface que contenha todas as funcionalidades possíveis.

Por exemplo, se uma classe precisa apenas de um método de leitura de dados, ela deve implementar uma interface que contenha apenas esse método, em vez de depender de uma interface que contenha também métodos de escrita e exclusão de dados.

abstract class UserRepository implements FindOneBy<User> {
  abstract findOneBy<K extends keyof User>(
    key: K,
    value: User[K]
  ): Observable<User>;
}
Enter fullscreen mode Exit fullscreen mode

O código se torna mais coeso e menos acoplado, pois dependem apenas de interfaces que precisam, pois temos interfaces mais granulares e específicas, o que facilita a manutenção e reutilização de código. O ISP incentiva a modularidade para que interfaces possam ser adicionadas para atender a novas funcionalidades, sem afetar classes existentes.

Dependency Inversion Principle ou Princípio da Inversão de Dependência

Uma classe de alto nível deve depender de uma interface ou classe abstrata ao invés de depender de uma classe concreta de baixo nível.

Um bom exemplo é a implementação que apresentei no Princípio da Substituição de Liskov em que a classe SignInUseCase depende de AuthRepository, que é uma classe abstrata. A substituição entre AuthHttpRepositoryImpl e AuthStubRepositoryImpl quando conveniente, é possível utilizando a técnica de injeção de dependências.

Então, em vez de uma classe de alto nível depender diretamente de uma classe de acesso a dados específica, ela deve depender de uma abstração genérica que represente as funcionalidades que ela precisa.

Alguns frameworks trabalham desta forma por padrão, como é o caso do Angular, quando este não é o padrão do framework que utilizamos podemos usar alguma biblioteca, como a Inversify ou tsyringe da Microsoft.

Assim o código se torna mais flexível e reutilizável, pois os módulos de alto nível são desacoplados dos módulos de baixo nível. Permitindo que diferentes implementações de baixo nível possam ser usadas com a mesma classe de alto nível, sem afetar o código a classe.

Este princípio recomenda um design orientado a interfaces, que é uma abordagem mais modular e escalável para o desenvolvimento de software. Permitindo que equipes diferentes trabalhem sem que uma dependa diretamente do código da outra.

Comenta aqui se você gostaria de ler uma publicação mostrando como funciona esta técnica, implementando o pattern dependency injection do zero. 🙂

Top comments (2)

Collapse
 
jakob18 profile image
jakob18

Muito obrigado, grandes exemplos, certamente vai ajudar devs com dificuldades!

Collapse
 
guiseek profile image
Guilherme Siquinelli

Que bom que os exemplos foram suficientes pra ajudar no entendimento!

Eu que agradeço seu feedback @jakob18 ! Isso incentiva a escrever mais, muito obrigado!