I would like to propose an extension point for handler input deserialization that can access the service instance.
The main motivation is supporting stateful deserialization, for example using serde::de::DeserializeSeed, when decoding a handler input requires a dependency owned by the service.
Today, handler input deserialization is stateless: the input type implements Restate’s Deserialize, and wrappers like Json<T> call normal Serde deserialization. This works well for most cases, but it does not allow passing a registry, codec, schema resolver, parser configuration, or other service-owned dependency into the deserialization step.
Example use case
Imagine a service that receives commands containing compact external identifiers, but the domain object should be constructed using a registry owned by the service.
For example:
struct UserServiceImpl {
role_registry: RoleRegistry,
}
struct CreateUser {
username: String,
role: Role,
}
The incoming JSON might look like this:
{
"username": "alice",
"role": "admin"
}
However, Role is not just a string. It must be resolved through RoleRegistry during deserialization:
struct CreateUserSeed<'a> {
role_registry: &'a RoleRegistry,
}
impl<'de, 'a> serde::de::DeserializeSeed<'de> for CreateUserSeed<'a> {
type Value = CreateUser;
fn deserialize<D>(self, deserializer: D) -> Result<CreateUser, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
struct RawCreateUser {
username: String,
role: String,
}
let raw = RawCreateUser::deserialize(deserializer)?;
let role = self
.role_registry
.lookup(&raw.role)
.ok_or_else(|| serde::de::Error::custom("unknown role"))?;
Ok(CreateUser {
username: raw.username,
role,
})
}
}
With the current handler input model, the generated SDK code deserializes the input before invoking the handler. Because of that, the input deserializer cannot access &self.role_registry.
The workaround is to accept raw bytes and manually deserialize inside every handler:
async fn create(&self, ctx: Context<'_>, input: Bytes) -> HandlerResult<()> {
let mut de = serde_json::Deserializer::from_slice(&input);
let input = CreateUserSeed {
role_registry: &self.role_registry,
}
.deserialize(&mut de)?;
// handler logic...
Ok(())
}
This works, but it bypasses the normal typed input path and requires repetitive boilerplate.
Proposed direction
Add a service-aware input extraction/deserialization trait.
For example, something along these lines:
pub trait FromInput<S>: Sized {
type Error: std::error::Error + Send + Sync + 'static;
fn from_input(
bytes: &mut bytes::Bytes,
metadata: &InputMetadata,
service: &S,
) -> Result<Self, Self::Error>;
}
Then the existing behavior could be preserved with a blanket/default implementation for current Deserialize inputs:
impl<S, T> FromInput<S> for T
where
T: restate_sdk::serde::Deserialize,
{
type Error = T::Error;
fn from_input(
bytes: &mut bytes::Bytes,
_metadata: &InputMetadata,
_service: &S,
) -> Result<Self, Self::Error> {
T::deserialize(bytes)
}
}
The generated handler code would then deserialize the input using the service instance that is already available before invoking the handler.
Optional convenience wrapper for Serde seeds
A convenience wrapper could make the DeserializeSeed use case ergonomic:
pub struct SeededJson<T>(pub T);
pub trait JsonSeed<S>: Sized {
type Seed<'a>: for<'de> serde::de::DeserializeSeed<'de, Value = Self>
where
S: 'a;
fn seed<'a>(service: &'a S, metadata: &'a InputMetadata) -> Self::Seed<'a>;
}
Then users could write:
impl JsonSeed<UserServiceImpl> for CreateUser {
type Seed<'a> = CreateUserSeed<'a>;
fn seed<'a>(
service: &'a UserServiceImpl,
_metadata: &'a InputMetadata,
) -> Self::Seed<'a> {
CreateUserSeed {
role_registry: &service.role_registry,
}
}
}
And the handler could remain typed:
#[restate_sdk::service]
trait UserService {
async fn create(input: SeededJson<CreateUser>) -> HandlerResult<()>;
}
Why this would be useful
This would enable typed handler inputs for cases where decoding needs stable service-owned dependencies, such as:
- schema registries
- ID/code registries
- tenant-aware parsers
- versioned command formats
- custom enum/object resolution
- codec configuration
- Serde
DeserializeSeed-based decoding
It would also avoid forcing users to accept Bytes and manually deserialize inside each handler.
Compatibility
This could be added in a non-breaking way if existing handler input types continue to use the current Deserialize path by default.
The service-aware path would only be used by types that explicitly implement the new input extraction trait, or by wrappers such as SeededJson<T>.
Caveats / constraints
This feature should probably be treated as an advanced escape hatch rather than the default input path.
A few important constraints seem worth calling out:
-
Input extraction should stay synchronous.
This should not become a hidden place for database calls, network requests, or other side effects. If constructing the final domain object requires I/O, the handler should deserialize a raw DTO first and perform the lookup explicitly inside the handler.
-
The service-owned dependency should be stable and deterministic.
This is best suited for things like codec configuration, schema registries, static lookup tables, parsers, or version resolvers. It should not encourage deserialization behavior that depends on mutable external state.
-
Existing handler inputs must remain fully compatible.
The current Deserialize-based input path should keep working unchanged. Users who do not need service-aware deserialization should not need to change any code.
-
Wrapper types may be preferable to blanket behavior.
A wrapper such as SeededJson<T> makes the custom deserialization path explicit at the handler boundary. That may be clearer and safer than trying to infer when a type wants service-aware deserialization.
I would like to propose an extension point for handler input deserialization that can access the service instance.
The main motivation is supporting stateful deserialization, for example using
serde::de::DeserializeSeed, when decoding a handler input requires a dependency owned by the service.Today, handler input deserialization is stateless: the input type implements Restate’s
Deserialize, and wrappers likeJson<T>call normal Serde deserialization. This works well for most cases, but it does not allow passing a registry, codec, schema resolver, parser configuration, or other service-owned dependency into the deserialization step.Example use case
Imagine a service that receives commands containing compact external identifiers, but the domain object should be constructed using a registry owned by the service.
For example:
The incoming JSON might look like this:
{ "username": "alice", "role": "admin" }However,
Roleis not just a string. It must be resolved throughRoleRegistryduring deserialization:With the current handler input model, the generated SDK code deserializes the input before invoking the handler. Because of that, the input deserializer cannot access
&self.role_registry.The workaround is to accept raw bytes and manually deserialize inside every handler:
This works, but it bypasses the normal typed input path and requires repetitive boilerplate.
Proposed direction
Add a service-aware input extraction/deserialization trait.
For example, something along these lines:
Then the existing behavior could be preserved with a blanket/default implementation for current
Deserializeinputs:The generated handler code would then deserialize the input using the service instance that is already available before invoking the handler.
Optional convenience wrapper for Serde seeds
A convenience wrapper could make the
DeserializeSeeduse case ergonomic:Then users could write:
And the handler could remain typed:
Why this would be useful
This would enable typed handler inputs for cases where decoding needs stable service-owned dependencies, such as:
DeserializeSeed-based decodingIt would also avoid forcing users to accept
Bytesand manually deserialize inside each handler.Compatibility
This could be added in a non-breaking way if existing handler input types continue to use the current
Deserializepath by default.The service-aware path would only be used by types that explicitly implement the new input extraction trait, or by wrappers such as
SeededJson<T>.Caveats / constraints
This feature should probably be treated as an advanced escape hatch rather than the default input path.
A few important constraints seem worth calling out:
Input extraction should stay synchronous.
This should not become a hidden place for database calls, network requests, or other side effects. If constructing the final domain object requires I/O, the handler should deserialize a raw DTO first and perform the lookup explicitly inside the handler.
The service-owned dependency should be stable and deterministic.
This is best suited for things like codec configuration, schema registries, static lookup tables, parsers, or version resolvers. It should not encourage deserialization behavior that depends on mutable external state.
Existing handler inputs must remain fully compatible.
The current
Deserialize-based input path should keep working unchanged. Users who do not need service-aware deserialization should not need to change any code.Wrapper types may be preferable to blanket behavior.
A wrapper such as
SeededJson<T>makes the custom deserialization path explicit at the handler boundary. That may be clearer and safer than trying to infer when a type wants service-aware deserialization.