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