Spring + Either: dealing with ResponseEntity responsibly
Bridging the gap between Arrow's Either and Spring's ResponseEntity
Spring + Either: dealing with ResponseEntity responsibly
Left or right?
Disclaimer
I’m going to assume you have been using Arrow's Either for a bit. If you stumbled upon this article without knowing about Eithers, have a look at this. It explains the rationale and the way to use it very well.
I’m also going to assume that you’re using Spring. If you’re using Ktor, congratulations! You should not run into the problem below.
Problem
So you’ve been using Eithers for a while, and it’s likely that you, like me, have run into a roadblock when it comes to communicating Eithers back to a client through Spring’s ResponseEntity. Take the following code block for example.
@PostMapping("/users")
fun createUser(
@RequestBody userPayload: CreateUserPayload
): ResponseEntity<Any> = either {
// Validate user, may return UserValidationError
// (InvalidEmail or InvalidPhoneNumber).
UserValidator.validateUser(userPayload).bind()
// Check if user with this email does not already exist.
// If they do, return a UserAlreadyExists.
ensure(UserRepository.findByEmail(userPayload.email) == null) {
UserAlreadyExists
}
// Create the user and return a User object.
UserRepository.create(userPayload)
}.fold({ error: UserCreateError ->
val statusCode = when (error) {
InvalidEmail,
InvalidPhoneNumber -> HttpStatus.BAD_REQUEST
UserAlreadyExists -> HttpStatus.CONFLICT
}
// Return a response entity with the error message as the body.
ResponseEntity.status(statusCode).body(error.message)
}, { user: User ->
// Return a response entity with the user as the body.
ResponseEntity.status(HttpStatus.CREATED).body(user)
})
This code is responsible for validating a user, checking if it does not already exist, and, if it doesn’t, creating one and returning it.
There are a number of different outcomes:
- Everything goes well: the user is created, and we receive a
201 Createdstatus with the newly created user as the body. - Validation fails: the user is not created and we receive a
400 Bad Requestwith the message “Email is invalid” or “Phone number is invalid” depending on the issue. - User already exists: the user is not created, and we receive a
409 Conflictwith the message “User with this email already exists”.
Even though we receive a ResponseEntity in each scenario, their types are not the same, and this is exactly the issue. Our return type is now ResponseEntity<Any>, in other words, we have lost type safety!
You may think to yourself: “Big deal, this is the edge of my application anyway, I don’t need type safety here”, but imagine, for example, that you add a log to your happy path:
@PostMapping("/users")
fun createUser(
@RequestBody userPayload: CreateUserPayload
): ResponseEntity<Any> = either {
// Existing validation...
UserRepository.create(userPayload)
Logger.debug("Successfully created user")
}.fold(/* ... */)
Since this logger returns Unit, our Right scenario now returns Unit instead of User. The compiler or IDE won’t give us a warning, because our ResponseEntity expects Any, and Unit is Any. This can easily lead to subtle bugs.
Either return type “solution”
So, why don’t we just return an Either to the client directly? We could do this, but using Eithers is not a very common thing to do in the first place. Regardless, we don’t want to leak the fact that we’re using Eithers to our client anyway, because that would increase a burden on the client to:
- understand what Eithers are;
- deal with a
200 OKstatus code in a custom way; after all, if we were to return aLeftto a client, it would look like this:
{
"left": "Something went wrong"
}
which is bad for obvious reasons. The image below illustrates this very well.
Everyone’s favorite server behavior.
We have HTTP status codes for a reason.
Either + ResponseEntity = …?
What if we take the power of an Either (containing two types for two different outcomes), and combine it with the power of a ReponseEntity (communicating an HTTP status properly)?
Enter EitherResponseEntity:
import arrow.core.Either
import com.example.EitherResponseEntity.Left
import com.example.EitherResponseEntity.Right
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonValue
import org.springframework.http.HttpStatus
/**
* A type-safe alternative to [ResponseEntity][org.springframework.http.ResponseEntity]
* for an [Either] object.
*
* Contains either a [Left] or a [Right] value, and a suitable [HttpStatus].
*
* Should be instantiated through [toEitherResponseEntity].
*
* Example: if you call [toEitherResponseEntity] on an [Either] with a [Left][Either.Left] value,
* the accompanied status code will default to a [HttpStatus.BAD_REQUEST],
* to communicate faulty use of a certain piece of business logic,
* whereas calling [toEitherResponseEntity] on an [Either] with a [Right][Either.Right] value
* will result in a [HttpStatus.OK] status code.
*/
sealed class EitherResponseEntity<out A, out B> {
abstract val status: HttpStatus
/**
* Convenient getter to be consistent with [getStatusCode][org.springframework.http.ResponseEntity.getStatusCode].
*/
val statusCode: HttpStatus
get() = this.status
/**
* Convenient getter to be consistent with [body][org.springframework.http.ResponseEntity.body].
* Prefer usage of [bodyLeft] or [bodyRight].
*/
val body: Any
get() = when (this) {
is Left -> value as Any
is Right -> value as Any
}
/**
* Gets the left value if this is [Left].
* If not, it throws an [IllegalStateException].
*/
val bodyLeft: A
get() = when (this) {
is Left -> value
is Right -> error("Body is not left.")
}
/**
* Gets the right value if this is [Right].
* If not, it throws an [IllegalStateException].
*/
val bodyRight: B
get() = when (this) {
is Left -> error("Body is not right.")
is Right -> value
}
/**
* @see [Either.Left].
*/
data class Left<A>(
@field:JsonValue val value: A,
@JsonIgnore override val status: HttpStatus,
) : EitherResponseEntity<A, Nothing>()
/**
* @see [Either.Right].
*/
data class Right<B>(
@field:JsonValue val value: B,
@JsonIgnore override val status: HttpStatus,
) : EitherResponseEntity<Nothing, B>()
/**
* @see [Either.map].
*/
fun <C> map(f: (right: B) -> C): EitherResponseEntity<A, C> =
when (this) {
is Left -> this
is Right -> Right(f(this.value), this.status)
}
/**
* @see [Either.mapLeft].
*/
fun <C> mapLeft(f: (left: A) -> C): EitherResponseEntity<C, B> =
when (this) {
is Left -> Left(f(this.value), this.status)
is Right -> this
}
}
/**
* Converts an [Either] into an [EitherResponseEntity].
*/
fun <A, B> Either<A, B>.toEitherResponseEntity(
httpStatusIfLeft: (left: A) -> HttpStatus = { HttpStatus.BAD_REQUEST },
httpStatusIfRight: (right: B) -> HttpStatus = { HttpStatus.OK },
): EitherResponseEntity<A, B> = fold(
{ left -> Left(value = left, status = httpStatusIfLeft(left)) },
{ right -> Right(value = right, status = httpStatusIfRight(right)) },
)
As you can see, this is not much more than a DTO and a conversion function. Let’s see it in action. We’ll adjust our existing user creation example:
@PostMapping("/users")
fun createUser(
@RequestBody userPayload: CreateUserPayload
): EitherResponseEntity<String, User> = either {
// Existing user creation stuff.
}.toEitherResponseEntity({ error -> // (1)
when (error) {
InvalidEmail,
InvalidPhoneNumber -> HttpStatus.BAD_REQUEST
UserAlreadyExists -> HttpStatus.CONFLICT
}
}, {
HttpStatus.CREATED
}).mapLeft { error -> error.message } // (2)
(1) We have replaced our fold with toEitherResponseEntity. When we call toEitherResponseEntity on an Either, we can pass two HttpStatus suppliers, one for the Left situation, and one for the Right situation. They are optional, though. When omitted, they default to BAD_REQUEST and OK respectively. At this point, we have an EitherResponseEntity<UserCreateError, User>, but our client does not care about that error type; it just wants to see the error message.
(2) Here we call mapLeft, which allows us to extract the message from our error and send that to the client. This will give us the final type that we want, which is EitherResponseEntity<String, User>.
Now it’s impossible to accidentally return the wrong type, because we made it explicit which type we expect.
Tying it all together
There is one final thing we need to do to make this work. With the current implementation, our endpoint would still always return a 200 OK, since we don’t do anything with the status code yet. Let’s add a ResponseBodyAdvice to ensure our EitherResponseEntity is handled correctly:
import org.springframework.core.MethodParameter
import org.springframework.core.annotation.Order
import org.springframework.http.MediaType
import org.springframework.http.converter.HttpMessageConverter
import org.springframework.http.server.ServerHttpRequest
import org.springframework.http.server.ServerHttpResponse
import org.springframework.http.server.ServletServerHttpResponse
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice
@ControllerAdvice
@Order(0)
class EitherResponseEntityAdvice : ResponseBodyAdvice<Any> {
override fun supports(
returnType: MethodParameter,
converterType: Class<out HttpMessageConverter<*>>,
): Boolean = EitherResponseEntity::class.java.isAssignableFrom(returnType.parameterType)
override fun beforeBodyWrite(
body: Any?,
returnType: MethodParameter,
selectedContentType: MediaType,
selectedConverterType: Class<out HttpMessageConverter<*>>,
request: ServerHttpRequest,
response: ServerHttpResponse,
): Any {
if (body != null) {
// Set status code.
(response as? ServletServerHttpResponse)
?.setStatusCode((body as EitherResponseEntity<*, *>).status)
}
// Return the body (Left or Right).
return (body as EitherResponseEntity<*, *>).body
}
}
Now our server will return the correct body and status code. This should give you all you need to deal with Eithers and ResponseEntity responsibly!
Error types
For completeness, here are the error types used in this example:
sealed interface DomainError {
val message: String
}
sealed interface UserCreateError : DomainError {
sealed interface UserValidationError : UserCreateError {
data object InvalidEmail : UserValidationError {
override val message = "Email is invalid"
}
data object InvalidPhoneNumber : UserValidationError {
override val message = "Phone number is invalid"
}
// More validation errors...
}
data object UserAlreadyExists : UserCreateError {
override val message = "User with this email already exists"
}
}
You may now think to yourself: “Why not just add the HTTP status codes to these errors here?”, and that’s a very reasonable and pragmatic thought. However, these errors do not and should not know or care who is going to consume them. It may be a client calling a REST endpoint, it may be one service calling another, it doesn’t matter. These errors fall into your domain. The fact that it’s an HTTP call in our scenario is an “adapter concern”.
You may also notice that these errors are tightly bound to this use case, user creation, and that’s on purpose. It will make it easier to limit the amount of errors, and therefore make our error-to-status-code conversion more concise.
With the introduction of rich errors in Kotlin 2.4, dealing with different types of errors should be easier, though. Speaking of which…
Rich errors in Kotlin 2.4
The introduction of rich errors in Kotlin 2.4 will change our solution quite a bit. Let’s have a look at what the future may hold for us.
We could start by replacing our errors with this:
error object InvalidEmail
error object InvalidPhoneNumber
error object UserAlreadyExists
typealias UserCreateError = UserValidationError | UserAlreadyExists
typealias UserValidationError = InvalidEmail | InvalidPhoneNumber
Then, we could update our user creation function like so:
@PostMapping("/users")
fun createUser(
@RequestBody userPayload: CreateUserPayload
): ResponseEntity<T | UserCreateError> = let {
// Returns Unit | UserValidationError.
// We short-circuit when there's an error.
if (UserValidator.validateUser(userPayload) == UserValidationError) {
return@let UserValidationError
}
// We short-circuit when the user already exists.
if (UserRepository.findByEmail(userPayload.email) == null) {
return@let UserAlreadyExists
}
UserRepository.create(userPayload)
}.toRichErrorResponseEntity({ error ->
when (error) {
InvalidEmail,
InvalidPhoneNumber -> HttpStatus.BAD_REQUEST
UserAlreadyExists -> HttpStatus.CONFLICT
}
}, {
HttpStatus.CREATED
})
And our converter function could look something like this:
fun <T, E : Error> (T | E).toEitherResponseEntity(
httpStatusIfError: (error: E) -> HttpStatus = { HttpStatus.BAD_REQUEST },
httpStatusIfSuccess: (success: T) -> HttpStatus = { HttpStatus.OK },
): RichErrorResponseEntity<T, E> {
val status = if (this is Error) httpStatusIfError(this) else httpStatusIfSuccess(this)
return ResponseEntity.status(status).body(this)
}
The only thing now is that we’re missing the error message. Once the design for rich errors reaches a more final state, it will probably be easier to imagine how to achieve this, but for now, we’re going to have to wait a little longer.
If this guide was helpful, please consider leaving a comment. Thanks!
Happy coding!
메타데이터
- post_id
- d46119d9db82
- slug
- spring-either-dealing-with-responseentity-responsibly-d46119d9db82
- url
- https://blog.fresh-minds.nl/spring-either-dealing-with-responseentity-responsibly-d46119d9db82
- canonical_url
- https://blog.fresh-minds.nl/spring-either-dealing-with-responseentity-responsibly-d46119d9db82
- author_url
- https://medium.com/@jelleblaauw
- status
- ok
- fetched_at
- 2026-07-14 22:28:10