Skip to content

Conversation

@idugalic
Copy link
Member

@idugalic idugalic commented Sep 13, 2025

Send free futures/Async (single-threaded executors)

Concurrency and async programming do not require a multi-threaded environment. You can run async tasks on a single-threaded executor, which allows you to write async code without the Send bound.

This approach has several benefits:

  • Simpler code: No need for Arc, Mutex(RwLock), or other thread synchronization primitives for shared state.

  • Ergonomic references: You can freely use references within your async code without worrying about moving data across threads. 🤯

  • Efficient design: This model aligns with the “Thread-per-Core” pattern, letting you safely run multiple async tasks concurrently on a single thread.

In short: you get all the power of async/await without the complexity of multi-threaded synchronization all the time.

Just switching to a LocalExecutor or something like Tokio LocalSet should be enough.

If you want to enable single-threaded, Send-free async support, you can enable the optional feature not-send-futures when adding fmodel-rust to your project:

[dependencies]
fmodel-rust = { version = "0.8.2", features = ["not-send-futures"] }

Enabling this feature allows your application and infrastructure code to use single-threaded async without requiring Send bounds or thread-synchronization primitives like Arc and Mutex/RwLock. This makes writing async code simpler and more ergonomic in single-threaded environments. by utilizing cheaper primitives like Rc and RefCell.

Example of the concurrent execution of the aggregate in single-threaded environment (behind feature - Send free Futures):

/// In-memory state repository for testing
struct InMemoryOrderStateRepository {
    states: RefCell<HashMap<u32, (OrderState, i32)>>,
}

async fn es_test_not_send() {
    let repository = InMemoryOrderEventRepository::new();

    let aggregate = Rc::new(EventSourcedAggregate::new(
        repository,
        decider().map_error(|()| AggregateError::DomainError("Decider error".to_string())),
    ));
    let aggregate2 = Rc::clone(&aggregate);

    // Notice how we `move` here, which requires Rc (not ARc). If you do not move, Rc is not needed.
    let task1 = async move {
        let command = OrderCommand::Create(CreateOrderCommand {
            order_id: 1,
            customer_name: "Alice".to_string(),
            items: vec!["Item1".to_string()],
        });
        let result = aggregate.handle(&command).await;
        assert!(result.is_ok());
    };

    let task2 = async move {
        let command = OrderCommand::Create(CreateOrderCommand {
            order_id: 1,
            customer_name: "John Doe".to_string(),
            items: vec!["Item 1".to_string(), "Item 2".to_string()],
        });
        let result = aggregate2.handle(&command).await;
        assert!(result.is_ok());
    };

    // Run both tasks concurrently on the same thread.
    tokio::join!(task1, task2);
}

@idugalic idugalic merged commit dd97ab9 into main Sep 13, 2025
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants