Cross-platform applications with Rust 3: Domain logic and shell integration
The Crux framework combines domain types, modular apps, and cross-platform integration with Rust, generating scalable and testable applications.
(Image: iX)
- Marcel Koch
After an introduction to the Crux framework, the third and final part of the article series now covers advanced concepts and practical integration into real applications. The following points on domain types, splitting into multiple apps, and use in concrete shell technologies offer a detailed look from practice.
Domain Types
In long-term software projects, it is advisable to define domain types independently of the framework used. In the context of the e-mail app shown in the previous parts, the type EmailAddress is a good example: This type encapsulates the validation and representation of an e-mail address, ensuring that only valid values are used in the system. The logic for checking – for example, for the presence of the @ character – is anchored directly in the type and is not part of the Crux app.
This keeps the domain logic clearly separated from technical details. It can be tested, reused, and further developed independently of Crux, other frameworks, or the specific platform. Should the technical environment change, the core logic remains intact and does not need to be rewritten.
Videos by heise
Domain types like EmailAddress increase code maintainability and understandability and protect against errors by preventing invalid states during creation.
Listing 1: Domain Type EmailAddress
#[derive(Clone, PartialEq, Eq, Hash, Debug, serde::Serialize, serde::Deserialize)]
pub struct EmailAddress(String);
#[derive(thiserror::Error, Debug)]
pub enum EmailError {
#[error("missing @")]
MissingAt,
}
impl EmailAddress {
/// Der Smart-Constructor garantiert eine gĂĽltige Adresse
pub fn parse(s: impl Into<String>) -> Result<Self, EmailError> {
let s = s.into();
if s.contains('@') { Ok(Self(s)) } else { Err(EmailError::MissingAt) }
}
pub fn as_str(&self) -> &str { &self.0 }
}
Domain types like EmailAddress can be tested independently of the framework.
Listing 2: Tests for EmailAddress
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_email_is_accepted() {
let email = EmailAddress::parse("marcel.koch@example.org");
assert!(email.is_ok());
assert_eq!(email.unwrap().as_str(), "marcel.koch@example.org");
}
#[test]
fn invalid_email_is_rejected() {
let email = EmailAddress::parse("marcel.kochexample.org");
assert!(email.is_err());
assert_eq!(email.unwrap_err(), EmailError::MissingAt);
}
}
The first test checks that a valid e-mail address is accepted and stored correctly. The second test ensures that an invalid e-mail address (without @) is rejected and the corresponding error (MissingAt) is returned.
Splitting into Multiple Apps or Crates
If the app's development team grows, it can be useful to split the team and the application into multiple independent apps and crates. A contact management system could extend the existing e-mail app and store, update, or delete all incoming contact data. The contact management system would get its own Crux app, managed in a separate crate. This separate crate contains a distinct feature and is also referred to as a feature crate. Each feature crate defines its own events, models, view models, and effects – so the model is explicitly part of the respective feature. The main app integrates the individual feature crates and coordinates their interaction.
Listing 3: contacts (Feature-Crate)
// contacts/src/lib.rs
#[derive(Clone, Debug)]
pub struct Contact {
pub name: String,
pub email: EmailAddress,
}
pub enum ContactsEvent {
AddContact(Contact),
RemoveContact(EmailAddress),
EditContact(EmailAddress, Contact),
}
#[derive(Default)]
pub struct ContactsModel {
pub contacts: Vec<Contact>,
}
pub struct ContactsViewModel {
pub contacts: Vec<Contact>,
}
pub enum ContactsEffect {
ShowContactAdded(EmailAddress),
ShowContactRemoved(EmailAddress),
}
pub fn update(event: ContactsEvent, model: &mut ContactsModel) -> Vec<ContactsEffect> {
match event {
ContactsEvent::AddContact(contact) => {
model.contacts.push(contact.clone());
vec![ContactsEffect::ShowContactAdded(contact.email)]
}
ContactsEvent::RemoveContact(email) => {
model.contacts.retain(|c| c.email != email);
vec![ContactsEffect::ShowContactRemoved(email)]
}
ContactsEvent::EditContact(email, new_contact) => {
if let Some(c) = model.contacts.iter_mut().find(|c| c.email == email) {
*c = new_contact.clone();
}
vec![]
}
}
}
pub fn view(model: &ContactsModel) -> ContactsViewModel {
ContactsViewModel {
contacts: model.contacts.clone(),
}
}
The e-mail feature takes over the function of the previous e-mail app:
Listing 4: email (Feature-Crate)
// email/src/lib.rs
pub enum EmailEvent {
SendEmail(EmailAddress),
EmailSent(bool),
}
#[derive(Default)]
pub struct EmailModel {
pub last_sent: Option<EmailAddress>,
}
pub struct EmailViewModel {
pub last_sent: Option<String>,
}
pub enum EmailEffect {
SendEmailRequest(String),
}
The main app aggregates the feature models and coordinates communication:
Listing 5: Main App Integration (App-Crate)
// app/src/lib.rs
use contacts::{ContactsEvent, ContactsModel, ContactsViewModel, ContactsEffect};
use email::{EmailEvent, EmailModel, EmailViewModel, EmailEffect};
pub enum AppEvent {
Email(EmailEvent),
Contacts(ContactsEvent),
}
pub struct AppModel {
pub email: EmailModel,
pub contacts: ContactsModel,
}
pub struct AppViewModel {
pub email: EmailViewModel,
pub contacts: ContactsViewModel,
}
pub enum AppEffect {
Email(EmailEffect),
Contacts(ContactsEffect),
}
pub fn update(event: AppEvent, model: &mut AppModel) -> Vec<AppEffect> {
match event {
AppEvent::Email(email_event) => {
email::update(email_event, &mut model.email)
.into_iter().map(AppEffect::Email).collect()
}
AppEvent::Contacts(contacts_event) => {
contacts::update(contacts_event, &mut model.contacts)
.into_iter().map(AppEffect::Contacts).collect()
}
}
}
pub fn view(model: &AppModel) -> AppViewModel {
AppViewModel {
email: email::view(&model.email),
contacts: contacts::view(&model.contacts),
}
}
This division keeps the application modular, testable, and extensible. Each feature manages its own state and can be developed, tested, and maintained independently. The main app handles the orchestration and merging of individual view models into a consistent interface. This results in a scalable architecture.