Empowering Rust application development with Axon Synapse
Introduction
Last year, we introduced Axon Synapse: the streamlined solution for leveraging Axon Server. Axon Synapse offers JSON over HTTP support, simplifies state management, and our upcoming clustered version ensures high availability by synchronizing state through Axon Server.
Developers gain a robust and efficient solution by seamlessly combining applications that connect via Axon Synapse and Axon Framework applications that connect directly to Axon Server.
With Axon Framework applications commonly built using Spring Boot, the hassle of configuring necessary components is conveniently handled automatically.
In this project, we demonstrate the integration of a Java application with a JavaScript application. We refer to the axon-rust repository to showcase a use case without involving an Axon Framework application, which provides a generated client and two demo projects in Rust.
What is Axon Synapse?
Axon Synapse’s core focus is messaging. One of Axon's fundamental principles is differentiating three distinct message types. Each message type follows a unique routing path and requires a specific handling method.
In the following, we will explore three message types and their implementation in a Rust demo project focusing on gift cards. The project enables gift card creation, redemption, and cancellation and offers two query methods.
Although Axon is adept at modeling complex use cases, this demo simplifies understanding each message type's unique attributes. To receive messages, it is crucial to register a handler, which acts as a webhook to call an endpoint with single or multiple messages.
Command Messages
Command messages are used to enact changes and must be directed to a singular, capable handler. Sending a command message can result in various failure scenarios. For instance, the command message cannot be routed if no handler is registered. Additionally, business rules can prevent the success of a command, such as attempting to redeem a gift card exceeding the remaining amount.
Sending Command Messages
To send a command message, we must first construct it. Axon Synapse supports multiple payload formats, including Base64 encoded binaries, JSON, XML, and UTF-8 Text. JSON is often preferred due to its readability and widespread support.In Rust, this is straightforward with the `serde_json` crate. With something like:
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct IssueGiftCard {
pub id: String,
pub amount: u32,
}
In one of the demo projects, protobuf binary is utilized and sent as a Base64 encoded string. This lets it be included in the JSON messages sent as a text field.
The generated code provides two command-sending functions: send_command and send_command_message. The former requires numerous arguments, whereas the latter wraps most of them in a CommandMessage struct, requiring only three arguments. CommandMessage can be standardized, making it my preferred method.
The other two properties include the Axon Synapse connection configuration and context, with the default being default. Selecting the appropriate context becomes imperative in scenarios that encompass multiple bounded contexts.
The crucial properties are the name, payload, and routing key. The name ensures proper routing to the relevant handler. The payload can be supplied as a value, which aligns well with JSON utilizing the to_value function. As for the routing key, we can utilize the command's ID.
The routing key serves as a hint for Axon Server, directing commands with the same routing key to the same instance. This routing strategy minimizes exceptions related to concurrency.
We send command messages based on REST calls in both demo projects. We map the result to allow the caller to receive it seamlessly. This is demonstrated by these examples of the rocket and warp functions, where an HTTP call is mapped to a command message, sent, and the result is returned. Proper handling of a command very much depends on the reason why the command is sent and who might need feedback.
In some cases, it might be fine to ignore the result.
Receiving Command Messages
To handle command messages effectively here, you need to register the names of the commands to Axon Server using Axon Synapse. The generated API needs a `CommandHandlerRegistration` instance for this. The registration includes the command names and the corresponding endpoint to call for these messages.
Additionally, there are various other properties primarily related to security. Once the command message is routed through Axon Synapse, it is sent to the configured endpoint for processing.
How you respond to a command message depends on the type of application you are building. In a typical Axon application using event sourcing, you can retrieve the state from a cache or rebuild it directly from events with matching IDs to the command message.
Next, evaluate the command message based on the current state. This evaluation usually leads to success, sending one or more events. If an error occurs, the error is returned without creating any events. If the evaluation is successful, you can choose to return either an empty 200 response or a CommandResponseMessage, which may contain additional payload data.
When evaluating a command directly against a database, you can send an integration event instead of a domain event. This event serves as a notification of the change made. Sending an event is not mandatory, but it can facilitate the successful utilization of Axon in the future.
In the example projects, you can handle the command using either the decider pattern or a cache with command models. It will apply some rules, like that for redeeming a card, the amount requested should be available on the card.
Event Messages
In addition to being generated from executed commands, event messages can originate from other sources. For instance, events such as `GiftCardExpired` can be triggered when a gift card is no longer valid due to the passage of time. Integration events from collaborations with other contexts or companies can also generate event messages. Given their importance, event messages are typically stored forever so projections can be rebuilt.
Publishing Event Messages
It's essential to distinguish between two types of event messages: domain event messages and non-domain event messages (or integration event messages). Domain event messages belong to a specific entity and have three additional properties: aggregate type, aggregate id, and sequence number.
As you may have guessed by now, these properties play a crucial role in ensuring the consistency of event sourcing.
In domain-driven design, aggregates are patterns that represent consistent domain concepts. The state of an aggregate is built by applying all the messages with the same aggregate id and type, ordered by the sequence number. The sequence number must be in increasing order to guarantee consistency. Only the first event will be accepted if multiple events with the same aggregate id and sequence number are published.
However, this strictness can have challenges, which is why there are plans to kill the aggregate.
Similar to sending commands, two functions are available for publishing events: one with a list of arguments and another that requires a configuration, context, and `PublishableEventMessage`. The `Ok` response of the returned result will be empty.
When there is an error, we need to handle it appropriately. If publishing the event is part of handling a command, we consider the command as failed.
In some cases, such as concurrency issues, it may be possible to reconstruct the aggregate state, generate a new resulting event message, and try again. In the example projects, events are always sent as part of handling a command (see here or here). However, it's also possible to publish non-domain events outside the context of commands.
Receiving Event Messages
Regarding receiving events, there's no need to send a specific list of event names like command messages. If the list is empty, all events will be sent. This can be useful for projections that require all events, such as querying all existing events. It’s also possible to set a starting point to read from. All events ever created will be sent by default.
Typically, you only want a few specific events to be able to handle specific queries. In the example projects, where event messages update an in-memory model, one example implements the `InMemoryViewStateRepository` from fmodel, while the other updates a projection.
By using Axon Synapse, we don’t need to track which events have already been processed. It's possible to receive a list of event messages with each request to reduce overhead. How the events should be sent can be configured on the registration.
Something to keep in mind when receiving events is dealing with backward compatibility. As events should be stored forever, they may evolve. Event messages include a payload revision field to accommodate breaking changes. Axon Synapse does not offer functionality similar to Axon Framework's upcasters.
If the need arises, adding some mapping when receiving events will be straightforward, leveraging the payload revision field.
Query Messages
A query message is used to retrieve information. Depending on the query type, it may require one or multiple applications to fetch the data.
It's worth noting that Axon Framework offers subscription and streaming queries, which involve sending a stream of messages. However, please be aware that Axon Synapse currently does not support these features.
Sending Query Messages
Two methods are available to send query messages: one with multiple arguments and another that takes a `QueryMessage' as input. The query message should have a name to ensure Axon Server properly routes the query to the correct application(s).
Both example projects only use point-to-point queries. Although less common, scatter-gather queries are also possible via Axon Synapse. You can read more about these queries in the framework on Baeldung.
The gift-card-proto project includes a generic method to send and handle query messages. If there are no errors, it translates the protobuf to JSON, which works with the build.rs file.
Similarly, gift-card-rust has a function to send queries. Since it directly serializes the enum containing all query messages, it can determine the type from the payload alone, thanks to the tags.
Unlike an Axon Framework application, Axon Synapse has no expected return type. Depending on the returned type, some deserialization or mapping may be required.
In the case of gift-card-rust, the response is JSON and can be returned as-is. However, the response needs to be deserialized using the proto files for gift-card-proto.
Receiving Query Messages
Query messages are typically handled by performing a database query to retrieve the required result. However, there are alternative approaches to handling query messages. In both demo projects, an in-memory representation is used.
While this approach may be suitable for small amounts of events, it may not be scalable in production as each instance needs to fetch all events upon restart.
Another use case is bridging with an external system. For example, a query message could trigger an API call to a payment provider to verify the success of a payment.
Handling query messages works the same way as other message types. Depending on the query type, there may not be a valid error response. If a list of results is expected and none are found, it is appropriate to send an empty list.
For a practical example of handling query messages, refer to the 'queries' function in the gift-card-rust project or the 'handle_query' function in the gift-card-proto project. If trying to retrieve a specific card, it is possible to encounter a 'not found' error message.
Advantages of Message-Based Communication
Message-based communication offers several advantages for application development. We can easily evolve our application by leveraging different kinds of messages.
For example, if we have additional business rules, we can simply add logic to the command handlers without modifying the rest of the application. Similarly, handling additional queries becomes seamless as we expand the model and rebuild the projections accordingly.
Another significant benefit is locational transparency. Unlike traditional microservices setups that rely on REST calls to specific instances, message-based communication allows us to send the appropriate message instead. In conjunction with Axon Server, Axon Synapse handles the routing process, making it more efficient and manageable.
Importantly, it's worth noting that event sourcing is not a prerequisite for utilizing Axon Synapse. You can use the command and query routing feature to utilize locational transparency.
You can introduce integration events and transition to domain events later on. This flexibility allows for a smoother adoption process and incremental improvements in your application architecture.
Conclusion
So now you've learned a thing or two about Axon Synapse's capabilities. With the help of the Rust demo projects, developing applications in Rust using Axon Synapse becomes a breeze.
For a deeper dive into the intricacies of Axon Synapse, I encourage you to explore the wealth of knowledge available in the AxonIQ Docs site. And if any questions arise, check out our discuss platform.