I built a simple Spring Boot REST API using Kotlin and the Axon Framework, patterned off of the “Food Ordering App” example on YouTube. Then came the time 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. In other words, I needed to handle business-specific error cases and inform the client of what went wrong.
So, just like Steven did in the above video, I created a new custom exception class called UsernameAlreadyTakenException
, and threw it inside my create user @CommandHandler function if the username was already in use. I expected that I would then be able to catch (e: UsernameAlreadyTakenException) { ... }
from my REST controller’s endpoint method, and return the 400 response containing the exception’s message.
But as I learned, for reasons that I don’t totally understand, the Axon Framework intentionally does not propagate custom exceptions up the stack, and instead wraps / converts them to CommandExecutionException
which contains a details
field where developers are to put all of their custom exception error codes, messages, etc. The docs explain it this way:
Due to the application division, you loose any certainty that both applications can access the same classes, which thus holds for any exception classes.
On the surface, this seems like a reasonable explanation. But the more I think about it, the more it doesn’t make sense to me. If “the thing” that publishes a command and “the thing” that handles that command are separate applications and you’re worried about them not having a particular class on their respective paths, then… can’t you simply add that class to both paths and be done with it?
How is storing custom exception logic like error codes and error messages inside the details
field of CommandExecutionException
better than just adding MyCustomException
to both applications’ class paths? If the command handler application stores an error code inside of details.errorCode
and an error message inside details.errorMessage
, then the command publisher still has to extract those fields individually by doing getDetails().errorCode
and getDetails().errorMessage
. So you still have a “contract” between the command publisher application and the event handler application, but now the details of this contract are opaque. It would’ve been better if you could just catch MyCustomException
from the command publisher application, and be able to look at the definition of MyCustomException
to see what fields it gives you.
Let me illustrate what I mean with some code. 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(
// Also notice how the various fields of `CommandExecutionException`
// are basically useless here:
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
}
// Your custom fields can now finally 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)
)
}
}
On a related note, I can see that I’m not the first person to be confused by this functionality (one, two, three, four). In my opinion, this situation could be improved by either:
- Expanding the Axon documentation on exception handling, adding some minimal code examples like the ones above, or
- Simply propagate the custom exception, which is clearly the behaviour that a lot of users besides myself are expecting out of the box, and leave it up to the “catching” application to have all necessary classes on its path