Sébastien Dan
Sébastien Dan

Reputation: 1384

How to raise an HTTP exception from a subsequent command that failed in a saga in NestJS CQRS?

I'm using NestJS CQRS recipe in order to manage interactions between two entities: User and UserProfile. The architecture is an API Gateway NestJS server + a NestJS server for each microservice (User, UserProfile, etc.).

I have already set up basic interactions through User and UserProfile modules on API Gateway with their own sagas/events/commands:

In details:

In User module, CreateUser command raises a UserCreated event that is intercepted by User saga, which will trigger CreateUserProfile command (from UserProfile module).

If the latter fails, a UserProfileFailedToCreate event is raised and intercepted by UserProfile saga, which will trigger DeleteUser command (from User module).

Everything works fine.

If the CreateUser command fails, I resolve(Promise.reject(new HttpException(error, error.status)) which indicates to the end user that something went wrong during the user creation.

My problem is that I cannot replicate the same behavior for the CreateUserProfile command since the HTTP request promise has already been resolved from the first command, obviously.

So my question is: is there any way to make a command fail if a subsequent command fails in the saga? I understand that the HTTP request is totally disconnected from any subsequent commands triggered by a saga, but I want to know if anybody has already played with events or something else here to replicate this data flow?

One of the reasons I'm using CQRS, besides having a much cleaner code for data interactions among microservices, is to be able to rollback repositories actions in case any of the chained commands fails, which works fine. But I need a way to indicate to the end user that the chain went through an issue and was rollbacked.

UserController.ts

@Post('createUser')
async createUser(@Body() createUserDto: CreateUserDto): Promise<{user: IAuthUser, token: string}> {
  const { authUser } = await this.authService.createAuthUser(createUserDto);
  // this is executed after resolve() in CreateUserCommand
  return {user: authUser, token: this.authService.createAccessTokenFromUser(authUser)};
}

UserService.ts

async createAuthUser(createUserDto: CreateUserDto): Promise<{authUser: IAuthUser}> {
  return await this.commandBus
    .execute(new CreateAuthUserCommand(createUserDto))
    .catch(error => { throw new HttpException(error, error.status); });
}

CreateUserCommand.ts

async execute(command: CreateAuthUserCommand, resolve: (value?) => void) {
    const { createUserDto } = command;
    const createAuthUserDto: CreateAuthUserDto = {
      email: createUserDto.email,
      password: createUserDto.password,
      phoneNumber: createUserDto.phoneNumber,
    };

    try {
      const user = this.publisher.mergeObjectContext(
        await this.client
          .send<IAuthUser>({ cmd: 'createAuthUser' }, createAuthUserDto)
          .toPromise()
          .then((dbUser: IAuthUser) => {
            const {password, passwordConfirm, ...publicUser} = Object.assign(dbUser, createUserDto);
            return new AuthUser(publicUser);
          }),
      );
      user.notifyCreated();
      user.commit();
      resolve(user); // <== This makes the HTTP request return its reponse
    } catch (error) {
      resolve(Promise.reject(error));
    }
  }

UserSagas.ts

authUserCreated = (event$: EventObservable<any>): Observable<ICommand> => {
    return event$
      .ofType(AuthUserCreatedEvent)
      .pipe(
        map(event => {
          const createUserProfileDto: CreateUserProfileDto = {
            avatarUrl: '',
            firstName: event.authUser.firstName,
            lastName: event.authUser.lastName,
            nationality: '',
            userId: event.authUser.id,
            username: event.authUser.username,
          };
          return new CreateUserProfileCommand(createUserProfileDto);
        }),
      );
  }

CreateUserProfileCommand.ts

async execute(command: CreateUserProfileCommand, resolve: (value?) => void) {
    const { createUserProfileDto } = command;

    try {
      const userProfile = this.publisher.mergeObjectContext(
        await this.client
          .send<IUserProfile>({ cmd: 'createUserProfile' }, createUserProfileDto)
          .toPromise()
          .then((dbUserProfile: IUserProfile) => new UserProfile(dbUserProfile)),
      );
      userProfile.notifyCreated();
      userProfile.commit();
      resolve(userProfile);
    } catch (error) {
      const userProfile = this.publisher.mergeObjectContext(new UserProfile({id: createUserProfileDto.userId} as IUserProfile));
      userProfile.notifyFailedToCreate();
      userProfile.commit();
      resolve(Promise.reject(new HttpException(error, 500)).catch(() => {}));
    }
  }

UserProfileSagas.ts

userProfileFailedToCreate = (event$: EventObservable<any>): Observable<ICommand> => {
    return event$
      .ofType(UserProfileFailedToCreateEvent)
      .pipe(
        map(event => {
          return new DeleteAuthUserCommand(event.userProfile);
        }),
      );
  }

DeleteUserCommand.ts

async execute(command: DeleteAuthUserCommand, resolve: (value?) => void) {
    const { deleteAuthUserDto } = command;

    try {
      const user = this.publisher.mergeObjectContext(
        await this.client
          .send<IAuthUser>({ cmd: 'deleteAuthUser' }, deleteAuthUserDto)
          .toPromise()
          .then(() => new AuthUser({} as IAuthUser)),
      );
      user.notifyDeleted();
      user.commit();
      resolve(user);
    } catch (error) {
      resolve(Promise.reject(new HttpException(error, error.status)).catch(() => {}));
    }
  }

Upvotes: 4

Views: 1403

Answers (1)

Arnold Schrijver
Arnold Schrijver

Reputation: 3753

In DDD terms your creation of User and UserProfile constitutes a business transaction - a group of business operations/rules that must be consistent - that spans multiple microservices.

In that case returning the database User before the UserProfile has been created means you return data in an inconsistent state. This is not necessarily wrong, but you should handle this appropriately in the client if you do it like that.

I see three possible ways to deal with this scenario:

  1. You let the Sagas run until they have executed commands that indicate the business transaction has ended, only then resolve an outcome for the client indicating either success or failure (e.g. in error details you can report which steps succeeded and which did not). So you do not yet resolve in CreateAuthUserCommand.

  2. If it can take a long time for the UserProfile to be created (it could even have to be manually validated by a Moderator) then you might want to resolve the User in CreateAuthUserCommand and then subsequently have the client subscribe to UserProfile-related events. You need a mechanism for that, but it decouples the client from the running transaction, and it can do other stuff.

  3. Alternatively you could break up the business transaction into two parts for which the client sends separate requests: one creates/returns the authenticated User, and the other returns the created UserProfile. Though it seems that User + UserProfile belong to the same bounded context, the fact that they reside in two different microservices may indicate they are not (in this case I think the first microservice really is for Authentication and the other for UserProfiles which indicate different bounded contexts to me). Best-practice is to have a microservice implement their own encapsulated bounded context.

(Note: Answered an old question in hopes it is informative to others)

Upvotes: 2

Related Questions