Partes Anteriores:
Continuamos a considerar abordagens para tornar nosso código limpo, flexível e de fácil manutenção. E agora, vamos começar a investigar soluções arquitetônicas limpas.
Os princípios SOLID foram introduzidos por Robert C. Martin e são amplamente considerados como melhores práticas em programação orientada a objetos e design de software.
Neste artigo, daremos uma olhada nos três primeiros princípios SOLID: O Princípio da Responsabilidade Única, o Princípio Aberto/Fechado e o Princípio da Substituição de Liskov em exemplos práticos.
Uma classe deve ter apenas um motivo para mudar
Em outras palavras, uma classe deve ter uma única responsabilidade ou trabalho. É importante porque uma classe com uma única responsabilidade é mais fácil de entender, modificar e manter. As alterações em uma área do código não afetarão todo o sistema, reduzindo o risco de introdução de bugs.
Vejamos um exemplo prático e maneiras de seguir o princípio da responsabilidade única:
// Bad class UserSettingsService { constructor(user: IUser) { this.user = user; } changeSettings(settings: IUserSettings): void { if (this.isUserValidated()) { // ... } } getUserInfo(): Promise<IUserSettings> { // ... } async isUserValidated(): Promise<boolean> { const userInfo = await this.getUserInfo(); // ... } }
Neste exemplo, nossa classe realiza ações em diferentes direções: configura o contexto, altera-o e valida-o.
Para seguir o SRP, precisamos dividir essas diferentes responsabilidades.
// Better class UserAuth { constructor(user: IUser) { this.user = user; } getUserInfo(): Promise<IUserSettings> { // ... } async isUserValidated(): boolean { const userInfo = await this.getUserInfo(); // ... } } class UserSettings { constructor(user: IUser) { this.user = user; this.auth = new UserAuth(user); } changeSettings(settings: IUserSettings): void { if (this.auth.isUserValidated()) { // ... } } }
O SRP visa aprimorar a modularidade do código, minimizando os desafios decorrentes de interdependências. Ao organizar o código em funções, classes separadas e promover a modularidade, ele se torna mais reutilizável, economizando tempo que poderia ser gasto na recodificação da funcionalidade existente.
Entidades de software (classes, módulos, funções) devem estar abertas para extensão, mas fechadas para modificação
Você poderá adicionar novas funcionalidades sem alterar o código existente. É importante porque, ao aderir a este princípio, você pode introduzir novos recursos ou componentes ao seu sistema sem arriscar a estabilidade do código existente.
Promove a reutilização do código e reduz a necessidade de mudanças extensas ao estender a funcionalidade.
// Bad class Product { id: number; name: string[]; price: number; protected constructor(id: number, name: string[], price: number) { this.id = id; this.name = name; this.price = price; } } class Ananas extends Product { constructor(id: number, name: string[], price: number) { super(id, name, price); } } class Banana extends Product { constructor(id: number, name: string[], price: string) { super(id, name, price); } } class HttpRequestCost { constructor(product: Product) { this.product = product; } getDeliveryCost(): number { if (product instanceOf Ananas) { return requestAnanas(url).then(...); } if (product instanceOf Banana) { return requestBanana(url).then(...); } } } function requestAnanas(url: string): Promise<ICost> { // logic for ananas } function requestBanana(url: string): Promise<ICost> { // logic for bananas }
Neste exemplo, o problema está na classe HttpRequestCost
, que, no método getDeliveryCost
contém condições para o cálculo de diferentes tipos de produtos, e utilizamos métodos separados para cada tipo de produto. Portanto, se precisarmos adicionar um novo tipo de produto, devemos modificar a classe HttpRequestCost
, e isso não é seguro; poderíamos obter resultados inesperados.
Para evitá-lo, devemos criar uma request
de método abstrato na classe Product
sem realizações. A realização particular terá herdado as classes: Ananas e Banana. Eles realizarão o pedido por si mesmos.
HttpRequestCost
usará o parâmetro product
seguindo a interface da classe Product
, e quando passarmos dependências específicas em HttpRequestCost
, ele já realizará o método request
para si mesmo.
// Better abstract class Product { id: number; name: string[]; price: string; constructor(id: number, name: string[], price: string) { this.id = id; this.name = name; this.price = price; } abstract request(url: string): void; } class Ananas extends Product { constructor(id: number, name: string[], price: string) { super(id, name, price); } request(url: string): void { // logic for ananas } } class Banana extends Product { constructor(id: number, name: string[], price: string) { super(id, name, price); } request(url: string): void { // logic for bananas } } class HttpRequestCost { constructor(product: Product) { this.product = product; } request(): Promise<void> { return this.product.request(url).then(...); } }
Seguindo este princípio, você reduzirá o acoplamento de código e salvará o sistema de comportamentos imprevisíveis.
Os objetos de uma superclasse devem ser substituídos por objetos de suas subclasses sem interromper a aplicação.
Para entender esse princípio, vamos dar uma olhada neste exemplo:
// Bad class Worker { work(): void {/../} access(): void { console.log('Have an access to closed perimeter'); } } class Programmer extends Worker { createDatabase(): void {/../} } class Seller extends Worker { sale(): void {/../} } class Designer extends Worker { access(): void { throwError('No access'); } }
Neste exemplo, temos um problema com a classe Contractor
. Designer
, Programmer
e Seller
são todos Workers e herdaram da classe pai Worker
. Mas, ao mesmo tempo, os Projetistas não têm acesso ao perímetro fechado porque são Contratantes e não Funcionários. E substituímos o método access
e quebramos o Princípio da Substituição de Liskov.
Este princípio nos diz que se substituirmos a superclasse Worker
por sua subclasse, por exemplo a classe Designer
, a funcionalidade não deverá ser quebrada. Mas se fizermos isso, a funcionalidade da classe Programmer
será quebrada - o método access
terá realizações inesperadas da classe Designer
.
Seguindo o Princípio da Substituição de Liskov, não devemos reescrever as realizações em subclasses, mas precisamos criar novas camadas de abstração onde definimos realizações particulares para cada tipo de abstração.
Vamos corrigir:
// Better class Worker { work(): void {/../} } class Employee extends Worker { access(): void { console.log('Have an access to closed perimeter'); } } class Contractor extends Worker { addNewContract(): void {/../} } class Programmer extends Employee { createDatabase(): void {/../} } class Saler extends Employee { sale(): void {/../} } class Designer extends Contractor { makeDesign(): void {/../} }
Criamos novas camadas de abstrações Employee
e Contractor
e movemos o método access
para a classe Employee
e definimos realização específica. Se substituirmos a classe Worker
pela subclasse Contractor
, a funcionalidade Worker
não será quebrada.
Se você aderir ao LSP, poderá substituir qualquer instância de subclasse sempre que uma instância de classe base for esperada, e o programa ainda deverá funcionar conforme planejado. Isso promove a reutilização e a modularidade do código e torna seu código mais resiliente a mudanças.
No próximo artigo, daremos uma olhada nos princípios SOLID de segregação de interface e inversão de dependência.