DEV Community

Carlos Cardoso
Carlos Cardoso

Posted on

Resolvendo Uber Email Service Coding Challenge com Spring Boot

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:

Estrutura de pastas do projeto

  • 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 o EmailModel
    • usecases: Contém os casos de uso, no caso, apenas o SendEmailUseCase
  • gateways: Interfaces de comunicação para o “mundo exterior”
  • infra: Implementações dos nossos gateways. No caso temos SendGridEmailSender e SesEmailSender

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
) {
}
Enter fullscreen mode Exit fullscreen mode

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);
}

Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
// 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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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)