How to refactor by eliminating double await and the DRY code?

Viewed 346

I'm having doubts about which is the best strategy to manage the many service clients in this web app.

"Best" in terms of a good compromise between user's device RAM and Javascript execution speed (main thread ops).

This is what I'm doing right now, this is the main file:

  • main.ts:
import type { PlayerServiceClient } from './player.client';
import type { TeamServiceClient } from './team.client';
import type { RefereeServiceClient } from './referee.client';
import type { FriendServiceClient } from './friend.client';
import type { PrizeServiceClient } from './prize.client';
import type { WinnerServiceClient } from './winner.client';
import type { CalendarServiceClient } from './calendar.client';

let playerService: PlayerServiceClient;
export const player = async (): Promise<PlayerServiceClient> =>
    playerService ||
    ((playerService = new (await import('./player.client')).PlayerServiceClient()),
    playerService);

let teamService: TeamServiceClient;
export const getTeamService = (): TeamServiceClient =>
    teamService ||
    ((teamService = new (await import('./team.client')).TeamServiceClient()),
  teamService);

let refereeService: RefereeServiceClient;
export const getRefereeService = (): RefereeServiceClient =>
    refereeService ||
  ((refereeService = new (await import('./referee.client')).RefereeServiceClient()),
  refereeService);

let friendService: FriendServiceClient;
export const getFriendService = (): FriendServiceClient =>
    friendService ||
  ((friendService = new (await import('./friend.client')).FriendServiceClient()),
  friendService);

let prizeService: PrizeServiceClient;
export const getPrizeService = (): PrizeServiceClient =>
    prizeService ||
  ((prizeService = new (await import('./prize.client')).PrizeServiceClient()),
  prizeService);

let winnerService: WinnerServiceClient;
export const getWinnerService = (): WinnerServiceClient =>
    winnerService ||
  ((winnerService = new (await import('./winner.client')).WinnerServiceClient()),
  winnerService);

let calendarService: CalendarServiceClient;
export const getCalendarService = (): CalendarServiceClient =>
    calendarService ||
  ((calendarService = new (await import('./calendar.client')).CalendarServiceClient()),
  calendarService);

// and so on... a lot more...

As you can see there are many service clients.

I'm using this code because I thought it was better given my web app structure based on routes almost overlapping with client services:

I mean, if the player goes from /home to /players page I can use it like this:

  • components/players.svelte
import { getPlayerService } from "main";

const playerService = await getPlayerService();
const players = await playerService.queryPlayers();

In this way, if the PlayerService does not exist, it is imported at the moment and returned, otherwise it returns the one imported and instantiated before.

Since the user switches pages frequently this way I can avoid the sudden creation and destruction of those clients, right?

But in this way I am using global variables which I don't like to use and I'm using verbose, DRY and long code in each component.

Is there a way to use the below code in components instead?

import { playerService } from "main";

const players = await playerService.queryPlayers();

What do you suggest me to do?

2 Answers

The patterns you are implementing are "lazy loading" and "singleton".

You could have a single service factory which implements those patterns and use it for every service:

File serviceFactory.js

const serviceMap = {};

export function getService(serviceName) {
  return serviceMap[serviceName] ?? (serviceMap[serviceName] = import(serviceName).then(x => new x.default));
}

The ECMAScript modules standard will take care of executing the serviceFactory.js code only once in the application (no matter how many times you import it), so you can hold the singletons in a map assigned to a private top-level variable of the serviceFactory.js module.

This service factory implies that every service is exported with the default keyword like that:

export default class SomeService {
    constructor() {
        // ...
    }

    fetchSomething() {
        // ...
    }
}

Then, use the services everywhere in your application with this code:

import { getService } from './serviceFactory.js';

const service = await getService('./services/some.service.js');
const something = await service.fetchSomething();

If you really want to remove the double await, you can encapsulate it in the service factory like that:

const serviceMap = {};

export function getService(serviceName) {
  return serviceMap[serviceName] ?? (serviceMap[serviceName] = resolveService(serviceName));
}

function resolveService(name) {
  const futureInstance = import(name).then(x => new x.default);

  const handler = {
    get: function (target, prop) {
      return function (...args) {
        return target.then(instance => instance[prop](...args));
      }
    }
  }

  return new Proxy(futureInstance, handler);
}

Which allows you to write this code:

const something = await getService('./services/some.service.js').fetchSomething();

This allows the service to be loaded at the exact line of code where you need it. If it doesn't bothers you to load it with a static import because you need the import { playerService } from "main"; syntax, you can expose every service like this in one file per service:

export const playerService = getService('./services/player.service.js');

I have published the full working demo here: https://github.com/Guerric-P/lazy-singletons-demo

I don't think the code from @Guerric will work with build tools (like webpack.) Specifically dynamic string imports import(modulePath) is not supported.

My recommendation is to reduce the repeating bits of code to their smallest representation... Hopefully, it'll end up feeling less noisy.

Solution #1/2

Here's an example using a higher-order memoize function to help with the caching.

// Minimal definition of service loaders
export const getPlayerService = memoize<PlayerServiceClient>(async () => new (await import('./player.client')).PlayerServiceClient());
export const getTeamService = memoize<TeamServiceClient>(async () => new (await import('./team.client')).TeamServiceClient());
export const getRefereeService = memoize<RefereeServiceClient>(async () => new (await import('./referee.client')).RefereeServiceClient());
export const getFriendService = memoize<FriendServiceClient>(async () => new (await import('./friend.client')).FriendServiceClient());
export const getPrizeService = memoize<PrizeServiceClient>(async () => new (await import('./prize.client')).PrizeServiceClient());
export const getWinnerService = memoize<WinnerServiceClient>(async () => new (await import('./winner.client')).WinnerServiceClient());


// Mock hacked together memoize fn
// TODO: Replace with some npm library alternative
const fnCache = new WeakMap();
function memoize<TReturn>(fn): TReturn {
  let cachedValue = fnCache.get(fn);
  if (cachedValue) return cachedValue;
  cachedValue = fn();
  fnCache.set(fn, cachedValue);
  return cachedValue;
}

Solution #2/2

Depending on the version of the JS engine & transpiler, you could possibly cut out some code and use the nature of modules to cache singletons of your services.

(Note: I've occasionally run into gotchas here around how ES Modules rely on deterministic exports. The workaround is to assign the exports to pending promises which return the instance.)

The important feature to know about Promises: they are only resolved once, and can be used to effectively cache their result. Each await or .then will get the initial resolved value.

// SUPER minimal definition of services
export const playerService = (async (): PlayerServiceClient => new (await import('./player.client')).PlayerServiceClient())();
export const teamService = (async (): TeamServiceClient => new (await import('./team.client')).TeamServiceClient())();
export const refereeService = (async (): RefereeServiceClient => new (await import('./referee.client')).RefereeServiceClient())();
export const friendService = (async (): FriendServiceClient => new (await import('./friend.client')).FriendServiceClient())();
export const prizeService = (async (): PrizeServiceClient => new (await import('./prize.client')).PrizeServiceClient())();
export const winnerService = (async (): WinnerServiceClient => new (await import('./winner.client')).WinnerServiceClient())();

Calling the Service Wrapper

import { playerService } from "./services";

// Example: Using async/await IIFE
const PlayerService = (async () => await playerService)();

function async App() {
  // Example: Function-scoped service instance:
  // const PlayerService = await playerService
  const players = await PlayerService.queryPlayers();
}
Related