Skip to content

Add service-aware handler input deserialization #109

Description

@sagikazarmark

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions