dimanche 3 avril 2022

Javascript Adapter Pattern - Providing a strict interface in a microservice architecture

Introduction

I am implementing the analytics of my microservices application, where I have a huge amount of services.

The easiest way to do that is going to each segment (UI component, helper method, api method, ...) of my app, import the Analytics service as follows:

import analytics from "firebase-analytics";

...

function FollowButton({ ... }) {
  const currentUser = useCurrentUser();

  const handleOnClick = () => {
    api.relationships.follow(currentUser.data.id, otherUserId);

    ...

    analytics.logEvent("follow", {
      user: currentUser.data.id,
      otherUser: otherUserId,
      date: new Date();
    });
  }
}

I don't like one thing from this code:

  1. Hard coding each event name ('follow'), as perfectly, we could have typo errors or assign wrong names like 'followUser' instead of the correct one.

First refactoring

So I have decided to move all the names of my events to a constants file:

const EVENTS = {
  LOGIN: "login",
  SIGN_UP: "sign_up",
  FOLLOW: "follow",
  ... up to 200 more events!
};

With this, instead of hardcoding the event names, I could do:

analytics.logEvent(EVENTS.FOLLOW, {
  user: currentUser.data.id,
  otherUser: otherUserId,
  date: new Date();
});

And that's cool, but I feel some freedom here, and maybe there could be another way to handle this...

Second refactoring

I have decided to create some kind of namespace in my API gateway, in order to provide a more strict way to call the log events method.

This is the definition of the analytics.logEvent method:

logEvent(name: string, properties?: Record<string, any>): Promise<void>;

So, inside of services/firebase/api/analytics/helpers/logEvent.js I have implemented the following:

import { EVENTS } from "../utils/constants"; 

...

export default async (eventName, properties = undefined) => {
  if (!Object.values(EVENTS).includes(eventName)) {
    throw new Error(
      "Invalid event name. Make sure it is included in the `EVENTS` enum."
    );
  }

  const currentUser = getCurrentUser();

  const _properties = {
    ...properties,
    // force the addition of the current user id
    ...currentUser?.uid && { user: currentUser.uid },
  };

  try {
    await analytics.logEvent(eventName, _properties);
  } catch (err) {
    // eslint-disable-next-line no-console
    console.error(`Error logging Analytics event: ${err}`); // Silent
  }
};

As you can see, I am conditionally checking that given eventName is a value of my EVENTS enum. Also, in order to save some extra work, I am saving the current user id too, which will allow me to exclude running the useCurrentUser() hook in my FollowButton I implemented above.

Then, inside my module services/firebase/api/analytics/index.js I have an adapter (a namespace which centralizes, encapsulates, and exposes a list of logEvents methods based on the keys of my EVENTS enum):

export const eventsLogger = Object.freeze({
   FOLLOW: (properties = undefined) => logEvent(EVENTS.FOLLOW, properties),
   LOGIN: (properties = undefined) => logEvent(EVENTS.LOGIN, properties),
   SIGN_UP: (properties = undefined) => logEvent(EVENTS.SIGN_UP, properties),
   // up to 200 more functions!
});

Third refactoring

The idea of coding 200 more functions is not scalable... I have decided to dynamically generate that namespace, as follows:

export const eventsLogger = Object.freeze(
  Object.entries(EVENTS).reduce(
    // eslint-disable-next-line no-return-assign
    (acc, [event, eventName]) => (
      /** @type {import("./analytics.d").eventLoggerCallback} */
      acc[event] = (properties = undefined) =>
        logEvent(
          eventName,
          properties
          // eslint-disable-next-line no-sequences
        ),
      acc
    ),
    {}
  )
);

Then, in order to log my events, I just do:

import analytics from "../../services/firebase/api";

...

analytics.loggerEvent.FOLLOW({
  otherUser: otherUserId,
  date: new Date();
});

Problem

Having those 200 strict logEvent methods loaded in memory is not cool... Any pattern for implementing this? Suggestions?

Run the code

//
// UTILS
//

const EVENTS = Object.freeze({
  LOGIN: "login",
  SIGN_UP: "sign_up",
});

//
// HELPERS
//

function logEvent(eventName, properties = undefined) {
  console.log(`Logging the ${eventName} event to the Analytics service...`);
  console.log({ eventName, properties });
  console.log("-------------------");
}


//
// EXPOSED API
//

const analytics = {
  eventsLogger: Object.freeze(
    Object.entries(EVENTS).reduce(
      (acc, [event, eventName]) => (
        acc[event] = (properties = undefined) =>
          logEvent(eventName, properties)
        ,
        acc
      ),
      {}
    )
  ),
};


//
// MAIN
//

analytics.eventsLogger.LOGIN();

analytics.eventsLogger.SIGN_UP({ date: new Date(), username: "Victor" });

Aucun commentaire:

Enregistrer un commentaire