diff --git a/Cargo.toml b/Cargo.toml index 8e39f678..7e0b3d76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,4 +6,5 @@ members = [ "eventually-postgres", # Examples "examples/bank-accounting", + "examples/light-switch", ] diff --git a/examples/light-switch/Cargo.toml b/examples/light-switch/Cargo.toml new file mode 100644 index 00000000..d5391164 --- /dev/null +++ b/examples/light-switch/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "light_switch" +version = "0.1.0" +edition = "2021" +readme = "README.md" +publish = false + +[dependencies] +serde_json = { version = "1.0.114", optional = true } +serde = { version = "1.0.197", features = ["derive"] } +tokio = { version = "1.36.0", features = ["macros", "rt-multi-thread"] } +thiserror = { version = "2.0.12" } +anyhow = "1.0.97" +async-trait = "0.1.77" +eventually = { features = ["serde-json"], path="../../eventually" } diff --git a/examples/light-switch/README.md b/examples/light-switch/README.md new file mode 100644 index 00000000..5a14c5ec --- /dev/null +++ b/examples/light-switch/README.md @@ -0,0 +1,8 @@ +# Example: Light Switch application + +This example application is a bare minimum example of the core functionality. +Persistence is not enabled, nor is logging, nor any kind of user interface. It +just starts, runs some commands, queries the final state and prints it. + +It models a "light switch". You can "install" the switch, then turn it "off" or +"on" using commands. You can issue a query to get the current state of a switch. diff --git a/examples/light-switch/src/application.rs b/examples/light-switch/src/application.rs new file mode 100644 index 00000000..2713580a --- /dev/null +++ b/examples/light-switch/src/application.rs @@ -0,0 +1,23 @@ +use eventually::aggregate; + +use crate::domain::LightSwitch; +pub type LightSwitchRepo = aggregate::EventSourcedRepository; + +#[derive(Clone)] +pub struct LightSwitchService +where + R: aggregate::Repository, +{ + pub light_switch_repository: R, +} + +impl From for LightSwitchService +where + R: aggregate::Repository, +{ + fn from(light_switch_repository: R) -> Self { + Self { + light_switch_repository, + } + } +} diff --git a/examples/light-switch/src/commands/install_light_switch.rs b/examples/light-switch/src/commands/install_light_switch.rs new file mode 100644 index 00000000..ca8f21c7 --- /dev/null +++ b/examples/light-switch/src/commands/install_light_switch.rs @@ -0,0 +1,33 @@ +use async_trait::async_trait; +use eventually::{aggregate, command, message}; + +use crate::application::LightSwitchService; +use crate::domain::{LightSwitch, LightSwitchId, LightSwitchRoot}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallLightSwitch { + pub id: LightSwitchId, +} + +impl message::Message for InstallLightSwitch { + fn name(&self) -> &'static str { + "InstallLightSwitch" + } +} + +#[async_trait] +impl command::Handler for LightSwitchService +where + R: aggregate::Repository, +{ + type Error = anyhow::Error; + async fn handle( + &self, + command: command::Envelope, + ) -> Result<(), Self::Error> { + let command = command.message; + let mut light_switch = LightSwitchRoot::install(command.id)?; + self.light_switch_repository.save(&mut light_switch).await?; + Ok(()) + } +} diff --git a/examples/light-switch/src/commands/mod.rs b/examples/light-switch/src/commands/mod.rs new file mode 100644 index 00000000..b9d315d8 --- /dev/null +++ b/examples/light-switch/src/commands/mod.rs @@ -0,0 +1,3 @@ +pub mod install_light_switch; +pub mod turn_light_switch_off; +pub mod turn_light_switch_on; diff --git a/examples/light-switch/src/commands/turn_light_switch_off.rs b/examples/light-switch/src/commands/turn_light_switch_off.rs new file mode 100644 index 00000000..17ea92b4 --- /dev/null +++ b/examples/light-switch/src/commands/turn_light_switch_off.rs @@ -0,0 +1,34 @@ +use async_trait::async_trait; +use eventually::{aggregate, command, message}; + +use crate::application::LightSwitchService; +use crate::domain::{LightSwitch, LightSwitchId, LightSwitchRoot}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TurnLightSwitchOff { + pub id: LightSwitchId, +} + +impl message::Message for TurnLightSwitchOff { + fn name(&self) -> &'static str { + "TurnLightSwitchOff" + } +} + +#[async_trait] +impl command::Handler for LightSwitchService +where + R: aggregate::Repository, +{ + type Error = anyhow::Error; + async fn handle( + &self, + command: command::Envelope, + ) -> Result<(), Self::Error> { + let command = command.message; + let mut root: LightSwitchRoot = self.light_switch_repository.get(&command.id).await?.into(); + let _ = root.turn_off(command.id)?; + self.light_switch_repository.save(&mut root).await?; + Ok(()) + } +} diff --git a/examples/light-switch/src/commands/turn_light_switch_on.rs b/examples/light-switch/src/commands/turn_light_switch_on.rs new file mode 100644 index 00000000..4947df8d --- /dev/null +++ b/examples/light-switch/src/commands/turn_light_switch_on.rs @@ -0,0 +1,34 @@ +use async_trait::async_trait; +use eventually::{aggregate, command, message}; + +use crate::application::LightSwitchService; +use crate::domain::{LightSwitch, LightSwitchId, LightSwitchRoot}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TurnLightSwitchOn { + pub id: LightSwitchId, +} + +impl message::Message for TurnLightSwitchOn { + fn name(&self) -> &'static str { + "TurnLightSwitchOn" + } +} + +#[async_trait] +impl command::Handler for LightSwitchService +where + R: aggregate::Repository, +{ + type Error = anyhow::Error; + async fn handle( + &self, + command: command::Envelope, + ) -> Result<(), Self::Error> { + let command = command.message; + let mut root: LightSwitchRoot = self.light_switch_repository.get(&command.id).await?.into(); + let _ = root.turn_on(command.id)?; + self.light_switch_repository.save(&mut root).await?; + Ok(()) + } +} diff --git a/examples/light-switch/src/domain.rs b/examples/light-switch/src/domain.rs new file mode 100644 index 00000000..2b4d3470 --- /dev/null +++ b/examples/light-switch/src/domain.rs @@ -0,0 +1,153 @@ +use eventually::{aggregate, message}; + +pub type LightSwitchId = String; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum LightSwitchError { + #[error("Light switch has not yet been installed")] + NotYetInstalled, + #[error("Light switch has already been installed")] + AlreadyInstalled, + #[error("Light switch is already on")] + AlreadyOn, + #[error("Light switch is already off")] + AlreadyOff, +} + +// events +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Installed { + id: LightSwitchId, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct SwitchedOn { + id: LightSwitchId, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct SwitchedOff { + id: LightSwitchId, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum LightSwitchEvent { + Installed(Installed), + SwitchedOn(SwitchedOn), + SwitchedOff(SwitchedOff), +} + +impl message::Message for LightSwitchEvent { + fn name(&self) -> &'static str { + match self { + LightSwitchEvent::SwitchedOn(_) => "SwitchedOn", + LightSwitchEvent::SwitchedOff(_) => "SwitchedOff", + LightSwitchEvent::Installed(_) => "Installed", + } + } +} + +// aggregate +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum LightSwitchState { + On, + Off, +} + +#[derive(Debug, Clone)] +pub struct LightSwitch { + id: LightSwitchId, + state: LightSwitchState, +} + +impl aggregate::Aggregate for LightSwitch { + type Id = LightSwitchId; + type Event = LightSwitchEvent; + type Error = LightSwitchError; + + fn type_name() -> &'static str { + "LightSwitch" + } + + fn aggregate_id(&self) -> &Self::Id { + &self.id + } + + fn apply(state: Option, event: Self::Event) -> Result { + match state { + None => match event { + LightSwitchEvent::Installed(installed) => Ok(LightSwitch { + id: installed.id, + state: LightSwitchState::Off, + }), + LightSwitchEvent::SwitchedOn(_) | LightSwitchEvent::SwitchedOff(_) => { + Err(LightSwitchError::NotYetInstalled) + }, + }, + Some(mut light_switch) => match event { + LightSwitchEvent::Installed(_) => Err(LightSwitchError::AlreadyInstalled), + LightSwitchEvent::SwitchedOn(_) => match light_switch.state { + LightSwitchState::On => Err(LightSwitchError::AlreadyOn), + LightSwitchState::Off => { + light_switch.state = LightSwitchState::On; + Ok(light_switch) + }, + }, + LightSwitchEvent::SwitchedOff(_) => match light_switch.state { + LightSwitchState::On => { + light_switch.state = LightSwitchState::Off; + Ok(light_switch) + }, + LightSwitchState::Off => Err(LightSwitchError::AlreadyOff), + }, + }, + } + } +} + +// root +#[derive(Debug, Clone)] +pub struct LightSwitchRoot(aggregate::Root); + +// NOTE: The trait implementations for From, Deref and DerefMut below are +// implemented manually for demonstration purposes, but most would prefer to have them +// auto-generated at compile time by using the [`eventually_macros::aggregate_root`] macro +impl From> for LightSwitchRoot { + fn from(root: eventually::aggregate::Root) -> Self { + Self(root) + } +} +impl From for eventually::aggregate::Root { + fn from(value: LightSwitchRoot) -> Self { + value.0 + } +} +impl std::ops::Deref for LightSwitchRoot { + type Target = eventually::aggregate::Root; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl std::ops::DerefMut for LightSwitchRoot { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl LightSwitchRoot { + pub fn install(id: LightSwitchId) -> Result { + aggregate::Root::::record_new( + LightSwitchEvent::Installed(Installed { id }).into(), + ) + .map(Self) + } + pub fn turn_on(&mut self, id: LightSwitchId) -> Result<(), LightSwitchError> { + self.record_that(LightSwitchEvent::SwitchedOn(SwitchedOn { id }).into()) + } + pub fn turn_off(&mut self, id: LightSwitchId) -> Result<(), LightSwitchError> { + self.record_that(LightSwitchEvent::SwitchedOff(SwitchedOff { id }).into()) + } + pub fn get_switch_state(&self) -> Result { + Ok(self.state.clone()) + } +} diff --git a/examples/light-switch/src/main.rs b/examples/light-switch/src/main.rs new file mode 100644 index 00000000..5e576c1c --- /dev/null +++ b/examples/light-switch/src/main.rs @@ -0,0 +1,47 @@ +mod application; +mod commands; +mod domain; +mod queries; +use application::{LightSwitchRepo, LightSwitchService}; +use commands::install_light_switch::InstallLightSwitch; +use commands::turn_light_switch_off::TurnLightSwitchOff; +use commands::turn_light_switch_on::TurnLightSwitchOn; +use domain::{LightSwitchEvent, LightSwitchId}; +use eventually::{command, event, query}; +use queries::get_switch_state::GetSwitchState; + +#[tokio::main] +async fn main() -> Result<(), anyhow::Error> { + let store = event::store::InMemory::::default(); + let repo = LightSwitchRepo::from(store.clone()); + let svc = LightSwitchService::from(repo); + + let cmd = InstallLightSwitch { + id: "Switch1".to_string(), + } + .into(); + command::Handler::handle(&svc, cmd).await?; + println!("Installed Switch1"); + + let cmd = TurnLightSwitchOn { + id: "Switch1".to_string(), + } + .into(); + command::Handler::handle(&svc, cmd).await?; + println!("Turned Switch1 On"); + + let cmd = TurnLightSwitchOff { + id: "Switch1".to_string(), + } + .into(); + command::Handler::handle(&svc, cmd).await?; + println!("Turned Switch1 Off"); + + let query = GetSwitchState { + id: "Switch1".to_string(), + } + .into(); + let state = query::Handler::handle(&svc, query).await?; + println!("Switch1 is currently: {:?}", state); + Ok(()) +} diff --git a/examples/light-switch/src/queries/get_switch_state.rs b/examples/light-switch/src/queries/get_switch_state.rs new file mode 100644 index 00000000..b931890a --- /dev/null +++ b/examples/light-switch/src/queries/get_switch_state.rs @@ -0,0 +1,35 @@ +use async_trait::async_trait; +use eventually::{aggregate, message, query}; + +use crate::application::LightSwitchService; +use crate::domain::{LightSwitch, LightSwitchId, LightSwitchRoot, LightSwitchState}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GetSwitchState { + pub id: LightSwitchId, +} + +impl message::Message for GetSwitchState { + fn name(&self) -> &'static str { + "GetSwitch" + } +} + +#[async_trait] +impl query::Handler for LightSwitchService +where + R: aggregate::Repository, +{ + type Error = anyhow::Error; + type Output = LightSwitchState; + + async fn handle( + &self, + query: query::Envelope, + ) -> Result { + let query = query.message; + let root: LightSwitchRoot = self.light_switch_repository.get(&query.id).await?.into(); + let s = root.get_switch_state()?; + Ok(s) + } +} diff --git a/examples/light-switch/src/queries/mod.rs b/examples/light-switch/src/queries/mod.rs new file mode 100644 index 00000000..0a887d4a --- /dev/null +++ b/examples/light-switch/src/queries/mod.rs @@ -0,0 +1 @@ +pub mod get_switch_state;