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.

listen Print view
Rust

(Image: iX)

11 min. read
By
  • Marcel Koch
Contents

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.

Marcel Koch
Portrait Marcel Koch

Marcel Koch and his seven-strong team advise small and medium-sized companies and develop cross-platform apps for desktop and mobile as well as web applications across all industries - preferably with TypeScript, Rust, Flutter or Java, based on CI/CD and IaC. He focuses on pragmatic, tailor-made solutions, because software is not an end in itself. In addition to solid technical knowledge, he trains in non-violent communication, transactional analysis and agility and promotes a critical view of cloud hypes. Marcel is a speaker, author of specialist articles and books and can be heard regularly in podcasts.

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.

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.

Don't miss any news – follow us on Facebook, LinkedIn or Mastodon.

This article was originally published in German. It was translated with technical assistance and editorially reviewed before publication.