Sending Activity Notifications Based on REST API Calls with Aspects and Event Listeners

Spring May 22, 2020

In this post I will show a basic implementation of sending email notifications to your users when certain actions are completed via REST calls, such as create, update, delete, etc. You can see the interaction between components in the following sequence diagram.

Let's say we have an entity Item and the ItemService with the following methods.

public class ItemService {
	public Item save(Item item);
	public Item update(Long id, Item item);
	public void deleteItemById(Long id);
}
ItemService.java

So we want to inform our users by sending emails when a user modifies the item table with create, update or delete actions. We will split the responsibilities of

  • detecting an action took place
  • handling the results of that action.

Detecting actions will be done in Aspects. Aspects will be placed between controller and service and act as a proxy to catch method calls.  Once the action is detected, we need to handle it according to who is making the call, what type of action is it, what arguments are sent, what is the result sent back to caller. Handling the results of the action is done by the event listeners.

We can store the event specific details in enum variable.

public enum EventTypeSpecs {

    CREATE ("create", "createMailTemp"),
    UPDATE ("update", "updateMailTemp"),
    DELETE ("delete", "deleteMailTemp");

    private String modificationType;
    private String emailTemplateName;

    public String getModificationType() {
        return modificationType;
    }

    public String getEmailTemplateName() {
        return emailTemplateName;
    }

    EventTypeSpecs(String modificationType, String emailTemplateName) {
        this.modificationType = modificationType;
        this.emailTemplateName = emailTemplateName;
    }
}
EventTypeSpecs.java

Event Handling

When an action happens, for example a user creates a new item or deletes some items, we will publish events in order to pass the responsibility of handling it to other component. We should create an event listener that will subscribe to specific events for handling them, and event publisher that will publish the event to all of its listeners.

We can create a base event class that will represent the ItemEvent

public abstract class ItemEvent {
    public Long userId;
    public Long itemId;
    public Long List<Long> itemList;
    public EventTypeSpecs eventTypeSpecs;
    public String modifiedDate;
    public String modifiedBy;
    public String userEmail;
}
    
ItemEvent.java

EventListener should listen for these events. Once an event is received it should create the mail content and send it to mail subscribers for entity modification. Note that event listener is set as @Async, so event is handled asynchronously and it will not block the caller.

This point is worth to pay attention, when the request goes all the way through controller -> service -> repository -> service -> aspect -> controller, we will detect the action in aspect, and if we do not handle the action asynchronously then it will block this flow so frontend will wait until mail sending operation finishes. So we rather run it async so that the flow will complete immediately and sending email will be done on a separate thread. We also need to add @EnableAsync annotation to be able to use @Async annotation.

@EnableAsync
@Component
public class ItemEventListener {
    
    private final MailContentBuilder mailContentBuilder;
    private final MailSenderHelper mailSenderHelper;

    public ItemEventListener(MailContentBuilder mailContentBuilder, MailSenderHelper mailSenderHelper) {
        this.mailContentBuilder = mailContentBuilder;
        this.mailSenderHelper = mailSenderHelper;
    }

    @Async
    @EventListener
    public void itemEventListener(ItemEvent event) {
        String message = mailContentBuilder.buildNotificationEmailContext(event);
        String[] mailList = getSubscriberMailList(event.eventTypeSpecs);

        if (mailList.length > 0) {
            mailSenderHelper.sendMessageWithAttachment(
                    mailList,
                    "Museum Sense Notification",
                    message
            );
        }
    }
}
ItemEventListener.java

This example is using the event details to send email to users, but alternatively we could save the details of the action to database or publish to a message queue. For mail sending we have the helper classes: MailContentBuilder prepares the html content and MailSenderHelper sends the email. These component are not included in this post as it is out of context but if you would like to take a look at their implementation refer to: Sending HTML Emails with Thymeleaf in Spring applications

Event publisher is going to be pretty simple and its responsibility is to publish the given events type of ItemEvent.

@Component
public class ItemEventPublisher {

    private final ApplicationEventPublisher publisher;

    public ItemEventPublisher(ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }

    public void publishItemEvent(ItemEvent itemEvent){
        publisher.publishEvent(itemEvent);
    }
}
ItemEventPublisher.java

Item Notification Aspect

We make use of AOP (Aspect Oriented Programming) to add a proxy between Controller and Service Layers. So I will write an Aspect that separates the concern of saving the data and generating analytics from the details of action.

Defining the Pointcuts as variables provides reusability. So we can define pointcuts for the package name and method names.

@Pointcut("execution(* com.turkogluc.backend.service.*.*(..))")
private void atServicePackage() {}

@Pointcut("execution(* *.save(..))")
private void isSaveMethod() {}

@Pointcut("execution(* *.update(..))")
private void isUpdateMethod() {}

@Pointcut("execution(* *.deleteItemById(..))")
private void isDeleteMethod() {}
ItemNotificationAspect.java

And then I can use combination of pointcuts inside the Advice for matching the method calls. For the Save and Update actions I can use AfterReturning advice as we will take action only after the successful responses.

@AfterReturning(
        pointcut = "atServicePackage() && isSaveMethod()",
        returning = "result"
)
public void handleSave(JoinPoint joinPoint, Item result){
    publisher.publishItemEvent(createItemEvent(result, EventTypeSpecs.CREATE));
}

@AfterReturning(
        pointcut = "atServicePackage() && isUpdateMethod()",
        returning = "result"
)
public void handleUpdate(JoinPoint joinPoint, Item result){
    publisher.publishItemEvent(createItemEvent(result, EventTypeSpecs.UPDATE));

}

This advices are catching the save(..) and update(..) method calls within the com.turkogluc.backend.service package. As we are using AfterReturning advice, handleSave and handleUpdate methods are going to be run after service methods finish their work and return successfully. So if there is an exception thrown response will not be caught by our aspect.

In our advices we can reach the return variable with returning = "result" parameter at the annotation. And the arguments of the method with JoinPoint, for instance first argument: joinPoint.getArgs()[0]

The advice takes the arguments and return variable of the method, sets the event type accordingly for instance EventTypeSpecs.CREATE and creates and ItemEvent. Event Handling is explained above, but here it is worth to note that Event Publisher publishes the created event. Here I use a helper method to create an event object:

private ItemEvent createItemEvent(Item result, EventTypeSpecs eventTypeSpecs) {
    ApplicationUser principal = (ApplicationUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    return new ItemEvent.Builder()
            .userId(principal.getId())
            .userEmail(principal.getEmail())
            .eventTypeSpecs(eventTypeSpecs)
            .modifiedBy(principal.getUsername())
            .itemId(result.getData().id)
            .modifiedDate(LocalDateTime.now().format(dateTimeFormatter))
            .build();
}
ItemNotificationAspect.java

It creates an ItemEvent with taking the current user from the Spring SecurityContextHolder with getPrincipal(), setting the user details, details of the returned item, and current date time. So using this method we publish the event via event publisher as follows:

publisher.publishItemEvent(createItemEvent(result, EventTypeSpecs.CREATE));

For the delete action we need to use Around advice. Because the deleteById method is void and does not return anything so before the method call we need to record which items are meant to be deleted and after the method returns we should verify that it was successful.

@Around("atServicePackage() && isDeleteMethod()")
    public void handleDelete(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {

        Object[] args = proceedingJoinPoint.getArgs();
        List<Long> itemIdList = (List <Long>) args[0];

        try {
            proceedingJoinPoint.proceed();
        } catch (Throwable throwable) {
            logger.error(
                    "ItemNotificationAspect has detected the following error. No ItemEvent will be published due to the error. {}"
                    , throwable.getMessage()
            );
            throw throwable;
        }

        publisher.publishItemEvent(createItemDeleteEvent(itemIdList));

    }
ItemNotificationAspect.java

So we first get the idList from arguments by proceedingJoinPoint.getArgs(). Until here it works as Before Advice. And then we call proceedingJoinPoint.proceed() method to go to service.  After returning back from service we can publish the event unless there was an error at the service and exception thrown.

The published event will be caught by the event listener, and event listener asynchronously will prepare the email and sends it. As the event listener works concurrently, the response of API call will continue its flow and finish immediately, rather than waiting for the event handling to be finished.

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.