nicojs / typed-inject

Type safe dependency injection for TypeScript
Apache License 2.0
448 stars 23 forks source link

Question: [typed-inject in Clean Architecture] How to inject instances so that other registered instances can reference them while being used as injectables themselves by other instances? #59

Open haukehem opened 1 year ago

haukehem commented 1 year ago

Hi! First of all, I really appreciate this project and it's type-safe DI approach to Typescript. However, while implementing a little demonstration project with a Clean Architecture approach, I could not figure out how to correctly set up my IoC container.

My question sounds a bit long-winded, but let my try to describe my approach.

Short description of my object relation / call flow: UI/reducer -> Use-Case -> Service (impl. by a Repository) -> Datasource -> API client To minimize the amount dependencies, increase performance and overall be more flexible/adaptable, in my UI, I want to get the injected Use-Case instance, which in itself references a Service registered in the IoC and so on...

Now, to my approach. I started by implementing my API client:

export interface ApiClient {
    get(url: string): Promise<any>;

    post(url: string, body: {},
         contentType: RequestContentType,
         authenticated: boolean): Promise<any>;
}

export class BlueprintOffersApiClient implements ApiClient {
    async get(url: string) {
       ...
    }

    async post(url: string, body: {},
               contentType: RequestContentType = RequestContentType.json,
               authenticated: boolean = true) {
        ...
    }

    private static async getAccessToken(): Promise<string> {
        ...
    }
}

Next, my API Datasource of my Account module:

export interface IAccountApiDatasource {
    getUserProfile(id: string): Promise<UserProfile>;

    directToSignIn(codeChallenge: string): void;

    authenticate(codeVerifier: string, authorizationCode: string, scope: string): Promise<Credentials>;

    directToSignOut(idToken: string): void;
}

export class AccountApiDatasource implements IAccountApiDatasource {
    public static inject = [Types.apiClient] as const;

    constructor(private readonly apiClient: ApiClient
    ) {
    }

    async getUserProfile(id: string): Promise<UserProfile> {
       ...
    }

    async authenticate(codeVerifier: string, authorizationCode: string, scope: string): Promise<Credentials> {
        ...
    }

    directToSignOut(idToken: string): void {
        ...
    }

    directToSignIn(codeChallenge: string): void {
        ...
    }
}

In my Account module, I have two other Datasource NOT referencing the API client or any other thing:

export interface IAccountLocalDatasource {
    ...
}

export class AccountLocalDatasource implements IAccountLocalDatasource {
    ...
}
export interface IAccountSessionDatasource {
    ...
}

export class AccountSessionDatasource implements IAccountSessionDatasource {
    ...
}

The Datasources are used in the module's Repositories (Service implementations):

export interface AuthenticationService {
    signIn(): void;

    authenticate(): Promise<void>;

    signOut(): void;
}
export class AuthenticationRepository implements AuthenticationService {
    public static inject = [Types.iAccountApiDatasource, Types.iAccountLocalDatasource, Types.iAccountSessionDatasource] as const;

    constructor(private readonly iAccountApiDatasource: IAccountApiDatasource,
                private readonly iAccountLocalDatasource: IAccountLocalDatasource,
                private readonly iAccountSessionDatasource: IAccountSessionDatasource) {
    }

    async authenticate(): Promise<void> {
       ...
    }

    signOut(): void {
        ...
    }

    signIn(): void {
        ...
    }
}

The Use-Cases then reference the Services:

export class SignIn {
    public static inject = [Types.authenticationService] as const;

    constructor(private readonly authenticationService: AuthenticationService) {
    }

    async invoke() : Promise<void> {
        return this.authenticationService.signIn();
    }
}

Ok, so far so good. Now I want to set up my IoC:

export class Types {
    // Clients
    static apiClient = "apiClient";

    // Data sources
    static iAccountApiDatasource = "iAccountApiDatasource";
    static iAccountLocalDatasource = "iAccountLocalDatasource";
    static iAccountSessionDatasource = "iAccountSessionDatasource";
    ...

    // Services
    static authenticationService = "authenticationService";
   ...

    // Use cases
    static signIn = "signIn";
    ...
}

// Called in index.tsx
export function initializeDependencies() {
    return createInjector().provideClass(Types.apiClient, BlueprintOffersApiClient, Scope.Singleton)
        .provideClass(Types.iAccountApiDatasource, AccountApiDatasource, Scope.Singleton)
        .provideClass(Types.iAccountLocalDatasource, AccountLocalDatasource, Scope.Singleton)
        .provideClass(Types.iAccountSessionDatasource, AccountSessionDatasource, Scope.Singleton)
        .provideClass(Types.authenticationService, AuthenticationRepository, Scope.Singleton)
        .provideClass(Types.signIn, SignIn, Scope.Singleton);
}

But with this setup, the compiler displays the following error in the line providing the AuthenticationRepository: TS2345: Argument of type 'typeof AuthenticationRepository' is not assignable to parameter of type 'InjectableClass {}, BlueprintOffersApiClient, string>, AccountApiDatasource, string>, AccountLocalDatasource, string>, AccountSessionDatasource, string>, AuthenticationRepository, readonly [...]>'.

So, to get back to my (multipart) question:

  1. What is the error trying to tell me and and why does it occur - I really want to understand it
  2. How can I inject my API client(s), Datasources, Repositories and Use-Cases instances so that other registered instances can reference them while being used as injectables themselves by "higher" (closer to UI) instances? - But I also need a solution:D
haukehem commented 1 year ago

After trying out some things, I could get rid of the compiler errors in my IoC by instanciating all dependencies and wiring the stuff manually:

export function initializeDependencies() {
    const container = createInjector().provideClass(Types.apiClient, BlueprintOffersApiClient);

    // Data sources
    const accountApiDatasource = new AccountApiDatasource(container.resolve(Types.apiClient))
    container.provideValue(Types.iAccountApiDatasource, accountApiDatasource);
    const accountLocalDatasource = new AccountLocalDatasource();
    container.provideValue(Types.iAccountLocalDatasource, accountLocalDatasource);
    const accountSessionDatasource = new AccountSessionDatasource();
    container.provideValue(Types.iAccountSessionDatasource, accountSessionDatasource);

    // Repositories
    const authenticationRepository = new AuthenticationRepository(
        accountApiDatasource,
        accountLocalDatasource,
        accountSessionDatasource
    )
    container.provideValue(Types.authenticationService, authenticationRepository);

    // Use-Cases
    const signIn = new SignIn(authenticationRepository);
    container.provideValue(Types.signIn, signIn);
    return container;
}

But not only is this a pretty strange solutin on its own. Now, when I try to resolve a dependency (here: Calling SignIn-Use-Case in UI), the type of the instance I want to resolve is interpretated as BlueprintOffersApiClient, my API client implementation:

export default function SignInViewModel() {
    const signIn = container.resolve(Types.signIn);

    async function onSignIn() {
        await signIn.invoke();
    }
    ...
}

The compiler throws the following error for await signIn.invoke();: TS2339: Property 'invoke' does not exist on type 'BlueprintOffersApiClient'.

Probably the way I tried it here is not the idea of the plugin. Just want to protocoll what I've tried and what errors are displayed.