Search code examples
javascripttypescriptdesign-patternssingletoninversion-of-control

Dependency inversion (IOC) of third party singleton


I have difficulties implementing inversion of control in order to keep loose coupling between my modules, and also preventing dependency cycle.

In my example I want to dynamically register configuration for a third party library (Apollo Client).

Current implementation

With my current implementation, I have dependency cycle issues

Folder structure

src
├── apolloClient.ts
├── modules
│   ├── messages
│   │   ├── ui.ts
│   │   └── state.ts
│   ├── users
│   │   ├── ui.ts
│   │   └── state.ts
...

apolloClient.ts

import { InMemoryCache, ApolloClient } from '@apollo/client'
import { messagesTypePolicies } from './modules/messages/state.ts'
import { usersTypePolicies } from './modules/users/state.ts'

export const client = new ApolloClient({
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        // register manually all modules
        ...messagesTypePolicies,
        ...usersTypePolicies
      }
    }
  }),
})

modules/messages/state.ts

import { client } from '../../apolloClient.ts'

export const messagesTypePolicies = {
  messagesList: {
    read() {
      return ['hello', 'yo']
    }
  }
}

export const updateMessage = () => {
  client.writeQuery(...) // Use of client, so here we have a dependency cycle issue
}

What I would like

To prevent dependency cycle and tight coupling, I would like each module to register itself, so that the apolloClient singleton has no dependency on UI modules.

modules/messages/state.ts

import { client, registerTypePolicies } from '../../apolloClient.ts'

// Use custom function to register typePolicies on UI modules
registerTypePolicies({
  messagesList: {
    read() {
      return ['hello', 'yo']
    }
  }
})

export const updateMessage = () => {
  client.writeQuery(...) // Dependency cycle issue solved :)
}

apolloClient.ts

export const registerTypePolicies = () => {
  // What I am trying to solve using IOC pattern
  // Not sure how to dynamically update the singleton
}

Solution

  • I don't have a lot of experience with the ApolloClient, but it looks as though the settings for ApolloClient and InMemoryCache are created by passing configurations when you call new. My concern is that you might not be able to modify them after the fact. For this reason, I propose that you import your modules into the client file rather than the other way around.

    Of course you will have methods in your modules which depend on the client instance, but we can make them be functions which take the client as an argument. That allows us to write the methods before the client has been created.

    I came up with a design based on the Builder Pattern. We define each module as an object with two properties: policies is a key-value object of policy definitions, fitting the TypePolicies type from the apollo package; makeMethods is a function which takes the client and returns a key-value object of functions. We use that returned functions object as the generic for the interface so that we can get the exact definitions for the function later on.

    type MethodFactory<MethodMap> = (
      client: ApolloClient<NormalizedCacheObject>
    ) => MethodMap;
    
    interface Module<MethodMap> {
      policies: TypePolicies;
      makeMethods: MethodFactory<MethodMap>;
    }
    

    When we build the methods into a client, we will pass all of the policies to the cache. We will also pass the newly created client to the makeMethods factory and return methods which no longer require the client because it has already been injected.

    Let me pause here to say that something is not quite right with the policies portion of my code as I don't fully understand what a policy is supposed to look like. So I'll leave that up to you to figure out. (docs link)

    Our Builder starts with no settings. Each time that we add a module, we combine the settings with the existing and return a new Builder. Eventually we call build which returns an object with two properties: client which is the ApolloClient and methods which is an object of methods that are bound to the client.

    
    class Builder<MethodMap> {
      policies: TypePolicies;
      makeMethods: MethodFactory<MethodMap>;
    
      protected constructor(module: Module<MethodMap>) {
        this.makeMethods = module.makeMethods;
        this.policies = module.policies;
      }
    
      public static create(): Builder<{}> {
        return new Builder({ makeMethods: () => ({}), policies: {} });
      }
    
      public addModule<AddedMethods>(
        module: Module<AddedMethods>
      ): Builder<MethodMap & AddedMethods> {
        return new Builder({
          policies: {
            ...this.policies,
            ...module.policies
          },
          makeMethods: (client) => ({
            ...this.makeMethods(client),
            ...module.makeMethods(client)
          })
        });
      }
    
      public build(): {
        client: ApolloClient<NormalizedCacheObject>;
        methods: MethodMap;
      } {
        const client = new ApolloClient({
          cache: new InMemoryCache({
            typePolicies: this.policies
          })
        });
        const methods = this.makeMethods(client);
    
        return { client, methods };
      }
    }
    

    Usage looks like this:

    const messagesModule = {
      policies: {
        messagesList: {
          read() {
            return ["hello", "yo"];
          }
        } as TypePolicy // I don't know enough about TypePolicies to understand why this is needed
      },
      makeMethods: (client: ApolloClient<NormalizedCacheObject>) => ({
        updateMessage: (): void => {
          client.writeQuery({});
        }
      })
    };
    
    // some dummy data
    const otherModule = {
      policies: {},
      makeMethods: (client: ApolloClient<NormalizedCacheObject>) => ({
        doSomething: (): number => {
          return 5;
        },
        somethingElse: (arg: string): void => {}
      })
    };
    
    // build through chaining
    const { client, methods } = Builder.create()
      .addModule(messagesModule)
      .addModule(otherModule)
      .build();
    
    // can call a method without any arguments
    methods.updateMessage();
    
    // methods with arguments know their correct argument type
    methods.somethingElse('');
    
    // can call any method on the client
    const resolvers = client.getResolvers();
    
    // can destructure methods from the map object
    const {updateMessage, doSomething, somethingElse} = methods;
    

    You would export the client from here and use it throughout your app.

    You could destructure methods and export methods individually, or you could just export the whole methods object. Note that you cannot have methods with the same names in multiple modules because they are not nested.