spring-bootrestkotlinaxon

How to catch custom exceptions in the REST controller that are thrown inside @CommandHandler functions


I've built a simple Spring Boot REST API using Kotlin and the Axon Framework, patterned off of the "Food Ordering App" example on YouTube, and now I'm trying to return a 400 response from my POST /api/user/register endpoint when a client tries to create a new account with a username that's already in use.

So, just like Steven did in the above video, I created a new custom exception class UsernameTakenException, and I throw this exception from inside my create user @CommandHandler function if the username is already in use. I then expected that I would be able to catch (e: UsernameTakenException) { ... } this new exception from within my endpoint's controller method, and return the 400 response containing the exception's message.

But after doing some tests, it seems like my endpoint function can only ever catch exceptions of type java.util.concurrent.ExecutionException, and not the custom UsernameTakenException type that I created.

Here is my UserManagementController.kt file:

@RestController
@RequestMapping("api/user")
class UserManagementController(
    val commandGateway: CommandGateway,
    val queryGateway: QueryGateway
) {
    @PostMapping("register")
    fun createUser(@RequestBody createUserReqBody: CreateUserRequest): ResponseEntity<Map<String, Any>> {
        return try {
            val result: CompletableFuture<Any> = commandGateway.send(
                CreateUserCommand(
                    createUserReqBody.username,
                    createUserReqBody.password
                )
            )
            ResponseEntity.ok(
                mapOf(
                    "userId" to result.get()
                )
            )
        } catch (e: ExecutionException) { // I want to catch `UsernameTakenException` instead
            ResponseEntity.badRequest().body(
                mapOf(
                    // This at least gets the message string to the client:
                    "error" to "${e.cause?.message}"
                )
            )
        }
    }

    // other endpoints...
}

Here's my User.kt aggregate file:

@Aggregate
data class User(
    @AggregateIdentifier
    var userId: UUID? = null,
    var username: String? = null,
    var password: String? = null,
    var userService: UserService? = null
) {
    constructor() : this(null, null, null, null)

    @CommandHandler
    constructor(command: CreateUserCommand, userService: UserService) : this(
        UUID.randomUUID(),
        command.username,
        command.password
    ) {
        if (userService.usernameExists(command.username)) {
            // This message needs to get back to the client somehow:
            throw UsernameTakenException("This username is already taken")
        }
        AggregateLifecycle.apply(
            UserCreatedEvent(
                userId!!,
                command.username,
                command.password
            )
        )
    }

    // other handlers...
}

And here's my exceptions.kt file:

class UsernameTakenException(message: String) : Exception(message)

// other custom exceptions...

Am I missing something here, or going about this the wrong way?


Solution

  • I finally solved the problem.

    So first of all, I was on the right track with my original code, and ultimately I was able to make it work with just a few tweaks which I'll share below.

    Problem explanation: for reasons that I don't totally understand, the Axon Framework intentionally does not propagate any custom exceptions up the stack, and instead wraps / converts them to CommandExecutionException type, and allows developers to put their error codes, error categories, error messages, etc. inside a special details field. The idea is that this way you'll never run into a situation where the command handler application (because this is a distributed application) has the custom exception class on their path but the client doesn't. More details in the docs and in this AxonIQ forum post.

    Aside: In my opinion the docs could've been way more clear on this, and could've maybe included a code example to drive the point home. I can see I'm not the first person to be confused by this behaviour. Also, if the whole reason for not propagating a custom exception is that the client would also need that exception class in their path, then... couldn't the client just add it to their path? Forcing the client to access all of the information it needs from a details sub-field of CommandExecutionException solves nothing — there is still tight coupling between the client and the command handler when it has to get its error codes from e.getDetails().myErrorCode. So how is it any different than just adding the custom exception class to both paths in a distributed application?

    Anyways, here's my solution for throwing custom exceptions inside of command handlers and having them trigger different HTTP responses:

    UserManagementController.kt:

    @RestController
    @RequestMapping("api/user")
    class UserManagementController(
        val commandGateway: CommandGateway,
        val queryGateway: QueryGateway
    ) {
        @PostMapping("register")
        fun createUser(
            @RequestBody createUserReqBody: CreateUserRequest
        ): CompletableFuture<ResponseEntity<Map<String, String>>> {
            return commandGateway.send<UUID>(
                CreateUserCommand(
                    createUserReqBody.username,
                    createUserReqBody.password
                )
            ).thenApply { ResponseEntity.ok(mapOf("userId" to it.toString())) }
        }
    
        // other endpoints...
    }
    

    User.kt:

    @Aggregate
    data class User(
        @AggregateIdentifier
        var userId: UUID? = null,
        var username: String? = null,
        var password: String? = null,
    ) {
        constructor() : this(null, null, null)
    
        @CommandHandler
        constructor(
            command: CreateUserCommand,
            userService: UserService
        ) : this(
            UUID.randomUUID(),
            command.username,
            command.password
        ) {
            if (userService.usernameAlreadyTaken(command.username)) {
                throw UsernameAlreadyTakenException()
            }
            AggregateLifecycle.apply(
                UserCreatedEvent(
                    userId!!,
                    command.username,
                    command.password
                )
            )
        }
    
        // other handlers...
    }
    

    exceptions.kt:

    data class RestExceptionDetails(
        val message: String,
        val httpCode: HttpStatus
    )
    
    // So as you can see, your custom exception essentially becomes a POJO:
    class UsernameAlreadyTakenException() : CommandExecutionException(
        null,
        null,
        RestExceptionDetails("This username is already taken", HttpStatus.BAD_REQUEST)
    )
    

    RestExceptionHandling.kt:

    @RestControllerAdvice(assignableTypes = [UserManagementController::class])
    class RestExceptionHandling {
        @ExceptionHandler(CommandExecutionException::class)
        fun handleRestExceptions(
            e: CommandExecutionException
        ): ResponseEntity<Map<String, Any>> {
            val details: RestExceptionDetails? = e.getDetails<RestExceptionDetails>()
                .orElse(null)
            if (details == null) {
                throw e
            }
            // The values you pass inside the `details` field can now change which
            // HTTP responses get sent back to the client (e.g.: 400, 404, etc.)
            return ResponseEntity.status(details.httpCode).body(
                mapOf("error" to details.message)
            )
        }
    }