DEV Community

Werner Echezuría
Werner Echezuría

Posted on

13 3

Practical Rust Web Development - State Machine

Rust's type system allows implementing state machine in a straightforward way. In our application we might use it to define a sale's state in a specific point in time. We can define 4 different states for a sale:

Draft: An user can edit a sale, this can be used as some form of budget or presale, it does not affect inventory or accounting, you can only approve a draft sale.

Approved: The sale can't be edited, now it's an invoice that should be delivered to the client. You can cancel, pay or partially pay an approved sale.

Pay: An user payed the sale, now you can generate a collection receipt for the sale. You can only cancel a payed sale.

Partially Pay: The user received a part of the total payment, this could be used if you want to sell a product by parts using some form of credit, then you generate a collection receipt. You can cancel and pay a partially payed sale.

Cancel: This is an annulled invoice, if you commit a mistake with a sale, you need to generate a credit/debit note afterwards. This is the final state, once you cancel a sale, you can't change its state.

Let's see it in code. Take a look at src/models/sale_state.rs:

use crate::models::sale::Sale;

#[derive(DbEnum, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(juniper::GraphQLEnum)]
pub enum SaleState {
    Draft,
    Approved,
    PartiallyPayed,
    Payed,
    Cancelled
}

#[derive(Debug)]
pub enum Event {
    Approve,
    Cancel,
    PartiallyPay,
    Pay,
}

impl SaleState {
    pub fn next(self, event: Event) -> Result<SaleState, String> {
        match (self, event) {
            (SaleState::Draft, Event::Approve) => Ok(SaleState::Approved),
            (SaleState::Approved, Event::Pay) => Ok(SaleState::Payed),
            (SaleState::Approved, Event::PartiallyPay) => Ok(SaleState::PartiallyPayed),
            (SaleState::Approved, Event::Cancel) => Ok(SaleState::Cancelled),
            (SaleState::Payed, Event::Cancel) => Ok(SaleState::Cancelled),
            (SaleState::PartiallyPayed, Event::Cancel) => Ok(SaleState::Cancelled),
            (SaleState::PartiallyPayed, Event::Pay) => Ok(SaleState::Payed),
            (sale_state, sale_event) => Err(format!("You can't {:#?} from {:#?} state", sale_event, sale_state))
        }
    }
} 

The enum SaleState is used as a database enum, let's take a look at the migration migrations/2019-09-25-114234_add_state_to_sales/up.sql:

CREATE TYPE sale_state AS ENUM ('draft', 'approved', 'partially_payed', 'payed', 'cancelled');
ALTER TABLE sales ADD COLUMN state sale_state;
UPDATE sales SET state = 'approved'; 
ALTER TABLE sales ALTER COLUMN state SET NOT NULL; 

Thanks to diesel-derive-enum crate we can map a Rust enum to db enum.

In order to make sure we are respecting the rules we already defined, we might add a function in src/models/sale.rs:

    fn set_state(context: &Context, sale_id: i32, event: Event) -> FieldResult<bool> {
        use crate::schema::sales::dsl;
        use diesel::ExpressionMethods;
        use diesel::QueryDsl;
        use diesel::RunQueryDsl;

        let conn: &PgConnection = &context.conn;
        let sale_query_builder = dsl::sales
            .filter(dsl::user_id.eq(context.user_id))
            .find(sale_id);

        let sale = sale_query_builder.first::<Sale>(conn)?;
        let sale_state = sale.state.next(event)?;

        diesel::update(sale_query_builder)
            .set(dsl::state.eq(sale_state))
            .get_result::<Sale>(conn)?;

        Ok(true)
    }

We can add a filter to updateSale function, to make sure we only edit draft sales:

            let sale = diesel::update(
                dsl::sales
                    .filter(
                        dsl::user_id
                            .eq(context.user_id)
                            .and(dsl::state.eq(SaleState::Draft)),
                    )
                    .find(sale_id),
            )
            .set(&param_sale)
            .get_result::<Sale>(conn)?;

Then we add the approve, pay, partially pay and cancel functions:

    fn approveSale(context: &Context, sale_id: i32) -> FieldResult<bool> {
        Sale::set_state(context, sale_id, Event::Approve)
    }

    fn cancelSale(context: &Context, sale_id: i32) -> FieldResult<bool> {
        //TODO: perform credit note or debit note
        Sale::set_state(context, sale_id, Event::Cancel)
    }

    fn paySale(context: &Context, sale_id: i32) -> FieldResult<bool> {
        //TODO: perform collection
        Sale::set_state(context, sale_id, Event::Pay)
    }

    fn partiallyPaySale(context: &Context, sale_id: i32) -> FieldResult<bool> {
        //TODO: perform collection
        Sale::set_state(context, sale_id, Event::PartiallyPay)
    }

Full source code here

Sentry image

Hands-on debugging session: instrument, monitor, and fix

Join Lazar for a hands-on session where you’ll build it, break it, debug it, and fix it. You’ll set up Sentry, track errors, use Session Replay and Tracing, and leverage some good ol’ AI to find and fix issues fast.

RSVP here →

Top comments (1)

Collapse
 
alexeyyunoshev profile image
Alexey Yunoshev

Thank you

Image of Docusign

🛠️ Bring your solution into Docusign. Reach over 1.6M customers.

Docusign is now extensible. Overcome challenges with disconnected products and inaccessible data by bringing your solutions into Docusign and publishing to 1.6M customers in the App Center.

Learn more