Introdução
SOLID é um acrônimo mnemônico para cinco princípios de design orientado a objetos que foram introduzidos por Robert C. Martin, que é popularmente conhecido como Uncle Bob. Esses princípios têm como objetivo ajudar designers, arquitetos, engenheiros e desenvolvedores de software a criar softwares mais flexíveis, fáceis de manter e escaláveis. Ao seguir esses princípios, você pode projetar classes que são mais fáceis de testar, refatorar, reutilizar e estender.
O acrônimo SOLID significa:
S – Princípio da Responsabilidade Única
O – Princípio Aberto-Fechado
L – Princípio da Substituição de Liskov
I – Princípio da Segregação de Interfaces
D – Princípio da Inversão de Dependência
Neste artigo, explicaremos cada princípio individualmente para entender como ele pode ajudar você a escrever um código melhor. Além disso, adicionaremos trechos de código para cada princípio para mostrar como você pode aplicá-los em sua jornada de programação, bem como o que você deve evitar na arquitetura de código limpo. Para demonstrar os conceitos, usaremos a linguagem de programação Kotlin desenvolvida por JetBrains e colaboradores de código aberto.
Princípio da Responsabilidade Única
O Princípio da Responsabilidade Única (SRP) é um princípio de design de software que estabelece que cada classe ou módulo em um programa deve ter uma única responsabilidade bem definida. Isso significa que uma classe deve ter apenas um motivo para mudar, e deve ser responsável por apenas uma única parte da funcionalidade do programa.
Considere o seguinte trecho de código como um exemplo de como este princípio pode ser aplicado em Kotlin:
|
1 2 3 4 5 6 7 8 9 |
data class User( val firstName: String, val lastName: String, val email: String ) |
A palavra-chave data em Kotlin indica que esta classe é uma classe de dados, o que significa que ela se destina a conter dados e não possui nenhum comportamento complexo. Usando esta classe de dados, podemos criar uma classe UserService para gerenciar usuários, conforme mostrado abaixo:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class UserService { fun createUser(user: User) { // código para criar um usuário no banco de dados } fun deleteUser(user: User) { // código para excluir um usuário do banco de dados } fun updateUser(user: User) { // código para atualizar um usuário no banco de dados } fun getUser(id: Int): User { // código para recuperar um usuário do banco de dados } } |
Neste exemplo, a classe UserService tem uma única responsabilidade: gerenciar usuários em um banco de dados. Cada usuário é representado pelo data class User compartilhado anteriormente. Todos os métodos na classe UserService são relacionados a essa responsabilidade e, portanto, são coesos. Isso torna a classe mais fácil de entender e manter, pois fica claro que todos os métodos da classe estão relacionados a uma única tarefa bem definida.
Um método que viola o Princípio da Responsabilidade Única (SRP) na classe UserService seria aquele que não está relacionado à responsabilidade primária da classe, que é gerenciar usuários em um banco de dados. For exemplo, considere a seguinte variação da classe UserService:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class UserService { fun createUser(user: User) { // código para criar um usuário no banco de dados } fun deleteUser(user: User) { // código para excluir um usuário do banco de dados } fun updateUser(user: User) { // código para atualizar um usuário no banco de dados } fun getUser(id: Int): User { // código para recuperar um usuário do banco de dados } fun sendEmail(user: User, subject: String, message: String) { // código para enviar um e-mail para o usuário } } |
Neste exemplo, o método sendEmail não se relaciona com a responsabilidade primária da classe UserService, que é gerenciar usuários em um banco de dados. Este método é responsável por enviar e-mails, o que é uma preocupação separada do gerenciamento de usuários. Como resultado, este método viola o SRP, pois introduz um segundo motivo para a classe UserService ser alterada.
Para aderir ao Princípio da Responsabilidade Única, seria melhor separar a funcionalidade de envio de e-mail em uma classe separada, como uma classe EmailService . Isso permitiria que a classe UserService focasse em sua responsabilidade primária de gerenciar usuários, e a classe EmailService focasse em sua responsabilidade de enviar e-mails.
Você deve notar que o Princípio da Responsabilidade Única não se trata do número de métodos que uma classe possui, mas sim da coesão dos métodos e da clara separação de responsabilidades dentro de uma classe.
Princípio Aberto-Fechado
O Princípio Aberto-Fechado (OCP) é um princípio de design de software que estabelece que entidades de software (como classes, módulos ou funções) devem estar abertas para extensão, mas fechadas para modificação. Isso significa que deve ser possível adicionar novas funcionalidades a uma classe ou módulo sem alterar seu código existente.
Considere a classe UserService do exemplo anterior. Suponha que queiramos adicionar um novo recurso à classe UserService que nos permita buscar usuários por endereço de e-mail. Uma maneira de fazer isso seria adicionar um novo método à classe UserService , conforme destacado no código abaixo:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class UserService { fun createUser(user: User) { // código para criar um usuário no banco de dados } fun deleteUser(user: User) { // código para excluir um usuário do banco de dados } fun updateUser(user: User) { // código para atualizar um usuário no banco de dados } fun getUser(id: Int): User { // código para recuperar um usuário do banco de dados } fun searchUsersByEmail(email: String): List<User> { // código para buscar usuários por endereço de e-mail } } |
Essa abordagem funciona, mas viola o Princípio Aberto-Fechado, pois tivemos que modificar a classe existente UserService para adicionar o novo recurso. Uma abordagem melhor seria usar herança ou composição para estender a funcionalidade da classe UserService sem modificar seu código.
Para conseguir isso, poderíamos criar uma nova classe chamada UserSearchService que estende a classe UserService e adiciona a funcionalidade de busca por e-mail:
|
1 2 3 4 5 6 7 8 9 |
class UserSearchService(private val userService: UserService) : UserService() { fun searchUsersByEmail(email: String): List<User> { // código para buscar usuários por endereço de e-mail } } |
Neste exemplo, a classe UserSearchService está aberta para extensão, pois fornece funcionalidade adicional além do que é oferecido pela classe UserService classe. Ao mesmo tempo, a UserService classe permanece fechada para modificação, pois não precisamos modificar seu código para adicionar o recurso de busca por e-mail.
Princípio da Substituição de Liskov
O Princípio da Substituição de Liskov (LSP) é um princípio de design de software que afirma que os objetos de uma superclasse devem ser capazes de ser substituídos por objetos de uma subclasse sem afetar a correção do programa. Isso significa que uma subclasse deve ser um substituto válido para sua superclasse e deve se comportar da mesma forma que a superclasse quando usada no mesmo contexto.
Continuaremos a demonstração usando a User e a UserService classes dos exemplos anteriores. Para permitir que a classe User seja estendida, usamos a open palavra-chave no Kotlin:
|
1 2 3 4 5 6 7 8 9 |
open class User( val firstName: String, val lastName: String, val email: String ) |
Aqui está a classe UserService original que usa a User classe de dados acima:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class UserService { fun createUser(user: User) { // código para criar um usuário no banco de dados } fun deleteUser(user: User) { // código para excluir um usuário do banco de dados } fun updateUser(user: User) { // código para atualizar um usuário no banco de dados } fun getUser(id: Int): User { // código para recuperar um usuário do banco de dados } } |
Suponha que queiramos criar uma subclasse de User chamada AdminUser que representa usuários com privilégios administrativos. Poderíamos fazer isso assim:
|
1 2 3 4 5 6 7 8 9 |
class AdminUser( firstName: String, lastName: String, email: String, ) : User(firstName, lastName, email) |
Neste exemplo, a classe AdminUser é uma substituta válida para a classe User, pois se comporta da mesma forma que a classe User e pode ser usada onde quer que um objeto User seja esperado. Por exemplo, podemos usar a classe AdminUser com a classe UserService assim:
|
1 2 3 4 5 6 7 8 9 |
fun main() { val userService = UserService() val adminUser = AdminUser("John", "Doe", "john.doe@example.com") userService.createUser(adminUser) } |
Este código está correto, pois a classe AdminUser é uma substituta válida para a classe User e pode ser usada da mesma forma que um objeto User.
É importante notar que o Princípio da Substituição de Liskov é mais do que apenas herança. Trata-se de garantir que os objetos de uma subclasse se comportem da mesma forma que os objetos da superclasse, independentemente de como a subclasse seja implementada. Por exemplo, se a classe AdminUser se comportasse de maneira diferente da classe User de alguma forma, isso violaria o Princípio da Substituição de Liskov, pois não seria uma substituta válida para a classe User classe.
Princípio da Segregação de Interfaces
O Princípio da Segregação de Interfaces (ISP) é um princípio de design de software que afirma que os clientes não devem ser forçados a depender de interfacesque não utilizam. Isso significa que geralmente é uma boa ideia criar interfaces pequenas e focadas que façam bem uma única coisa, em vez de criar interfaces grandes e de uso geral que tentam fazer muitas coisas.
Aqui está um exemplo de como esse princípio pode ser aplicado reescrevendo as classes User e UserService dos exemplos anteriores:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
interface UserService { fun createUser(user: User) fun deleteUser(user: User) fun updateUser(user: User) fun getUser(id: Int): User } class DatabaseUserService : UserService { override fun createUser(user: User) { // código para criar um usuário no banco de dados } override fun deleteUser(user: User) { // código para excluir um usuário do banco de dados } override fun updateUser(user: User) { // código para atualizar um usuário no banco de dados } override fun getUser(id: Int): User { // código para recuperar um usuário do banco de dados } } |
Neste exemplo, a UserService interface define quatro métodos que estão relacionados ao gerenciamento de usuários em um banco de dados. A DatabaseUserService classe implementa essa interface e fornece implementações concretas para esses métodos.
Suponha que queiramos adicionar um novo recurso à UserService interface que nos permita buscar usuários por endereço de e-mail. Uma maneira de fazer isso seria adicionar um novo método à UserService interface:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
interface UserService { fun createUser(user: User) fun deleteUser(user: User) fun updateUser(user: User) fun getUser(id: Int): User fun searchUsersByEmail(email: String): List<User> } |
Seu código não será executado, a menos que você também implemente esse método em todas as classes que implementam a UserService interface:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class DatabaseUserService : UserService { override fun createUser(user: User) { // código para criar um usuário no banco de dados } override fun deleteUser(user: User) { // código para excluir um usuário do banco de dados } override fun updateUser(user: User) { // código para atualizar um usuário no banco de dados } override fun getUser(id: Int): User { // código para recuperar um usuário do banco de dados } override fun searchUsersByEmail(email: String): List<User> { // código para buscar usuários por endereço de e-mail } } |
Embora essa abordagem funcione, ela viola o Princípio da Segregação de Interface, pois força a DatabaseUserService classe a implementar um método ( searchUsersByEmail ) que ela pode não precisar ou usar.
Uma abordagem melhor seria criar uma interface separada para a funcionalidade de busca por e-mail:
|
1 2 3 4 5 |
interface UserSearchService { fun searchUsersByEmail(email: String): List<User> } |
Agora temos interfaces separadas, pequenas e focadas, ou seja, UserService e UserSearchServiceque têm uma única responsabilidade. Uma classe que necessite de todas as funcionalidades destas interfaces pode implementá-las como mostrado no trecho de código abaixo:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class DatabaseUserService : UserService, UserSearchService { override fun createUser(user: User) { // código para criar um usuário no banco de dados } override fun deleteUser(user: User) { // código para excluir um usuário do banco de dados } override fun updateUser(user: User) { // código para atualizar um usuário no banco de dados } override fun getUser(id: Int): User { // código para recuperar um usuário do banco de dados } override fun searchUsersByEmail(email: String): List<User> { // código para buscar usuários por endereço de e-mail } } |
Isso adere ao Princípio da Segregação de Interface, pois garante que os clientes (como a DatabaseUserService classe) não sejam forçados a depender de interfaces que não utilizam.
Para entender melhor esse conceito, suponha que tenhamos outra classe chamada MemoryUserService que implementa a UserService interface, mas não precisa da funcionalidade de busca por e-mail, podemos escrever o código assim:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class MemoryUserService : UserService { private val users = mutableListOf<User>() override fun createUser(user: User) { users.add(user) } override fun deleteUser(user: User) { users.remove(user) } override fun updateUser(user: User) { val index = users.indexOf(user) if (index >= 0) { users[index] = user } } override fun getUser(id: Int): User { return users[id] } } |
Neste exemplo, a MemoryUserService classe só precisa implementar os métodos definidos na UserService interface, e não precisa se preocupar com a funcionalidade de busca por e-mail. Isso permite que a MemoryUserService classe se concentre em sua responsabilidade primária de gerenciar usuários em memória, em vez de ser forçada a implementar uma funcionalidade não relacionada.
Dependency Inversion Principle
O Princípio da Inversão de Dependência (DIP) é um princípio de design de software que estabelece que módulos de alto nível não devem depender de módulos de baixo nível, mas sim que ambos devem depender de abstrações. Isso significa que geralmente é uma boa ideia projetar seu software de forma que os componentes de alto nível não fiquem atrelados a implementações específicas de componentes de baixo nível, mas sim dependam de abstrações (como interfaces ou classes abstratas) que possam ser implementadas de diferentes maneiras.
Vejamos um exemplo de como esse princípio pode ser aplicado às User e UserService classes usadas nos trechos de código anteriores:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
interface UserRepository { fun create(user: User) fun delete(user: User) fun update(user: User) fun get(id: Int): User } class UserService(private val repository: UserRepository) { fun createUser(user: User) { repository.create(user) } fun deleteUser(user: User) { repository.delete(user) } fun updateUser(user: User) { repository.update(user) } fun getUser(id: Int): User { return repository.get(id) } } |
Neste exemplo, a classe UserService depende de uma abstração chamada UserRepository interface, em vez de depender de uma implementação específica de um repositório de usuários. Isso nos permite implementar a interface UserRepository de diferentes maneiras, como com um banco de dados ou armazenamento em memória, sem afetar a classe UserService.
Por exemplo, aqui está uma implementação da interface UserRepository que usa um banco de dados:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class DatabaseUserRepository: UserRepository { override fun create(user: User) { // código para criar um usuário no banco de dados } override fun delete(user: User) { // código para excluir um usuário do banco de dados } override fun update(user: User) { // código para atualizar um usuário no banco de dados } override fun get(id: Int): User { // código para recuperar um usuário do banco de dados } } |
Aqui está outra implementação da interface UserRepository que usa armazenamento em memória:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class MemoryUserRepository : UserRepository { private val users = mutableListOf<User>() override fun create(user: User) { users.add(user) } override fun delete(user: User) { users.remove(user) } override fun update(user: User) { val index = users.indexOf(user) if (index >= 0) { users[index] = user } } override fun get(id: Int): User { return users[id] } } |
Isso torna o sistema mais flexível e fácil de manter, pois nos permite alterar a implementação do repositório sem afetar o restante do sistema. Também torna mais fácil testar a classe UserService, pois podemos simular a dependência UserRepository em nossos testes.
Conclusão
Neste artigo, falamos sobre os cinco princípios do SOLID código e trechos de código compartilhados que satisfazem cada princípio. Aderir a esses princípios pode ajudar você a projetar sistemas de software que sejam mais flexíveis, fáceis de manter e escaláveis. No entanto, é importante ter em mente que esses princípios são diretrizes, em vez de regras rígidas, e cabe ao desenvolvedor decidir quando e como aplicá-los no contexto de seu projeto específico. Continue seu aprendizado conferindo nosso blog para mais artigos detalhados e atualizados e tutoriais sobre computação em nuvem e DevOps, design e desenvolvimento de software, tendências tecnológicas para ficar de olho e muito mais.
Boa programação!
Comentários
Nenhum comentário ainda. Seja o primeiro.