Skip to content

Instantly share code, notes, and snippets.

@akhansari
Last active April 8, 2024 12:40
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save akhansari/b43a9a60ba1ca3f2a8c8705aa0db3efb to your computer and use it in GitHub Desktop.
Save akhansari/b43a9a60ba1ca3f2a8c8705aa0db3efb to your computer and use it in GitHub Desktop.
TypeScript prototype of the Decider pattern. (F# version: https://github.com/akhansari/EsBankAccount)
import * as assert from "assert";
/** Decider Pattern **/
type Transaction = {
amount: number
date: Date
}
type Deposited = Transaction & {
kind: "deposited"
}
type Withdrawn = Transaction & {
kind: "withdrawn"
}
type Closed = {
kind: "closed"
closedOn: Date
}
export type Event_ =
| Deposited
| Withdrawn
| Closed
type State = {
Balance: number
IsClosed: boolean
}
export module State {
export const initial: Readonly<State> = {
Balance: 0,
IsClosed: false,
}
}
export const evolve = (state: Readonly<State>, event: Readonly<Event_>): Readonly<State> => {
switch (event.kind) {
case "deposited":
return { ...state, Balance: state.Balance + event.amount }
case "withdrawn":
return { ...state, Balance: state.Balance - event.amount }
case "closed":
return { ...state, IsClosed: true }
}
}
export type Command =
| { kind: "deposit", amount: number, date: Date }
| { kind: "withdraw", amount: number, date: Date }
| { kind: "close", date: Date }
export module Command {
export const deposit = (amount: number, date: Date): Command =>
({ kind: "deposit", amount: amount, date: date })
export const withdraw = (amount: number, date: Date): Command =>
({ kind: "withdraw", amount: amount, date: date })
export const close = (date: Date): Command =>
({ kind: "close", date: date })
}
export const decide = (command: Readonly<Command>) => (state: Readonly<State>): ReadonlyArray<Event_> => {
switch (command.kind) {
case "deposit":
return [ { kind: "deposited", amount: command.amount, date: command.date } ]
case "withdraw":
return [ { kind: "withdrawn", amount: command.amount, date: command.date } ]
case "close":
const events: Event_[] = []
if (state.Balance > 0) {
events.push({ kind: "withdrawn", amount: state.Balance, date: command.date })
}
events.push({ kind: "closed", closedOn: command.date })
return events
}
}
/** Tests **/
function deciderSpec<S, C, E>(
initialState: Readonly<S>,
evolve: (s: Readonly<S>, e: Readonly<E>) => Readonly<S>,
decide: (c: Readonly<C>) => (s: Readonly<S>) => ReadonlyArray<E>) {
const spec: { State: Readonly<S>, Outcome: ReadonlyArray<E> } = {
State: initialState,
Outcome: [],
}
return {
given(events: ReadonlyArray<E>) {
spec.State = events.reduce(evolve, spec.State)
return this
},
when(command: Readonly<C>) {
const events = decide(command)(spec.State)
spec.State = events.reduce(evolve, spec.State)
spec.Outcome = events
return this
},
then(expectedEvents: ReadonlyArray<E>) {
assert.deepStrictEqual(spec.Outcome, expectedEvents)
return this
},
}
}
const test = (message: string, fn: Function): void => {
try {
fn()
console.log(" ✅ " + message)
} catch (e) {
console.error(" ❌ " + message)
console.error(e)
}
}
const anyDate = new Date(2000, 1, 1)
test("make a deposit", () => {
deciderSpec(State.initial, evolve, decide)
.when(Command.deposit(5, anyDate))
.then([ { kind: "deposited", amount: 5, date: anyDate } ])
})
test("make a withdrawal", () => {
deciderSpec(State.initial, evolve, decide)
.when(Command.withdraw(5, anyDate))
.then([ { kind: "withdrawn", amount: 5, date: anyDate } ])
})
test("close the account and withdraw the remaining amount", () => {
deciderSpec(State.initial, evolve, decide)
.given([
{ kind: "deposited", amount: 50, date: anyDate },
])
.when(Command.deposit(50, anyDate))
.when(Command.close(anyDate))
.then([
{ kind: "withdrawn", amount: 100, date: anyDate },
{ kind: "closed", closedOn: anyDate },
])
})
@cjjohansen
Copy link

cjjohansen commented Mar 9, 2024

How would you model a money transfer from one account to another. It must be some kind of processor that assures that withdrawal and then deposit succeeds. With possible compensating action? Would you create a new desider maybe named money transfer with say initiate, withdraw, deposit, complete commands and corresponding events? Also it would have to subscribe to events from the account aggregates.

@akhansari
Copy link
Author

@cjjohansen It's a complicated question, because this bank account example is oversimplified.
I'm not an expert of banking system, but for sure we are in a fully asynchronous system.
Normally we do a request and once the remote system acknowledge the transfer then we can make a withdrawal.
It makes sens to have a MoneyTransferRequest stream to persist different steps and especially all legal events.
So there shouldn't be any change in the BankAccount stream.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment