O que é e como aplicar Single Responsibility

Chegamos ao último post da série sobre SOLID. Nele, vamos abordar como implementar o S, ou seja, Single Responsibility Principle.

Para você que chegou agora e ainda não viu os outros posts da série, vou deixar os links aqui:

Definindo Single Responsibility

Em algumas literaturas, vamos encontrar a definição de Single Responsibility como: “uma classe só pode ter uma única função”.

Embora seja totalmente válida essa definição, infelizmente ela pode levar à alguns equívocos, como por exemplo, implementar uma classe para cada operação com o banco de dados.

Equívoco totalmente aceitável. Afinal, uma classe que salva e busca dados em um banco de dados, não estaria tendo duas funções?!

Nosso querido e famoso Uncle Bob define Single Responsibility Principle um pouco diferente. Ele diz que, “cada módulo de um software deve ter um e somente um motivo para ser alterado”.

Essa definição nos dá uma visão um pouco mais ampla. No meu entender, ela comporta melhor a ideia de ter uma classe para trabalhar todo tipo de transação com banco de dados. Afinal, essa classe ou módulo só precisará de mudanças quando algo relacionado com banco de dados precisar ser alterado.

Sem Single Responsibility

Para ficar mais clara a implementação das definições dadas acima, vamos iniciar com um código que não respeita Single Responsibility.

Nesse código, vamos criar duas structs. Uma como modelo do banco de dados, e outra como um serviço para envio de e-mail. Também vamos implementar uma função para iniciar o serviço. Dessa forma, garantimos que todos os parâmetros necessários para que ele funcione corretamente sejam passados.

Por fim, adicionaremos um método Send à nossa struct de serviço. Esse método irá salvar os dados no banco de dados E enviar um e-mail.

type EmailGorm struct {
	gorm.Model
	From    string
	To      string
	Subject string
	Message string
}

type EmailService struct {
	db           *gorm.DB
	smtpHost     string
	smtpPassword string
	smtpPort     int
}

func NewEmailService(db *gorm.DB, smtpHost string, smtpPassword string, smtpPort int) *EmailService {
	return &EmailService{
		db:           db,
		smtpHost:     smtpHost,
		smtpPassword: smtpPassword,
		smtpPort:     smtpPort,
	}
}

func (s *EmailService) Send(from string, to string, subject string, message string) error {
	email := EmailGorm{
		From:    from,
		To:      to,
		Subject: subject,
		Message: message,
	}

	err := s.db.Create(&email).Error
	if err != nil {
		log.Println(err)
		return err
	}
	
	auth := smtp.PlainAuth("", from, s.smtpPassword, s.smtpHost)
	
	server := fmt.Sprintf("%s:%d", s.smtpHost, s.smtpPort)
	
	err = smtp.SendMail(server, auth, from, []string{to}, []byte(message))
	if err != nil {
		log.Println(err)
		return err
	}

	return nil
}

Se você reparar bem na última frase antes do exemplo de código, verá que eu dei uma certa ênfase ao “E”. Fiz pois ele mostra que estamos desrespeitando o princípio de Single Responsibility.

Esse “E”, deixa bem claro que o método está fazendo mais de uma coisa, ou seja, tem mais de uma responsabilidade.

Além disso, ele também viola a definição do Uncle Bob, já que caso seja necessário qualquer alteração em relação ao modelo do banco de dados ou, ao processo de envio do e-mail, o mesmo método precisa ser alterado.

Com Single Responsibility

Para não violar o princípio de Single Responsibility, precisamos separar nosso código em outros blocos, dividindo assim as responsabilidades.

Ao analisar o “E” do exemplo anterior, é possível ver que, para dividir bem as responsabilidades, vamos precisar de uma struct para lidar com banco de dados e outra para lidar com envio do e-mail.

type EmailGorm struct {
	gorm.Model
	From    string
	To      string
	Subject string
	Message string
}

// BANCO DE DADOS
type EmailRepository interface {
	Save(from string, to string, subject string, message string) error
}

type EmailDBRepository struct {
	db *gorm.DB
}

func NewEmailRepository(db *gorm.DB) EmailRepository {
	return &EmailDBRepository{
		db: db,
	}
}

func (r *EmailDBRepository) Save(from string, to string, subject string, message string) error {
	email := EmailGorm{
		From:    from,
		To:      to,
		Subject: subject,
		Message: message,
	}

	err := r.db.Create(&email).Error
	if err != nil {
		log.Println(err)
		return err
	}

	return nil
}

// ENVIO DE E-MAIL
type EmailSender interface {
	Send(from string, to string, subject string, message string) error
}

type EmailSMTPSender struct {
	smtpHost     string
	smtpPassword string
	smtpPort     int
}

func NewEmailSender(smtpHost string, smtpPassword string, smtpPort int) EmailSender {
	return &EmailSMTPSender{
		smtpHost:     smtpHost,
		smtpPassword: smtpPassword,
		smtpPort:     smtpPort,
	}
}

func (s *EmailSMTPSender) Send(from string, to string, subject string, message string) error {
	auth := smtp.PlainAuth("", from, s.smtpPassword, s.smtpHost)

	server := fmt.Sprintf("%s:%d", s.smtpHost, s.smtpPort)

	err := smtp.SendMail(server, auth, from, []string{to}, []byte(message))
	if err != nil {
		log.Println(err)
		return err
	}

	return nil
}

// SERVIÇO
type EmailService struct {
	repository EmailRepository
	sender     EmailSender
}

func NewEmailService(repository EmailRepository, sender EmailSender) *EmailService {
	return &EmailService{
		repository: repository,
		sender:     sender,
	}
}

func (s *EmailService) Send(from string, to string, subject string, message string) error {
	err := s.repository.Save(from, to, subject, message)
	if err != nil {
		return err
	}

	return s.sender.Send(from, to, subject, message)
}

Além das novas structs, notem que foram criadas as interfaces EmailRepository e EmailSender. Essas interfaces ajudam manter um service agnóstico a implementação do como e por quem as tarefas serão realizadas.

Em outras palavras, caso no futuro decidirmos que o envio será feito pelo MailGun, desde que a implementação siga a interface EmailSender, nada precisará ser alterado na implementação do service.

Tiago, mas o método Send do EmailService não continua com duas responsabilidades?

Na verdade não! Ele só delega a responsabilidade na execução das tarefas para as outras structs. No final, ele continua tendo somente uma responsabilidade, delegar as execuções para outras structs.

Identificar métodos com a responsabilidade de delegar tarefas é simples. Faça a seguinte pergunta para si mesmo: “Se eu remover esse método, continuo tendo a capacidade de executar as tarefas nele presentes?”

Se a resposta for sim, então você tem um método cuja a responsabilidade é somente delegar. Se a resposta for não e, ele estiver executando mais de uma tarefa, então provavelmente você está violando o princípio de Single Responsibility.

Conclusão

Assim como os outros princípios do SOLID, Single Responsibility nos ajuda a ter um código extensível, fácil para testar e muito mais fácil para dar manutenção.

Com esse post, chegamos ao fim da série sobre SOLID em Golang.

Espero que você tenha gostado da série e que ela tenha sido útil para você.

Abraço e até a próxima!


Se inscreva na nossa newsletter

* indicates required

Deixe uma resposta