Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

- Use the @KomaStoreDsl annotation for builder APIs
- Follow the state{} and action{} block pattern
- Handle errors in dedicated error{} blocks
- Handle recoverable exceptions in dedicated recover{} blocks

### Error Handling

- Use store.error{} for business logic errors
- Use store.recover{} for business logic exceptions
- Use store.exceptionHandler() for system errors
- Prefer modeling errors as state transitions

Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ The architecture is inspired by [Flux](https://facebookarchive.github.io/flux/)
## When Koma Fits Best

Koma works especially well when a feature has multiple explicit UI or business states and the transition rules between them are important.
By combining Kotlin `sealed class`/`sealed interface` with Koma's state machine DSL, you can keep each state's `enter{}`, `action{}`, `exit{}`, and `error{}` behavior close together and make the transition rules easy to follow.
By combining Kotlin `sealed class`/`sealed interface` with Koma's state machine DSL, you can keep each state's `enter{}`, `action{}`, `exit{}`, and `recover{}` behavior close together and make the transition rules easy to follow.

## Current Scope

Expand Down Expand Up @@ -382,7 +382,7 @@ val store: Store<CounterState, CounterAction, CounterEvent> = Store {
}
```

This works, but you can also handle exceptions with the `error{}` block.
This works, but you can also handle exceptions with the `recover{}` block.

```kt
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
Expand All @@ -397,13 +397,13 @@ val store: Store<CounterState, CounterAction, CounterEvent> = Store {
}

// more specific exceptions should be placed first
error<IllegalStateException> {
recover<IllegalStateException> {
// ...
nextState { CounterState.Error(error = error) }
}

// more general exception handlers should come last
error<Exception> {
recover<Exception> {
// ...
nextState { CounterState.Error(error = error) }
}
Expand All @@ -412,7 +412,7 @@ val store: Store<CounterState, CounterAction, CounterEvent> = Store {
```

Exceptions can be caught not only in the `enter{}` block but also in the `action{}` and `exit{}` blocks.
In other words, your business logic exceptions can be handled in the `error{}` block.
In other words, your business logic exceptions can be handled in the `recover{}` block.

On the other hand, fatal errors and other uncaught non-`Exception` throwables in the entire Store can be handled with the `exceptionHandler()` specification:

Expand Down Expand Up @@ -541,7 +541,7 @@ Then, processing of all Coroutines will stop.

#### Specifying CoroutineDispatchers

You can specify the execution thread (CoroutineDispatchers) in `enter{}`, `exit{}`, `action{}`, `error{}`, and `launch{}` blocks, allowing you to locally control which thread each specific operation runs on.
You can specify the execution thread (CoroutineDispatchers) in `enter{}`, `exit{}`, `action{}`, `recover{}`, and `launch{}` blocks, allowing you to locally control which thread each specific operation runs on.
If you omit the dispatcher parameter, Koma keeps using the Store's current execution context for that operation.

```kt
Expand Down Expand Up @@ -605,7 +605,7 @@ val store: Store<CounterState, CounterAction, CounterEvent> = Store {
}
```

Regardless of the configured `PendingActionPolicy`, you can still discard already queued actions at a specific point by calling `clearPendingActions()` inside `enter{}`, `action{}`, `exit{}`, `error{}`, or inside `transaction{}` from a launched coroutine.
Regardless of the configured `PendingActionPolicy`, you can still discard already queued actions at a specific point by calling `clearPendingActions()` inside `enter{}`, `action{}`, `exit{}`, `recover{}`, or inside `transaction{}` from a launched coroutine.

### Using Control Flow in `Store{}`

Expand Down
4 changes: 2 additions & 2 deletions doc/internal/adr/2026-04-26-non-launch-cancellation.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@

`#190` では、`action { launch { ... } }` で開始した仕事を lane 単位で明示的に止める `cancelLaunch(lane)` を検討している。

一方で、`launch` を使わない通常の `action {}`、`enter {}`、`exit {}`、`error {}`、および `transaction {}` には、いま実行中の処理を途中で止める API はない。
一方で、`launch` を使わない通常の `action {}`、`enter {}`、`exit {}`、`recover {}`、および `transaction {}` には、いま実行中の処理を途中で止める API はない。

ここで判断したいのは、`#190` のような cancellation を非 `launch` の store work にも広げるべきかどうかである。

## 決定

非 `launch` の store work には、in-flight cancellation API を追加しない。

- 通常の `action {}`、`enter {}`、`exit {}`、`error {}`、`transaction {}` は、短く終わる直列・原子的な store work として扱う。
- 通常の `action {}`、`enter {}`、`exit {}`、`recover {}`、`transaction {}` は、短く終わる直列・原子的な store work として扱う。
- cancellation が必要な非同期処理や長く生きる仕事は、`launch {}` に出して扱う。
- `clearPendingActions()` は引き続き「後ろに積まれた pending action を捨てる API」として扱い、現在実行中の store work は止めない。

Expand Down
8 changes: 4 additions & 4 deletions doc/internal/adr/2026-04-27-clear-pending-actions-scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

`clearPendingActions()` は、現在実行中の store work 自体を止める API ではなく、その後ろに待機している dispatch を捨てる API である。

一方で、公開面としては `enter {}`、`action {}`、`exit {}`、`error {}`、および launched coroutine 内の `transaction {}` から呼べる。
一方で、公開面としては `enter {}`、`action {}`、`exit {}`、`recover {}`、および launched coroutine 内の `transaction {}` から呼べる。
このため、次の 2 点を整理しておきたい。

- `clearPendingActions()` をさらに狭い場所に限定すべきか
Expand All @@ -19,20 +19,20 @@

`clearPendingActions()` は、引き続き store の直列 pipeline 上で実行される scope と、そこへ明示的に戻る `transaction {}` からのみ呼べる API として扱う。

- `enter {}`、`action {}`、`exit {}`、`error {}` では公開を維持する
- `enter {}`、`action {}`、`exit {}`、`recover {}` では公開を維持する
- launched coroutine 内では `transaction {}` からのみ呼べるようにし、`launch {}` 本体には公開しない
- middleware やその他の非 store-work 文脈には広げない

また、利用上の重心は `action {}` と launched coroutine 内の `transaction {}` に置く。
`enter {}`、`exit {}`、`error {}` での利用は escape hatch として許容するが、常用の中心には置かない。
`enter {}`、`exit {}`、`recover {}` での利用は escape hatch として許容するが、常用の中心には置かない。

## 補足

- `clearPendingActions()` が意味を持つのは、「いま何が current store work で、その後ろに何が pending か」が直列 pipeline 上で定まっているときである。
- `launch {}` 本体は state に所有される非同期処理であり、store の直列 pipeline そのものではない。ここで queue を直接掃除できるようにすると、遅延や I/O の後の任意の時点で pending action を破棄できてしまい、挙動を追いにくくなる。
- `launch {}` 本体で必要なのは queue 制御よりも、state-owned job の寿命制御である。そちらは `cancelLaunch(lane)` のような action-launch cancellation で扱う方が役割分離として自然である。
- launched coroutine から `transaction {}` に入った時点では、処理は再び store の直列 pipeline に戻る。そのため、その瞬間に「この結果を採用するなら、古い pending action は不要」と判断して `clearPendingActions()` を呼ぶのは意味が通る。
- `enter {}`、`exit {}`、`error {}` も技術的には store work であり、そこで pending action を捨てる意味はある。したがって公開面から完全に外す必要まではない。
- `enter {}`、`exit {}`、`recover {}` も技術的には store work であり、そこで pending action を捨てる意味はある。したがって公開面から完全に外す必要まではない。
- ただし、可読性の観点では `action {}` と `transaction {}` の方が「何を確定させた結果として queue を切るのか」を読み取りやすい。README や KDoc では、この利用の重心を明示した方がよい。

## 関連
Expand Down
30 changes: 15 additions & 15 deletions doc/internal/adr/2026-05-01-error-dsl-exception-boundary.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
# `error {}` DSL は `Exception` の回復経路に限定する
# `recover {}` DSL は `Exception` の回復経路に限定する

- 更新日: 2026-05-01

## 背景

Koma の `error {}` DSL は、state machine の中で発生した失敗を state 遷移として扱うための入口である。
Koma の `recover {}` DSL(deprecated な `error {}` alias を含む)は、state machine の中で発生した失敗を state 遷移として扱うための入口である。
一方で Kotlin の `Throwable` には、通常の業務例外として回復を試みるべき `Exception` だけでなく、`AssertionError` などの `Error` 系や、独自 `Throwable` のような非標準の失敗も含まれる。

これまでの実装では、fatal として即再送出していたものを除き、広く `Throwable` を `error {}` 側へ流しうる形になっていた。
これまでの実装では、fatal として即再送出していたものを除き、広く `Throwable` を `recover {}` 側へ流しうる形になっていた。
しかしこの形だと、次の境界が曖昧になる。

- `error {}` が回復対象として扱う失敗
- `recover {}` が回復対象として扱う失敗
- `exceptionHandler()` が最後の受け皿として扱う失敗
- coroutine / job として成功完了にしてよい失敗
- job failure として扱うべき失敗
Expand All @@ -19,30 +19,30 @@ Koma の `error {}` DSL は、state machine の中で発生した失敗を state

## 決定

`error {}` DSL は、`Exception` を回復するための経路に限定する。
`recover {}` DSL は、`Exception` を回復するための経路に限定する。

- `error<T>` の `T` は `Exception` のみを受け付ける
- `recover<T>` の `T` は `Exception` のみを受け付ける
- `ErrorScope.error` の型も `Exception` に限定する
- Store 内の recoverable path は `Exception` のみを `error {}` に流す
- Store 内の recoverable path は `Exception` のみを `recover {}` に流す
- `Exception` ではない `Throwable` は recoverable とみなさず、job failure として扱う
- `Exception` ではない `Throwable` は `exceptionHandler()` に流れるが、`error {}` には入れない
- middleware / observer / persistence など framework boundary で発生した `Exception` も、`error {}` の回復対象には入れない
- `Exception` ではない `Throwable` は `exceptionHandler()` に流れるが、`recover {}` には入れない
- middleware / observer / persistence など framework boundary で発生した `Exception` も、`recover {}` の回復対象には入れない

この判断により、意味づけは次のように固定する。

- `error {}` は recovery path
- `recover {}` は recovery path
- `exceptionHandler()` は last-resort path
- `error {}` で処理できた失敗は、Store work としては成功完了でよい
- `error {}` に乗らない失敗は、未回復のまま success 扱いにしない
- `recover {}` で処理できた失敗は、Store work としては成功完了でよい
- `recover {}` に乗らない失敗は、未回復のまま success 扱いにしない

## 補足

- `CancellationException` は `Exception` ではあるが、coroutine cancellation の制御信号でもあるため、通常の recovery 対象には入れない。
- したがって runtime 上は、「`Exception` なら常に recoverable」ではなく、「recoverable exception path へ流してよい `Exception` だけを対象にする」という整理になる。
- recoverable / non-recoverable の境界は型だけでは決まらない。state handler や launched `transaction {}` の中で投げられた `Exception` は recovery path に流すが、middleware hook、observer callback、`stateSaver.save()` など framework boundary で投げられた `Exception` は recovery path に再投入しない。
- そのため実装では、framework boundary で起きた `Exception` を `InternalError` で包み、`error {}` に再突入しないようにしている。`exceptionHandler()` に渡す直前には unwrap して、利用者には元の `Exception` を見せる。
- `action {}` / `enter {}` / `exit {}` / launched `transaction {}` の中では、利用者は Kotlin の言語仕様上 `throw Throwable(...)` を書ける。この点は API 上の違和感になりうるため、README / KDoc では `error {}` が `Exception` 専用の recovery path であることを明示する。
- この判断は source-compatible ではない。既存の `error<Throwable> { ... }` や custom `Throwable` を回復対象にしていたコードは移行が必要になる。
- そのため実装では、framework boundary で起きた `Exception` を `InternalError` で包み、`recover {}` に再突入しないようにしている。`exceptionHandler()` に渡す直前には unwrap して、利用者には元の `Exception` を見せる。
- `action {}` / `enter {}` / `exit {}` / launched `transaction {}` の中では、利用者は Kotlin の言語仕様上 `throw Throwable(...)` を書ける。この点は API 上の違和感になりうるため、README / KDoc では `recover {}` が `Exception` 専用の recovery path であることを明示する。
- この判断は source-compatible ではない。 `error<Throwable> { ... }` や custom `Throwable` を回復対象にしていたコードは移行が必要になる。
- 本メモは、Store の通常 runtime path における error handling 境界を対象とする。起動時の state restore など、`_state` 初期化まわりの個別事情はここでは扱わない。

## 関連
Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,33 @@
# `error {}` に流さない framework boundary の例外処理は当面現状維持とする
# `recover {}` に流さない framework boundary の例外処理は当面現状維持とする

- 更新日: 2026-05-07

## 背景

既存の判断として、`error {}` DSL は Store の通常 runtime path における `Exception` の回復経路に限定している。
そのため、plugin hook、observer callback、`StateSaver.save()` / `restore()` など、framework boundary で起きた例外は `error {}` に再投入しない。
既存の判断として、`recover {}` DSL は Store の通常 runtime path における `Exception` の回復経路に限定している。
そのため、plugin hook、observer callback、`StateSaver.save()` / `restore()` など、framework boundary で起きた例外は `recover {}` に再投入しない。

この整理は、state machine の回復責務と framework 側の失敗を分離するうえでは自然である。
一方で、plugin や saver の失敗の中には、state transition 自体の整合性を必ずしも壊さず、レポートしつつ続行できそうなものもある。

ただし、この種の例外を一律に「続行可能」とみなすのは粗すぎる。
起動前後の `restore()` や `Plugin.onStart()` のように、失敗時に Store の初期化状態へ直接影響するものもあり、同じ扱いにはできない。
また、`error {}` に流さない例外は現在 `exceptionHandler()` 側で受けるため、設定次第では利用者が failure を見落としやすい、という別の論点もある。
また、`recover {}` に流さない例外は現在 `exceptionHandler()` 側で受けるため、設定次第では利用者が failure を見落としやすい、という別の論点もある。

このため、今の時点で runtime 挙動や handler 境界を拡張するかどうかを決めておく必要がある。

## 決定

framework boundary の例外処理は、当面は現状コードを維持する。

- plugin、observer、persistence など `error {}` に流さない例外を、今すぐ一律に「report して続行」へ寄せる変更は採用しない
- plugin、observer、persistence など `recover {}` に流さない例外を、今すぐ一律に「report して続行」へ寄せる変更は採用しない
- `exceptionHandler()` と別に、system-side の例外専用 handler を直ちに追加する変更も採用しない
- 現時点では、「Store DSL 内の recovery path」と「framework boundary の last-resort path」を既存どおり分けたままにする

ただし、将来の拡張候補として次は残す。

- 個々の失敗点ごとに、Store 整合性への影響と observability の要求を見極めたうえで、例外発生後も続行できる箇所だけを限定的に増やす
- `error {}` に流さない system-side の例外について、現行の `exceptionHandler()` とは別の handler を導入し、利用者が business error と framework error を分けて扱えるようにする
- `recover {}` に流さない system-side の例外について、現行の `exceptionHandler()` とは別の handler を導入し、利用者が business error と framework error を分けて扱えるようにする

これらは方向性としては保持するが、具体的な API や runtime policy は、実例となるユースケースや運用上の不足が揃ってから判断する。

Expand All @@ -39,5 +39,5 @@ framework boundary の例外処理は、当面は現状コードを維持する

## 関連

- [`error {}` DSL は `Exception` の回復経路に限定する](./2026-05-01-error-dsl-exception-boundary.md)
- [`recover {}` DSL は `Exception` の回復経路に限定する](./2026-05-01-error-dsl-exception-boundary.md)
- [`Plugin` 設計メモ](../notes/2026-05-02-plugin-design.md)
Loading
Loading