6
\$\begingroup\$

I am writing an event sourced system in which I need to have the state based on the events that already occurred for a given bank account.

The events are defined such as:

type AccountOpenedEvent = {
    AccountId: Guid
    Contact: Contact
    Timestamp: DateTimeOffset
}

type AccountClosedEvent = {
    AccountId: Guid
    Reason: string
    Timestamp: DateTimeOffset
}

type AccountCreditedEvent = {
    AccountId: Guid
    Amount: decimal
    Description: string
    Timestamp: DateTimeOffset
}

type AccountDebitedEvent = {
    AccountId: Guid
    Amount: decimal
    Description: string
    Timestamp: DateTimeOffset
}

type AddressChangeRequestedEvent = {
    AccountId: Guid
    NewAddress: Address
    Timestamp: DateTimeOffset
}

type PhoneNumberChangeRequestedEvent = {
    AccountId: Guid
    NewPhoneNumber: PhoneNumber
    Timestamp: DateTimeOffset
}

type EmailChangeRequestedEvent = {
    AccountId: Guid
    NewEmail: string
    Timestamp: DateTimeOffset
}

type AddressChangeValidatedEvent = {
    AccountId: Guid
    NewAddress: Address
    Timestamp: DateTimeOffset
}

type PhoneNumberChangeValidatedEvent = {
    AccountId: Guid
    NewPhoneNumber: PhoneNumber
    Timestamp: DateTimeOffset
}

type EmailChangeValidatedEvent = {
    AccountId: Guid
    NewEmail: string
    Timestamp: DateTimeOffset
}

type InvalidOperationAttemptedEvent = {
    Error: string
    Timestamp: DateTimeOffset
}

type Event =
    | AccountOpened of AccountOpenedEvent
    | AccountClosed of AccountClosedEvent
    | AccountCredited of AccountCreditedEvent
    | AccountDebited of AccountDebitedEvent
    | AddressChangeRequested of AddressChangeRequestedEvent
    | PhoneNumberChangeRequested of PhoneNumberChangeRequestedEvent
    | EmailChangeRequested of EmailChangeRequestedEvent
    | AddressChangeValidated of AddressChangeValidatedEvent
    | PhoneNumberChangeValidated of PhoneNumberChangeValidatedEvent
    | EmailChangeValidated of EmailChangeValidatedEvent
    | InvalidOperationAttempted of InvalidOperationAttemptedEvent

The projection system is defined as:

type Projection<'State,'Event> = {
    Init : 'State
    Update : 'State -> 'Event -> 'State
}

let project projection events =
    events |> Seq.fold projection.Update projection.Init

and the update function to update the state depending on the event received:

let updateAccountState (state : AccountState) (event : Event)=
    match event with
    | AccountOpened accountOpened ->
        match state.Status with
        | Some status ->
            match status with
            | AccountStatus.Opened when accountOpened.AccountId = state.Id.Value ->
                let message = "The account cannot be opened cause it is already opened."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            | AccountStatus.Opened when accountOpened.AccountId <> state.Id.Value ->
                let message = "The account cannot be opened cause the account id does not match this account."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            | AccountStatus.Closed when accountOpened.AccountId = state.Id.Value ->
                let message = "The account cannot be opened cause it is closed."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            // | AccountStatus.Closed when accountOpened.AccountId <> state.Id.Value ->
            | _ ->
                let message = "The account cannot be opened cause the account id does not match this account."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
        | None ->
            { state with
                Contact = Some accountOpened.Contact
                Status = Some AccountStatus.Opened
                Balance = Some 0m
                Created = Some accountOpened.Timestamp
                Updated = Some accountOpened.Timestamp
                Id = Some accountOpened.AccountId
            }
    | AccountClosed accountClosed ->
        match state.Status with
        | Some status ->
            match status with
            | AccountStatus.Opened when accountClosed.AccountId = state.Id.Value ->
                { state with
                    Status = Some AccountStatus.Closed
                    Updated = Some accountClosed.Timestamp
                }
            | AccountStatus.Opened when accountClosed.AccountId <> state.Id.Value ->
                let message = "The account cannot be closed cause the account id does not match this account."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            | AccountStatus.Closed when accountClosed.AccountId = state.Id.Value ->
                let message = "The account cannot be closed cause it is already closed."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            // | AccountStatus.Closed when accountClosed.AccountId <> state.Id.Value ->
            | _ ->
                let message = "The account cannot be closed cause the account id does not match this account."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
        | None ->
            let message = "The account cannot be closed cause it is not yet created."
            { state with AuditInvalidations = message :: state.AuditInvalidations }
    | AccountCredited accountCredited ->
        match state.Status with
        | Some status ->
            match status with 
            | AccountStatus.Opened when accountCredited.AccountId = state.Id.Value ->
                { state with
                    Balance = Some (state.Balance.Value + accountCredited.Amount)
                    Updated = Some accountCredited.Timestamp
                }
            | AccountStatus.Opened when accountCredited.AccountId <> state.Id.Value ->
                let message = "The account cannot be credited cause the account id does not match this account."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            | AccountStatus.Closed when accountCredited.AccountId = state.Id.Value ->
                let message = "The account cannot be credited cause it is closed."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            // | AccountStatus.Closed when accountCredited.AccountId <> state.Id.Value ->
            | _ ->
                let message = "The account cannot be credited cause the account id does not match this account."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
        | None ->
            let message = "The account cannot be credited cause it is not yet created."
            { state with AuditInvalidations = message :: state.AuditInvalidations }
    | AccountDebited accountDebited ->
        match state.Status with
        | Some status ->
            match status with 
            | AccountStatus.Opened when accountDebited.AccountId = state.Id.Value ->
                { state with
                    Balance = Some (state.Balance.Value - accountDebited.Amount)
                    Updated = Some accountDebited.Timestamp
                }
            | AccountStatus.Opened when accountDebited.AccountId <> state.Id.Value ->
                let message = "The account cannot be debited cause the account id does not match this account."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            | AccountStatus.Closed when accountDebited.AccountId = state.Id.Value ->
                let message = "The account cannot be debited cause it is closed."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            // | AccountStatus.Closed when accountDebited.AccountId <> state.Id.Value ->
            | _ ->
                let message = "The account cannot be debited cause the account id does not match this account."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
        | None ->
            let message = "The account cannot be debited cause it is not yet created."
            { state with AuditInvalidations = message :: state.AuditInvalidations }
    | AddressChangeRequested addressChangeRequested ->
        match state.Status with
        | Some status ->
            match status with 
            | AccountStatus.Opened when addressChangeRequested.AccountId = state.Id.Value ->
                { state with
                    NewAddressRequested = Some addressChangeRequested.NewAddress
                    Updated = Some addressChangeRequested.Timestamp
                }
            | AccountStatus.Opened when addressChangeRequested.AccountId <> state.Id.Value ->
                let message = "The account cannot receive an address change request cause the account id does not match this account."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            | AccountStatus.Closed when addressChangeRequested.AccountId = state.Id.Value ->
                let message = "The account cannot receive an address change request cause it is closed."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            // | AccountStatus.Closed when addressChangeRequested.AccountId <> state.Id.Value ->
            | _ ->
                let message = "The account cannot receive an address change request cause the account id does not match this account."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
        | None ->
            let message = "The account cannot receive an address change cause it is not yet created."
            { state with AuditInvalidations = message :: state.AuditInvalidations }
    | PhoneNumberChangeRequested phoneNumberChangeRequested ->
        match state.Status with
        | Some status ->
            match status with 
            | AccountStatus.Opened when phoneNumberChangeRequested.AccountId = state.Id.Value ->
                { state with
                    NewPhoneNumberRequested = Some phoneNumberChangeRequested.NewPhoneNumber
                    Updated = Some phoneNumberChangeRequested.Timestamp
                }
            | AccountStatus.Opened when phoneNumberChangeRequested.AccountId <> state.Id.Value ->
                let message = "The account cannot receive a phone number change request cause the account id does not match this account."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            | AccountStatus.Closed when phoneNumberChangeRequested.AccountId = state.Id.Value ->
                let message = "The account cannot receive a phone number change request cause it is closed."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            // | AccountStatus.Closed when addressChangeRequested.AccountId <> state.Id.Value ->
            | _ ->
                let message = "The account cannot receive a phone number change request cause the account id does not match this account."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
        | None ->
            let message = "The account cannot receive a phone number change cause it is not yet created."
            { state with AuditInvalidations = message :: state.AuditInvalidations }
    | EmailChangeRequested emailChangeRequested ->
        match state.Status with
        | Some status ->
            match status with 
            | AccountStatus.Opened when emailChangeRequested.AccountId = state.Id.Value ->
                { state with
                    NewEmailRequested = Some emailChangeRequested.NewEmail
                    Updated = Some emailChangeRequested.Timestamp
                }
            | AccountStatus.Opened when emailChangeRequested.AccountId <> state.Id.Value ->
                let message = "The account cannot receive an email change request cause the account id does not match this account."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            | AccountStatus.Closed when emailChangeRequested.AccountId = state.Id.Value ->
                let message = "The account cannot receive an email change request cause it is closed."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            // | AccountStatus.Closed when emailChangeRequested.AccountId <> state.Id.Value ->
            | _ ->
                let message = "The account cannot receive an email change request cause the account id does not match this account."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
        | None ->
            let message = "The account cannot receive an email change request cause it is not yet created."
            { state with AuditInvalidations = message :: state.AuditInvalidations }
    | AddressChangeValidated addressChangeValidated ->
        match state.Status with
        | Some status ->
            match status with 
            | AccountStatus.Opened
                when addressChangeValidated.AccountId = state.Id.Value
                     && addressChangeValidated.NewAddress = state.NewAddressRequested.Value ->
                let contact = { state.Contact.Value with Address = state.NewAddressRequested.Value }
                { state with
                    Contact = Some contact
                    Updated = Some addressChangeValidated.Timestamp
                    NewAddressRequested = None
                }
            | AccountStatus.Opened
                when addressChangeValidated.AccountId = state.Id.Value
                     && addressChangeValidated.NewAddress <> state.NewAddressRequested.Value ->
                let message = "The account cannot validate an address change request cause the request was made for a different address."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            | AccountStatus.Opened when addressChangeValidated.AccountId <> state.Id.Value ->
                let message = "The account cannot validate an address change request cause the account id does not match this account."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            | AccountStatus.Closed when addressChangeValidated.AccountId = state.Id.Value ->
                let message = "The account cannot validate an address change request cause it is closed."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            // | AccountStatus.Closed when addressChangeValidated.AccountId <> state.Id.Value ->
            | _ ->
                let message = "The account cannot validate an address change request cause the account id does not match this account."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
        | None ->
            let message = "The account cannot validate an address change cause it is not yet created."
            { state with AuditInvalidations = message :: state.AuditInvalidations }
    | PhoneNumberChangeValidated phoneNumberChangeValidated ->
        match state.Status with
        | Some status ->
            match status with 
            | AccountStatus.Opened
                when phoneNumberChangeValidated.AccountId = state.Id.Value
                     && phoneNumberChangeValidated.NewPhoneNumber = state.NewPhoneNumberRequested.Value ->
                let contact = { state.Contact.Value with PhoneNumber = state.NewPhoneNumberRequested.Value }
                { state with
                    Contact = Some contact
                    Updated = Some phoneNumberChangeValidated.Timestamp
                    NewPhoneNumberRequested = None
                }
            | AccountStatus.Opened
                when phoneNumberChangeValidated.AccountId = state.Id.Value
                     && phoneNumberChangeValidated.NewPhoneNumber <> state.NewPhoneNumberRequested.Value ->
                let message = "The account cannot validate a phone number change request cause the request was made for a different phone number."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            | AccountStatus.Opened when phoneNumberChangeValidated.AccountId <> state.Id.Value ->
                let message = "The account cannot validate a phone number change request cause the account id does not match this account."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            | AccountStatus.Closed when phoneNumberChangeValidated.AccountId = state.Id.Value ->
                let message = "The account cannot validate a phone number change request cause it is closed."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            // | AccountStatus.Closed when phoneNumberChangeValidated.AccountId <> state.Id.Value ->
            | _ ->
                let message = "The account cannot validate a phone number change request cause the account id does not match this account."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
        | None ->
            let message = "The account cannot validate a phone number change cause it is not yet created."
            { state with AuditInvalidations = message :: state.AuditInvalidations }
    | EmailChangeValidated emailChangeValidated ->
        match state.Status with
        | Some status ->
            match status with 
            | AccountStatus.Opened
                when emailChangeValidated.AccountId = state.Id.Value
                     && emailChangeValidated.NewEmail = state.NewEmailRequested.Value ->
                let contact = { state.Contact.Value with PhoneNumber = state.NewPhoneNumberRequested.Value }
                { state with
                    Contact = Some contact
                    Updated = Some emailChangeValidated.Timestamp
                    NewEmailRequested = None
                }
            | AccountStatus.Opened
                when emailChangeValidated.AccountId = state.Id.Value
                     && emailChangeValidated.NewEmail <> state.NewEmailRequested.Value ->
                let message = "The account cannot validate an email change request cause the request was made for a different phone number."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            | AccountStatus.Opened when emailChangeValidated.AccountId <> state.Id.Value ->
                let message = "The account cannot validate an email change request cause the account id does not match this account."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            | AccountStatus.Closed when emailChangeValidated.AccountId = state.Id.Value ->
                let message = "The account cannot validate an email change request cause it is closed."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            // | AccountStatus.Closed when emailChangeValidated.AccountId <> state.Id.Value ->
            | _ ->
                let message = "The account cannot validate an email change request cause the account id does not match this account."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
        | None ->
            let message = "The account cannot validate a phone number change cause it is not yet created."
            { state with AuditInvalidations = message :: state.AuditInvalidations }
    | InvalidOperationAttempted invalidOperationAttempted ->
        { state with
            AuditInvalidations = invalidOperationAttempted.Error :: state.AuditInvalidations
            Updated = Some invalidOperationAttempted.Timestamp
        }

I found my implementations being a wee bit redundant about the points below:

  • the option check (ie. Some / None) not sure how it can be avoided or refactored to make the pattern matching "lighter"
  • the Id check for the same reasons

I am not too sure how can this be changed and have something more readable.

\$\endgroup\$
  • \$\begingroup\$ @dfhwze I think so, why? \$\endgroup\$ – Ehouarn Perret Jun 11 at 8:14
3
\$\begingroup\$

I think, I would change AccountState to the following:

type AccountStatus =
    | Uninitialized
    | Opened
    | Closed

In this way you have the full life cycle of an account and you then can avoid Some/None for the account status


The AccountState could then be changed to:

type AccountState = {
    Id: Guid
    Status: AccountStatus
    AuditInvalidations: string list
    Contact: Contact
    Balance: decimal
    Created: DateTimeOffset option
    Updated: DateTimeOffset option
    NewAddressRequested: Address option
    NewPhoneNumberRequested: PhoneNumber option
    NewEmailRequested: string option
   }

Where option is removed from fields that IMO shouldn't be optional: An Account without an Id or a Contact seems rather scary to me and the Balance has always a value (that may be 0.0m), and with the AccountStatus.Uninitialized, Status can also always have a value.


In updateAccountState you can consider to evaluate the Id at the first match level in order to get rid of repetitive checks of it. Below is the first two matches as examples:

let updateAccountState (state : AccountState) (event : Event)=
    match event with
    | AccountOpened account ->
        match account.AccountId with
        | id when id = state.Id ->
            match state.Status with
            | AccountStatus.Opened ->
                let message = "The account cannot be opened cause it is already opened."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            | AccountStatus.Closed ->
                let message = "The account cannot be opened cause it is closed."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            | AccountStatus.Uninitialized ->
                { state with
                    Contact = account.Contact
                    Status = AccountStatus.Opened
                    Balance = 0m
                    Created = Some account.Timestamp
                    Updated = Some account.Timestamp
                    Id = account.AccountId
                }
        | _ -> 
            let message = "The account cannot be opened cause the account id does not match this account."
            { state with AuditInvalidations = message :: state.AuditInvalidations }
    | AccountClosed account ->
        match account.AccountId with
        | id when id = state.Id ->
            match state.Status with
            | AccountStatus.Opened ->
                { state with
                    Status = AccountStatus.Closed
                    Updated = Some account.Timestamp
                }
            | AccountStatus.Closed ->
                let message = "The account cannot be closed cause it is already closed."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
            | AccountStatus.Uninitialized ->
                let message = "The account cannot be closed cause it is not yet created."
                { state with AuditInvalidations = message :: state.AuditInvalidations }
        | _ ->
            let message = "The account cannot be closed cause the account id does not match this account."
            { state with AuditInvalidations = message :: state.AuditInvalidations }

This is IMO more clear and easy to follow.


Update

All your AccountXXXEvent events have AccountId and Timestamp in common, so these properties could be placed on Event and then AccountXXXEvent could be discriminated unions as well:

type AccountEvent =
    | AccountOpenedEvent of Contact: Contact
    | AccountClosedEvent of Reason: string
    | AccountCreditEvent of Amount:decimal * Description:string
    | etc...

Event could then look like:

type Event = Event of Id:Guid * Timestamp:DateTimeOffset * AccountEvent:AccountEvent

and finally updateAccountState as:

let updateAccountState (state : AccountState) (event : Event) =
    match event with
    | Event(id, timestamp, accountEvent) ->
        match id with
        | stateId when stateId = state.Id ->
            match accountEvent with
            | AccountOpenedEvent contact ->
                match state.Status with
                | AccountStatus.Opened -> invalidate state "The account cannot be opened cause it is already opened."
                | AccountStatus.Closed -> invalidate state "The account cannot be opened cause it is closed."
                | AccountStatus.Uninitialized ->
                    { state with
                        Contact = contact
                        Status = AccountStatus.Opened
                        Balance = 0m
                        Created = Some timestamp
                        Updated = Some timestamp
                        Id = id
                    }
            | AccountClosedEvent reason -> 
                match state.Status with
                | AccountStatus.Opened ->
                    { state with
                        Status = AccountStatus.Closed
                        Updated = Some timestamp
                    }
                | AccountStatus.Closed -> invalidate state "The account cannot be closed cause it is already closed."
                | AccountStatus.Uninitialized -> invalidate state "The account cannot be closed cause it is not yet created."
            | // TODO all the other events
        | _ -> 
            invalidate state "The transaction can not be fulfilled because the ids don't match." 

where invalidate is defined as:

let invalidate state message = { state with AuditInvalidations = message :: state.AuditInvalidations }
\$\endgroup\$
  • \$\begingroup\$ thanks, interesting suggestions, I am gonna refactor my code \$\endgroup\$ – Ehouarn Perret Jun 11 at 14:15

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.