Last active
April 19, 2026 01:09
-
-
Save ababup1192/fa0f6f9dd84c0549ca5a8c4270571cce to your computer and use it in GitHub Desktop.
Flix Decider pattern
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| pub type alias Decider[c, e, s] = { | |
| decide = c -> s -> List[e], | |
| evolve = s -> e -> s, | |
| initialState = s, | |
| isTerminal = s -> Bool | |
| } | |
| mod Decider { | |
| pub def fold(decider: Decider[c, e, s], events: List[e]): s = | |
| List.foldLeft((state, event) -> decider#evolve(state, event), decider#initialState, events) | |
| pub def foldFrom(decider: Decider[c, e, s], state: s, events: List[e]): s = | |
| List.foldLeft((st, event) -> decider#evolve(st, event), state, events) | |
| pub def execute(decider: Decider[c, e, s], state: s, command: c): (s, List[e]) = | |
| let events = decider#decide(command, state); | |
| let newState = foldFrom(decider, state, events); | |
| (newState, events) | |
| pub def executeAll(decider: Decider[c, e, s], state: s, commands: List[c]): (s, List[e]) = | |
| List.foldLeft((acc, cmd) -> { | |
| let (currentState, accEvents) = acc; | |
| let (newState, newEvents) = execute(decider, currentState, cmd); | |
| (newState, List.append(accEvents, newEvents)) | |
| }, (state, Nil), commands) | |
| pub def executeAllFromInitial(decider: Decider[c, e, s], commands: List[c]): (s, List[e]) = | |
| executeAll(decider, decider#initialState, commands) | |
| } | |
| // ============================================================================ | |
| // 本の貸出ドメイン | |
| // ============================================================================ | |
| pub enum BookCommand with Eq, ToString { | |
| case Register // 本を登録する | |
| case CheckOut(String) // 本を貸し出す(borrowerId) | |
| case Return // 本を返却する | |
| } | |
| pub enum BookEvent with Eq, ToString { | |
| case Registered // 書籍登録イベント | |
| case CheckedOut(String) // 書籍貸出イベント(borrowerId) | |
| case Returned // 書籍返却イベント | |
| } | |
| pub enum BookState with Eq, ToString { | |
| case UnregisteredBook // 未登録本 | |
| case AvailableBook // 貸出可能本 | |
| case CheckedOutBook(String) // 貸出中(borrowerId) | |
| } | |
| mod BookLending { | |
| use BookCommand.{Register, CheckOut, Return} | |
| use BookEvent.{Registered, CheckedOut, Returned} | |
| use BookState.{UnregisteredBook, AvailableBook, CheckedOutBook} | |
| pub def decide(command: BookCommand, state: BookState): List[BookEvent] = | |
| match (command, state) { | |
| case (Register, UnregisteredBook) => | |
| Registered :: Nil | |
| case (CheckOut(borrowerId), AvailableBook) => | |
| CheckedOut(borrowerId) :: Nil | |
| case (Return, CheckedOutBook(_)) => | |
| Returned :: Nil | |
| case _ => | |
| Nil // 無効な操作 | |
| } | |
| pub def evolve(state: BookState, event: BookEvent): BookState = | |
| match (state, event) { | |
| case (UnregisteredBook, Registered) => | |
| AvailableBook | |
| case (AvailableBook, CheckedOut(borrowerId)) => | |
| CheckedOutBook(borrowerId) | |
| case (CheckedOutBook(_), Returned) => | |
| AvailableBook | |
| case _ => | |
| state // 無効なイベントは状態を変更しない | |
| } | |
| pub def initialState(): BookState = UnregisteredBook | |
| pub def isTerminal(_state: BookState): Bool = false | |
| pub def decider(): Decider[BookCommand, BookEvent, BookState] = { | |
| decide = decide, | |
| evolve = evolve, | |
| initialState = initialState(), | |
| isTerminal = isTerminal | |
| } | |
| pub def getBorrower(state: BookState): Option[String] = | |
| match state { | |
| case CheckedOutBook(borrowerId) => Some(borrowerId) | |
| case _ => None | |
| } | |
| pub def isAvailable(state: BookState): Bool = | |
| match state { | |
| case AvailableBook => true | |
| case _ => false | |
| } | |
| } | |
| // ============================================================================ | |
| // イベントストア(インメモリ) | |
| // ============================================================================ | |
| mod EventStore { | |
| pub def load(store: Map[String, List[e]], streamId: String): List[e] = | |
| Map.getWithDefault(streamId, Nil, store) | |
| pub def append(store: Map[String, List[e]], streamId: String, events: List[e]): Map[String, List[e]] = | |
| let existing = load(store, streamId); | |
| let updated = List.append(existing, events); | |
| Map.insert(streamId, updated, store) | |
| } | |
| // ============================================================================ | |
| // ファイルベースイベントストア(DB代わり) | |
| // ============================================================================ | |
| mod FileStore { | |
| import java.io.{File, FileWriter, BufferedWriter, FileReader, BufferedReader} | |
| pub def loadEvents(path: String): List[BookEvent] \ IO = | |
| let file = new File(path); | |
| if (file.exists()) { | |
| let reader = new BufferedReader(new FileReader(file)); | |
| let lines = readAllLines(reader, Nil); | |
| reader.close(); | |
| List.filterMap(line -> parseLine(line), lines) | |
| } else { | |
| Nil | |
| } | |
| def readAllLines(reader: BufferedReader, acc: List[String]): List[String] \ IO = | |
| let line = reader.readLine(); | |
| if (Object.isNull(line)) { | |
| List.reverse(acc) | |
| } else { | |
| readAllLines(reader, String.trim(line) :: acc) | |
| } | |
| pub def appendEvents(path: String, events: List[BookEvent]): Unit \ IO = | |
| let file = new File(path); | |
| let parent = file.getParentFile(); | |
| if (not Object.isNull(parent) and not parent.exists()) { | |
| discard parent.mkdirs() | |
| } else { | |
| () | |
| }; | |
| let writer = new BufferedWriter(new FileWriter(file, true)); | |
| List.forEach(event -> { | |
| writer.write(eventToString(event)); | |
| writer.newLine() | |
| }, events); | |
| writer.close() | |
| pub def eventToString(event: BookEvent): String = | |
| match event { | |
| case BookEvent.Registered => "Registered" | |
| case BookEvent.CheckedOut(borrowerId) => "CheckedOut(${borrowerId})" | |
| case BookEvent.Returned => "Returned" | |
| } | |
| def parseLine(line: String): Option[BookEvent] = | |
| if (String.isEmpty(line)) { | |
| None | |
| } else if (line == "Registered") { | |
| Some(BookEvent.Registered) | |
| } else if (line == "Returned") { | |
| Some(BookEvent.Returned) | |
| } else { | |
| parseCheckedOut(line) | |
| } | |
| def parseCheckedOut(line: String): Option[BookEvent] = | |
| let prefix = "CheckedOut("; | |
| let prefixLen = String.length(prefix); | |
| if (String.indexOf({substr = prefix}, line) == Some(0)) { | |
| let len = String.length(line); | |
| if (len > prefixLen + 1) { | |
| let borrowerId = String.sliceRight({start = prefixLen}, String.sliceLeft({end = len - 1}, line)); | |
| Some(BookEvent.CheckedOut(borrowerId)) | |
| } else { | |
| None | |
| } | |
| } else { | |
| None | |
| } | |
| } | |
| eff BookEventStoreEff { | |
| def loadBookEvents(streamId: String): List[BookEvent] | |
| def appendBookEvents(streamId: String, events: List[BookEvent]): Unit | |
| } | |
| eff BookDecisionEff { | |
| def onBookDecide(streamId: String, command: BookCommand, events: List[BookEvent]): Unit | |
| def onBookReject(streamId: String, command: BookCommand, reason: String): Unit | |
| } | |
| mod EffectfulRunner { | |
| pub def execute( | |
| decider: Decider[BookCommand, BookEvent, BookState], | |
| streamId: String, | |
| command: BookCommand | |
| ): List[BookEvent] \ BookEventStoreEff + BookDecisionEff = | |
| let events = BookEventStoreEff.loadBookEvents(streamId); | |
| let currentState = Decider.fold(decider, events); | |
| let newEvents = decider#decide(command, currentState); | |
| match newEvents { | |
| case Nil => | |
| BookDecisionEff.onBookReject(streamId, command, "No events generated"); | |
| Nil | |
| case _ => | |
| BookEventStoreEff.appendBookEvents(streamId, newEvents); | |
| BookDecisionEff.onBookDecide(streamId, command, newEvents); | |
| newEvents | |
| } | |
| pub def executeAll( | |
| decider: Decider[BookCommand, BookEvent, BookState], | |
| streamId: String, | |
| commands: List[BookCommand] | |
| ): List[BookEvent] \ BookEventStoreEff + BookDecisionEff = | |
| List.flatMap(cmd -> execute(decider, streamId, cmd), commands) | |
| pub def getState( | |
| decider: Decider[BookCommand, BookEvent, BookState], | |
| streamId: String | |
| ): BookState \ BookEventStoreEff = | |
| let events = BookEventStoreEff.loadBookEvents(streamId); | |
| Decider.fold(decider, events) | |
| } | |
| // ============================================================================ | |
| // メイン関数 | |
| // ============================================================================ | |
| def main(): Unit \ IO = | |
| println("=== Functional Event Sourcing - Book Lending System ==="); | |
| println(""); | |
| let decider = BookLending.decider(); | |
| // === デモ1: InMemory ストア === | |
| println("--- Demo 1: InMemory Store ---"); | |
| region rc { | |
| let store = Ref.fresh(rc, Map.empty()); | |
| run { | |
| discard EffectfulRunner.execute(decider, "book-001", BookCommand.Register); | |
| discard EffectfulRunner.execute(decider, "book-001", BookCommand.CheckOut("alice")); | |
| discard EffectfulRunner.execute(decider, "book-001", BookCommand.Return); | |
| discard EffectfulRunner.execute(decider, "book-001", BookCommand.CheckOut("bob")); | |
| discard EffectfulRunner.execute(decider, "book-002", BookCommand.CheckOut("charlie")); | |
| let book1State = EffectfulRunner.getState(decider, "book-001"); | |
| let book2State = EffectfulRunner.getState(decider, "book-002"); | |
| println("book-001 state: ${book1State}"); | |
| println("book-002 state: ${book2State}"); | |
| match BookLending.getBorrower(book1State) { | |
| case Some(borrower) => println("Currently borrowed by: ${borrower}") | |
| case None => println("Not currently borrowed") | |
| } | |
| } | |
| // [INFO ] [DECIDE] book-001: Register -> Registered :: Nil | |
| // [INFO ] [DECIDE] book-001: CheckOut(alice) -> CheckedOut(alice) :: Nil | |
| // [INFO ] [DECIDE] book-001: Return -> Returned :: Nil | |
| // [INFO ] [DECIDE] book-001: CheckOut(bob) -> CheckedOut(bob) :: Nil | |
| // [WARN ] [REJECT] book-002: CheckOut(charlie) - No events generated | |
| // book-001 state: CheckedOutBook(bob) | |
| // book-002 state: UnregisteredBook | |
| // Currently borrowed by: bob | |
| with handler BookEventStoreEff { | |
| def loadBookEvents(streamId, k) = k(EventStore.load(Ref.get(store), streamId)) | |
| def appendBookEvents(streamId, events, k) = { | |
| Ref.put(EventStore.append(Ref.get(store), streamId, events), store); | |
| k() | |
| } | |
| } | |
| with handler BookDecisionEff { | |
| def onBookDecide(streamId, command, events, k) = { | |
| Logger.info("[DECIDE] ${streamId}: ${command} -> ${events}"); | |
| k() | |
| } | |
| def onBookReject(streamId, command, reason, k) = { | |
| Logger.warn("[REJECT] ${streamId}: ${command} - ${reason}"); | |
| k() | |
| } | |
| } | |
| with Logger.runWithIO | |
| }; | |
| println(""); | |
| // === デモ2: File ストア === | |
| println("--- Demo 2: File Store ---"); | |
| let baseDir = "events"; | |
| run { | |
| discard EffectfulRunner.execute(decider, "book-101", BookCommand.Register); | |
| discard EffectfulRunner.execute(decider, "book-101", BookCommand.CheckOut("dave")); | |
| let bookState = EffectfulRunner.getState(decider, "book-101"); | |
| println("book-101 state after checkout: ${bookState}") | |
| } | |
| // [INFO ] [DECIDE] book-101: Register -> Registered :: Nil | |
| // [INFO ] [DECIDE] book-101: CheckOut(dave) -> CheckedOut(dave) :: Nil | |
| // book-101 state after checkout: CheckedOutBook(dave) | |
| with handler BookEventStoreEff { | |
| def loadBookEvents(streamId, k) = k(FileStore.loadEvents("${baseDir}/${streamId}.txt")) | |
| def appendBookEvents(streamId, events, k) = { | |
| FileStore.appendEvents("${baseDir}/${streamId}.txt", events); | |
| k() | |
| } | |
| } | |
| with handler BookDecisionEff { | |
| def onBookDecide(streamId, command, events, k) = { | |
| Logger.info("[DECIDE] ${streamId}: ${command} -> ${events}"); | |
| k() | |
| } | |
| def onBookReject(streamId, command, reason, k) = { | |
| Logger.warn("[REJECT] ${streamId}: ${command} - ${reason}"); | |
| k() | |
| } | |
| } | |
| with Logger.runWithIO; | |
| println(""); | |
| // ファイルから復元(再起動シミュレーション) | |
| println("--- Reload from File (Simulating Restart) ---"); | |
| run { | |
| discard EffectfulRunner.execute(decider, "book-101", BookCommand.Return); | |
| let bookState = EffectfulRunner.getState(decider, "book-101"); | |
| println("book-101 state after restart + return: ${bookState}") | |
| } | |
| // [INFO ] [DECIDE] book-101: Return -> Returned :: Nil | |
| // book-101 state after restart + return: AvailableBook | |
| with handler BookEventStoreEff { | |
| def loadBookEvents(streamId, k) = k(FileStore.loadEvents("${baseDir}/${streamId}.txt")) | |
| def appendBookEvents(streamId, events, k) = { | |
| FileStore.appendEvents("${baseDir}/${streamId}.txt", events); | |
| k() | |
| } | |
| } | |
| with handler BookDecisionEff { | |
| def onBookDecide(streamId, command, events, k) = { | |
| Logger.info("[DECIDE] ${streamId}: ${command} -> ${events}"); | |
| k() | |
| } | |
| def onBookReject(streamId, command, reason, k) = { | |
| Logger.warn("[REJECT] ${streamId}: ${command} - ${reason}"); | |
| k() | |
| } | |
| } | |
| with Logger.runWithIO | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment