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.