disintegrate icon indicating copy to clipboard operation
disintegrate copied to clipboard

How to do uniqueness check

Open gmkumar2005 opened this issue 8 months ago • 3 comments

I want to check is the Student.name is unique before generating the student_created_event. What is the best way to achieve this?

  1. Is there a way to run query! outside decision_maker to detect unique key volition ?
  2. Is there a way to inject a service like "reservation_service.reserve_unique(&self.email).await;" into process function?
  3. Any other best practice ?

gmkumar2005 avatar May 17 '25 07:05 gmkumar2005

Here are 3 ideas off the top of my head...

  1. Due to the historic use of natural keys as primary indexes, some users have come to expect all identifiers to be unique. In many cases this does not reflect reality. If two people have the same name you cannot change that fact. How would you handle this if it was a paper based system? You'd probably provide enough context so that the user could select the correct record.

  2. Some people use read models to prevalidate the uniqueness before executing a command. The read model will be eventually consistent so there's a small time window when a duplicate could occur. The odds of this actually happening is often small so this can be an acceptable solution in many use cases.

  3. Annotate the name field of the event with #[id]. This will index the event by the name. You can now construct a StateQuery using the name as an identifier to determine if the name has already been used. Thus the validation becomes part of the process method. If renaming of students is a thing, you might want to consider creating separate events for handling the names. So therefore create a StudentName stream, containing two events AllocateName(#[id] name) and UnallocateName(#[id] name). So a rename command would emit two events, an UnallocateName for the old name and an AllocateName for the new name. In summary the concept is, index some events by the name and use them to create StateQuerys to determine if the name is in use.

phillipbaird avatar May 17 '25 10:05 phillipbaird

Thanks, @phillipbaird.

I’m considering amending the business model to allow duplicates and detect them later in the projection layer. We can then mark duplicate records so business users can decide which ones to keep or discard.

For distributed processing, the duplicate detection would run as a scheduled task.

I’d appreciate your thoughts on this approach.

Thanks, Kiran

gmkumar2005 avatar May 19 '25 16:05 gmkumar2005

"mark duplicate records" indicates a state change which requires a command/event pair. "users can decide" suggests a read model (aka projection) from the above events in order to make the decision. It also infers the commands "keep" and "discard" which have corresponding events. The "duplicate detection" would then run through the "added". "marked as duplicate", "kept" and "discarded" events to determine what "mark duplicate" commands to process.

Have I interpreted your narrative correctly? If not then you may need to clarify the details.

phillipbaird avatar May 20 '25 00:05 phillipbaird

Did u get a chance to look at the approach described ? https://dcb.events/examples/unique-username/

gmkumar2005 avatar May 28 '25 11:05 gmkumar2005

The approach described on the dcb.events website is exactly the same as option 3 in my original reply.

dcb.events is written by the folks developing the AXON Java framework. The terminology they use on that site is tightly coupled to their own implementation. In this case they say... "With DCB all Events that affect the unique constraint (the username in this example) can be tagged with the corresponding value (or a hash of it)". "Tagged" means indexed.

So if you have the following events, putting the #[id] attribute on the username means that those events will be indexed by the username field. In dcb.events terminology they have been "tagged". So now you can build Disintegrate StateQuery(s) to select events by username and determine if the username is; in use, a duplicate, etc. etc.

#[derive(disintegrate::Event)]
pub enum DomainEvent {
    AccountRegistered {
        #[id]
        username: String,
        ...
    },
    AccountClosed {
        #[id]
        username: String,
        ...
    }
}

phillipbaird avatar May 29 '25 00:05 phillipbaird

Thanks @phillipbaird Now I understand better. DCB has discussed more scenarios 1) Releasing an UserName and 2) Allowing change of UserName. 3) Username retention Does the combination of state_query and validation_query achieve above scenarios ?

gmkumar2005 avatar May 29 '25 06:05 gmkumar2005

A validation_query is used to fine tune optimistic concurrency and is not related to the problem at hand. See the final paragraph of the Rust docs for an example where you might use it. Note: When the docs talk about "invalidating the decision" they are saying this in the context of concurrent updates by multiple users.

Everything you want to do with username uniqueness can be achieve with simple commands and state. Here's a slightly more fleshed out example to illustrate...

#[derive(disintegrate::Event)]
#[stream(UsernameStream,[AccountRegistered, AccountClosed])]
pub enum DomainEvent {
    AccountRegistered {
        #[id]
        username: String,
        ...
    },
    AccountClosed {
        #[id]
        username: String,
        date_closed: Date,
        ...
    },
    ...
}

pub struct RegisterUserCommand {
    pub username: String,
    ...
}

impl Decision for RegisterUserCommand {
    type Event = DomainEvent;
    type StateQuery = RegisterUserState;
    type Error = DomainError;

   fn state_query(&self) -> Self::StateQuery {
        RegisterUserState {
            username: self.username,
            username_in_use: false,
            days_since_closure: None,
        }
    }

    fn process(&self, state: &Self::StateQuery) -> Result<Vec<Self::Event>, Self::Error> {
        if state.username_in_use {
            return Err(DomainError::UsernameIsNotUnique);
        }

        if let Some(days_since_closure) = state.days_since_closure {
            if days_since_closure < 7 {
                return Err(DomainError::UsernameCannotBeReusedWithinWeekOfAccountClosure);
            }
        }

        Ok(vec![
            DomainEvent::AccountRegistered {
                username: self.username,
                ...
            }
        ])

    }
}

#[derive(Clone, Debug, StateQuery)]
#[state_query(UsernameStream)]
pub struct RegisterUserState {
    #[id]
    username: String,
    username_in_use: bool,
    days_since_closure: Option<isize>,
}

impl StateMutate for RegisterUserState {
    fn mutate(&mut self, event: Self::Event) {
        match event {
            UsernameStream::AccountRegistered { .. } => {
                self.username_in_use = true;
                self.days_since_closure = None;
            }
            UsernameStream::AccountClosed{ date_closed, .. } => {
                self.username_in_use = false;
                self.days_since_closure = Some((todays_date - date_closed).num_days());
            }
        }
    }
}

phillipbaird avatar May 29 '25 08:05 phillipbaird

Thanks

gmkumar2005 avatar May 30 '25 09:05 gmkumar2005