Olá, pessoal! Estou praticando a abordagem de learn in public para compartilhar minha jornada e, ao mesmo tempo, demonstrar minhas habilidades técnicas enquanto me preparo para novas oportunidades como desenvolvedor. Neste artigo, resolvo o desafio da Uber de criar um serviço que recebe informações para disparar emails usando e abstraindo vários email services providers. Aqui, apresento meu processo de como estruturo e implemento esse serviço em Java com Spring Boot utilizando o SendGrid da Twillio e o SES da AWS, seguindo os princípios de uma arquitetura limpa.
A ideia é mostrar, de forma transparente, desde a definição da estrutura de pastas até a implementação dos gateways para diferentes serviços de e-mail e o tratamento global de exceções. Espero que esse conteúdo sirva de inspiração e aprendizado para quem deseja aplicar boas práticas de desenvolvimento em seus projetos.
Estrutura de pastas
Primeiramente, gosto de definir a estrutura de pastas que vou usar:
-
application
: Camada de mais alto nível da nossa aplicação-
controllers
: Pontos de entrada da nossa aplicação. Endpoints. Utilizam services para executar os casos de uso -
services
: Classes que implementam os casos de usos da nossa aplicação
-
-
core
: Camada de mais baixo nível da nossa aplicação. Contém o essencial da aplicação-
domain
: Contém nossos modelos de domínio, no caso, apenas oEmailModel
-
usecases
: Contém os casos de uso, no caso, apenas oSendEmailUseCase
-
-
gateways
: Interfaces de comunicação para o “mundo exterior” -
infra
: Implementações dos nossos gateways. No caso temosSendGridEmailSender
eSesEmailSender
Camada domínio
Com a estrutura feita, comecei a implementação pelo record EmailModel
, que representa o email que queremos mandar, ele tem os atributos subject (o assunto do email), receiver (email do destinatário) e body (o conteúdo do email). Nesse caso, para não aumentar a complexidade do projeto, optei por considerar o conteúdo do email apenas uma String simples, ou seja, não vou dar suporte para html ou anexos.
// core.domain.EmailModel
public record EmailModel(
String subject,
String body,
@NotBlank(message = "The receiver email can't be empty")
@Email(message = "The receiver email is invalid")
String receiver
) {
}
Também usei algumas anotações para posteriormente realizar a validação do email que vamos mandar, @NotBlank
para não aceitar e-mails vazios ou nulos e @Email
para aceitar apenas Strings de e-mails válidos.
Depois vamos criar nosso caso de uso, o SendEmailUseCase. O controller na nossa camada de aplicação fará uso de alguma classe que implemente essa interface.
// core.usecases.SendEmailUseCase
public interface SendEmailUseCase {
void sendEmail(@Valid EmailModel email);
}
Camada de infraestrutura
Agora vamos criar a interface que representará o nosso gateway para o mundo externo, ou seja, a interface que vamos utilizar nos gateways que se comunicarão com o SendGrid e a Aws SES.
// gateways.EmailSenderGateway
@Component
public interface EmailSenderGateway {
void sendEmail(@Valid EmailModel email) throws EmailSenderGatewayException;
}
Com isso feito, podemos utilizar essa interfaces nas nossas implementações.
// infra.twillio_sendgrid.SendGridEmailSender
@Service
public class SendGridEmailSender implements EmailSenderGateway {
private static final Log log = LogFactory.getLog(SendGridEmailSender.class);
private final SendGrid sendGrid;
private final String verifiedEmail = Dotenv.load().get("VERIFIED_EMAIL");
public SendGridEmailSender(SendGrid sendGrid) {
this.sendGrid = sendGrid;
}
@Override
public void sendEmail(@Valid EmailModel email) throws EmailSenderGatewayException {
log.info("SendGrid - Tentando enviar email: " + email);
final Email from = new Email(verifiedEmail);
final Email to = new Email(email.receiver());
final String subject = email.subject();
final Content body = new Content("text/plain", email.body());
final Mail mail = new Mail(from, subject, to, body);
final Request request = new Request();
try {
request.setMethod(Method.POST);
request.setEndpoint("mail/send");
request.setBody(mail.build());
sendGrid.api(request);
} catch (Exception e) {
log.error("SendGrid - Erro ao enviar email " + e);
throw new EmailSenderGatewayException("Falha ao enviar email", e);
}
}
}
// infra.aws_ses.SesEmailSender
@Service
public class SesEmailSender implements EmailSenderGateway {
private static final Log log = LogFactory.getLog(SesEmailSender.class);
private final SesClient sesClient;
private final String verifiedEmail = Dotenv.load().get("VERIFIED_EMAIL");
public SesEmailSender(SesClient sesClient) {
this.sesClient = sesClient;
}
@Override
public void sendEmail(@Valid EmailModel emailModel) throws EmailSenderGatewayException {
log.info("SES - Tentando enviar email: " + emailModel);
final SendEmailRequest request = SendEmailRequest.builder()
.source(verifiedEmail)
.destination(Destination.builder().toAddresses(emailModel.receiver()).build())
.message(Message.builder()
.body(Body.builder().text(Content.builder().data(emailModel.body()).build()).build())
.subject(Content.builder().data(emailModel.subject()).build())
.build())
.build();
try {
sesClient.sendEmail(request);
} catch (Exception e) {
log.error("SES - Erro ao enviar email " + e);
throw new EmailSenderGatewayException("Falha ao enviar email", e);
}
}
}
Camada da aplicação
Com isso feito, podemos criar a classe EmailSenderService, que vai implementar nosso caso de uso.
// application.services.EmailSenderService
@Service
public class EmailSenderService implements SendEmailUseCase {
private final List<EmailSenderGateway> emailSenderGateways;
public EmailSenderService(List<EmailSenderGateway> emailSenderGateways) {
this.emailSenderGateways = emailSenderGateways;
}
@Override
public void sendEmail(@Valid EmailModel emailModel) {
for (final EmailSenderGateway gateway : emailSenderGateways) {
try {
gateway.sendEmail(emailModel);
return;
} catch (EmailSenderGatewayException e) {
if (gateway == emailSenderGateways.getLast()) {
throw new EmailSenderServiceException("All email gateways are down", e);
}
}
}
}
}
Nessa implementação de caso de uso, vamos tentar enviar o email por um dos gateways implementados, caso ele não esteja disponível, vamos tentar enviar pelo próximo, caso nenhum funcione, uma exceção é lançada.
Agora só nos falta criar o controller que vai receber as requisições e adicionar um tratamento de erros melhor à nossa aplicação.
// application.controllers.EmailSenderController
@RestController
@RequestMapping("send/")
public class EmailSenderController {
private final EmailSenderService emailSenderService;
public EmailSenderController(EmailSenderService emailSenderService) {
this.emailSenderService = emailSenderService;
}
@PostMapping
public ResponseEntity<Void> sendEmail(@RequestBody @Valid EmailModel email) {
emailSenderService.sendEmail(email);
return ResponseEntity.ok().build();
}
}
Tratamento de exceções
Para tratar as exceções da aplicação, primeiro defini a forma que a aplicação vai representar uma exceção ou um erro na resposta de uma requisição Http, para isso criei a classe ApiException com os dados que achei importantes para uma boa representação de erro da API.
// application.controllers.exception_handling.ApiException
@Builder
@Value
@JsonInclude(Include.NON_EMPTY)
public class ApiException {
HttpStatus status;
String message;
String path;
@Builder.Default
LocalDateTime timestamp = LocalDateTime.now();
Object extraInfo;
public Integer getStatusCode() {
return status == null ? null : status.value();
}
public ResponseEntity<Object> toResponseEntity() {
return ResponseEntity.status(getStatus()).body(this);
}
}
Criei um ControllerAdvice que vai capturar todas as exceções não tratadas e responder com um ResponseEntity adequado. Nele, criei o método handleEmailServiceException, que vai ser chamado quando não for possível enviar o email, como quando ambos os serviços de email estiverem com problemas. Também sobrescrevi o método handleMethodArgumentNotValid, “MethodArgumentNotValidException” é a exceção lançada quando passamos um objeto inválido como argumento para um método no qual o parâmetro está anotado com @Valid
, ou seja. Nesse caso, quando o email recebido na requisição não for válido.
// application.controllers.exception_handling.GlobalExceptionHandler
@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(EmailSenderServiceException.class)
public ResponseEntity<Object> handleEmailServiceException(EmailSenderServiceException e) {
return ApiException.builder()
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.message(e.getMessage())
.build()
.toResponseEntity();
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException e, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
final Map<String, Map<String, String>> errors = new HashMap<>();
e.getBindingResult().getAllErrors().forEach((error) -> {
final String fieldName = ((FieldError) error).getField();
final String errorMessage = error.getDefaultMessage();
String rejectedValue = null;
if (((FieldError) error).getRejectedValue() != null) {
rejectedValue = ((FieldError) error).getRejectedValue().toString();
}
final Map<String, String> errorMap = new HashMap<>();
errorMap.put("message", errorMessage);
errorMap.put("rejectedValue", rejectedValue);
errors.put(fieldName, errorMap);
});
return ApiException.builder()
.status(HttpStatus.BAD_REQUEST)
.message("Invalid request body")
.extraInfo(errors)
.build().toResponseEntity();
}
}
Construir este serviço me ensinou muito sobre design de sistemas tolerantes a falhas e a importância de uma arquitetura bem planejada. É claro que devem ter diversos pontos que podem ser melhorados, então deixem sugestões se tiverem.
Também criei testes automatizados e de integração para essa aplicação, mas isso fica para outro post.
Além disso planejo também criar um front-end simples para integrar nessa API.
Atualmente, estou buscando oportunidades como desenvolvedor back-end Java/Spring Jr remoto. Se você conhece vagas ou quer trocar ideias sobre arquitetura de software, conecte-se comigo no LinkedIn
Projeto completo no GitHub: https://github.com/carloscardoso05/Uber-Email-Service-Code-Challenge
Top comments (0)