Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Avoiding circular dependencies the right way - NestJS

Say I have a StudentService with a method that adds lessons to a student and a LessonService with a method that adds students to a lesson. In both my Lesson and Student Resolvers I want to be able to update this lesson <---> student relationship. So in my LessonResolver I have something along the lines of:

  async assignStudentsToLesson(
    @Args('assignStudentsToLessonInput')
    assignStudentsToLesson: AssignStudentsToLessonInput,
  ) {
    const { lessonId, studentIds } = assignStudentsToLesson;
    await this.studentService.assignLessonToStudents(lessonId, studentIds); **** A.1 ****
    return this.lessonService.assignStudentsToLesson(lessonId, studentIds); **** A.2 ****
  }

and essentially the reverse in my StudentResolver

The difference between A.1 and A.2 above is that the StudentService has access to the StudentRepository and the LessonService has access to the LessonRepository - which I believe adheres to a solid separation of concerns.

However, it seems to be an anti-pattern that the StudentModule must import the LessonModule and the LessonModule must import the StudentModule. This is fixable using the forwardRef method, but in the NestJS Documentation it mentions this pattern should be avoided if possible:

While circular dependencies should be avoided where possible, you can't always do so. (is this one of those cases?)

This seems like it should be a common issue when using DI, but I'm struggling to get a definitive answer as to what options are available that can eliminate this situation, or if I've stumbled upon a situation where it's unavoidable.

The ultimate goal is for me to be able to write the two GraphQL queries below:

query {
  students {
    firstName
    lessons {
      name
    }
  }
}

query {
  lessons {
    name
    students {
      firstName
    }
  }
}
like image 432
thatvegandev Avatar asked May 27 '20 23:05

thatvegandev


People also ask

How can circular dependencies be avoided?

To reduce or eliminate circular dependencies, architects must implement loose component coupling and isolate failures. One approach is to use abstraction to break the dependency chain. To do this, you introduce an abstracted service interface that delivers underlying functionality without direct component coupling.

How do you correct circular dependency?

To fix the error, we can either move the formula to another cell, or change the reference in the formula so that it refers to another cell. In this case we will change the cell reference to cell B1. As you can see in the image below, this adjustment has fixed the circular dependency error.

How do I resolve circular dependencies in Spring?

A simple way to break the cycle is by telling Spring to initialize one of the beans lazily. So, instead of fully initializing the bean, it will create a proxy to inject it into the other bean. The injected bean will only be fully created when it's first needed.

What is forwardRef in Nestjs?

Forward reference A forward reference allows Nest to reference classes which aren't yet defined using the forwardRef() utility function. For example, if CatsService and CommonService depend on each other, both sides of the relationship can use @Inject() and the forwardRef() utility to resolve the circular dependency.


1 Answers

Probably the easiest way is to drop the dependencies altogether and instead introduce a third module that depends on the other two. In your case, you could merge your two resolvers into a single StudentLessonResolver that lives in its own module, say ResolverModule:

async assign({ lessonId, studentIds }: AssignStudentsToLessonInput) {
  await this.studentService.assignLessonToStudents(lessonId, studentIds);
  return this.lessonService.assignStudentsToLesson(lessonId, studentIds);
}

So StudentModule and LessonModule are now completely independent, while ResolverModule depends on both of them. No cycle anymore :)

If for some reason you need to have two resolvers and have them update each other, you could use events or callbacks to publish changes. You would then again introduce a third module which listens to those events and updates the other module.


type AssignCallback = (assignStudentsToLesson: AssignStudentsToLessonInput) => Promise<void>;

class LessonResolver {  // and similar for StudentResolver
  private assignCallbacks: AssignCallback[] = [];

  // ... dependencies, constructor etc.

  onAssign(callback: AssignCallback) {
    assignCallbacks.push(callback);
  }

  async assignStudentsToLesson(
    @Args('assignStudentsToLessonInput')
    assignStudentsToLesson: AssignStudentsToLessonInput,
  ) {
    const { lessonId, studentIds } = assignStudentsToLesson;
    await this.lessonService.assignStudentsToLesson(lessonId, studentIds); **** A.2 ****
    for (const cb of assignCallbacks) {
      await cb(assignStudentsToLesson);
    }
  }
}

// In another module
this.lessonResolver.onAssign(({ lessonId, studentIds }) => {
  this.studentService.assignLessonToStudents(lessonId, studentIds);
});
this.studentResolver.onAssign(({ lessonId, studentIds }) => {
  this.lessonService.assignStudentsToLesson(lessonId, studentIds);
});

Again, you break the cycle because StudentModule and LessonModule do not know about each other, while your registered callbacks guarantee that calling either resolver results in both services being updated.

If you are using a reactive library such as RxJS, instead of managing callbacks manually you should use a Subject<AssignStudentsToLessonInput> to which Resolvers publish and the newly introduced module subscribes.

Update

As the OP suggested, there are other alternatives as well, such as injecting both repositories into both services. But if each module contains both the repository and the service, i.e. if you import LessonRepository and LessonService from LessonModule, this would not work, since you would still have the cyclic dependency on the module level. But if there really is a tight connection between students and lessons, you could also merge the two module into a single one, and there would be no problem.

A similar option would be to change the single resolver of the first solution to a service that uses the repositories directly. Whether this is a good option depends on the complexity of managing the stores. In the long run, you are probably better off going through the service.

One advantage I see in the single resolver/service solution is that it offers a single solution for assigning students to a lesson, whereas in the event solution, studentService.assignLessonToStudents and lessonService.assignStudentsToLesson effectively do exactly the same thing, so it is not clear which one should be used.

like image 98
mperktold Avatar answered Oct 09 '22 00:10

mperktold