lilydocs
HTTP APIWebSocketConsumer
Message handling3 min read

Validating business data

Separate deserialization from business rules and classify permanent and temporary failures explicitly.

Json<T> converts a message's JSON structure into a Rust type. Rules such as a positive order amount, a nonempty ID, or an account being open for processing must be written separately. Choosing the right error class prevents unfixable messages from entering unnecessary retry cycles.

Decoding versus business validation#

Condition Responsible layer Typical outcome
Invalid envelope/version/content Framework contract selection Permanent rejection before the handler
Malformed JSON or incorrect field type Json<T> and Serde Permanent decoding error
Empty ID or invalid amount Application validation Explicit permanent error
Temporary database or service failure Application error conversion Retryable error when appropriate
Actor lacks permission to process Application guard or policy Explicit policy rejection

The framework does not infer business retry decisions from error text. Deserialize validates the declared data representation. Do not transplant an HTTP validation extractor or an undefined #[validate] marker into a Consumer handler; call shared validation through an ordinary Rust function or service.

Write a pure validation function#

Rust
use lilyrs::queue::QueueHandlerError;
use serde::Deserialize;

#[derive(Deserialize)]
struct OrderInput {
    order_id: String,
    amount_minor: i64,
}

fn validate_order(input: &OrderInput) -> Result<(), QueueHandlerError> {
    if input.order_id.trim().is_empty() {
        return Err(QueueHandlerError::permanent("ORDER_ID_REQUIRED"));
    }
    if input.amount_minor <= 0 {
        return Err(QueueHandlerError::permanent("ORDER_AMOUNT_INVALID"));
    }
    Ok(())
}

The function performs no I/O, so it can be unit-tested without a broker. Error codes are bounded and stable; avoid embedding raw payloads, credentials, or sensitive business data in them. Another transport can call the same validator.

Use it in a handler#

Replace the first worker's method with the following and add the preceding OrderInput and validate_order definitions to the same module. Keep the first application's imports for queue, Json, and QueueHandlerError:

Rust
#[queue("orders.created", version = 1, content = "json")]
async fn created(&self, Json(event): Json<OrderInput>) -> Result<(), QueueHandlerError> {
    validate_order(&event)?;
    println!("{}:{}", event.order_id, event.amount_minor);
    Ok(())
}

This example adds amount_minor to the payload. Send the body below through the publisher's serde_json::json! call; the original body containing only order_id is insufficient for this Rust type:

JSON
{"order_id":"order-42","amount_minor":1250}

Permanent rejection does not mean the application manually ACKs the message. Lily carries the error class into retry/dead-letter and settlement policy. Completing required validation before the first business effect reduces the risk of partial side effects.

Handle external checks explicitly#

Checks requiring I/O, such as reading account status from a database, can live in a Scoped service. retryable_with_source can represent a temporary connection failure; business policy determines the class of a verified result such as a closed account. Treating every database error as permanent can lose work, while treating every error as retryable can repeatedly process permanent failures.

Payload validation does not provide authentication. A user_id field or header is not by itself a verified actor. See guards and delivery-local state for passing verified identity as typed local state and checking permission before the handler.

Sources for this page v0.1.0

This guide is based on the reviewed framework commit.

QUICK START
Documentation
Source code