diff --git a/README.md b/README.md index ae049e8..249f06d 100644 --- a/README.md +++ b/README.md @@ -22,17 +22,19 @@ predefined `User`, `Order`, or `Customer` aggregate stream. ## Libraries -This repository contains three Gleam libraries: +This repository contains five Gleam libraries: 1. `factos`: store-independent domain primitives. -2. `factos_sqlight`: SQLite backend implemented with the `sqlight` package. -3. `factos_kurrentdb_erlang`: KurrentDB backend for the Erlang target. +2. `factos_pog`: PostgreSQL backend implemented with the `pog` package. +3. `factos_sqlight`: SQLite backend implemented with the `sqlight` package. +4. `factos_kurrentdb_erlang`: KurrentDB backend for the Erlang target. +5. `factos_cf`: Cloudflare D1 backend for Workers. The core library is intentionally small. It knows about facts, event types, tags, -queries, contexts, deciders, views, recorded events, loaded streams, and append -conditions. It does not know how bytes are encoded, where events are stored, how -subscriptions work, whether projections are synchronous, or which transport is -used. +queries, contexts, deciders, views, reactors, recorded events, loaded streams, +and append conditions. It does not know how bytes are encoded, where events are +stored, how effects are executed, whether projections are synchronous, or which +transport is used. Backend libraries own storage details. They define storage codecs, persistence errors, migrations, and dispatch functions for their storage technology. @@ -210,7 +212,8 @@ The core library provides: 4. `AppendCondition` for context-stability requirements. 5. `Decider` for command-side decisions. 6. `View` for query-side projection folds. -7. `Decoded`, `Recorded`, `Context`, and `LoadedStream` records used by backends. +7. `Reactor` for pure reactions from committed recorded events to application-owned effect values. +8. `Decoded`, `Recorded`, `Context`, and `LoadedStream` records used by backends. ### Pure Command Computation @@ -263,10 +266,52 @@ Views can be merged when they consume the same event type: let dashboard = factos.merge_views(registrations, display_name_changes) ``` -Factos intentionally stops at pure projection computation. Materialized view -storage, catch-up subscriptions, delivery retries, and read-model rebuilds belong +Factos intentionally stops at pure computation. Materialized view storage, +catch-up subscriptions, effect delivery retries, and read-model rebuilds belong to application or backend-specific code. +### Reactor Computation + +`Reactor` is the side-effect-side equivalent of a view. It inspects committed +`Recorded(event)` values and returns application-owned effect values. It does not +execute IO. + +```gleam +pub type Effect { + SendWelcomeEmail(to: String, event_id: String) +} + +let user_reactor = + factos.reactor(fn(recorded) { + case recorded.event { + UserRegistered(username) -> [ + SendWelcomeEmail(to: username, event_id: recorded.id), + ] + UsernameReserved(_) -> [] + DisplayNameChanged(_, _) -> [] + } + }) +``` + +Backend dispatch functions return the committed `Recorded(event)` values for the +append, so applications can react only after the facts were accepted: + +```gleam +let assert Ok(dispatch) = + factos_sqlight.dispatch_stream( + connection, + stream: "user-renata", + decider: registration_decider(), + codec: codec(), + command: RegisterUser("renata"), + ) + +let effects = factos.react_all(user_reactor, dispatch.events) +``` + +Effect execution remains outside `factos`: applications can run effects +immediately, persist them durably, retry them, or ignore them during replay. + ## SQLite Backend: `factos_sqlight` The SQLite backend stores events in an append-only table named `factos_events` and @@ -447,8 +492,8 @@ need a write path that can atomically enforce that context condition. ## Example -The `examples/src/order_workflow.gleam` file contains a restaurant order workflow -using `factos_sqlight`. It demonstrates: +The `examples/orders_sqlight/src/order_workflow.gleam` file contains a restaurant +order workflow using `factos_sqlight`. It demonstrates: 1. domain commands and events, 2. a custom state machine, @@ -457,10 +502,21 @@ using `factos_sqlight`. It demonstrates: 5. application-owned encoding and decoding, and 6. a projection view for kitchen summary data. -Run it from the examples package: +The `examples/tickets_pog/src/tickets_pog.gleam` file contains a concurrent +ticket-sale workflow using `factos_pog`. It demonstrates: + +1. query-based command context consistency, +2. concurrent buyers racing against a shared capacity rule, +3. dispatch returning committed recorded events, and +4. a pure reactor that turns accepted ticket-sale facts into application effects. + +Run each example from its package: ```sh -cd examples +cd examples/orders_sqlight +gleam run + +cd ../tickets_pog gleam run ``` diff --git a/backends/factos_pog/README.md b/backends/factos_pog/README.md index e670c61..da2ace5 100644 --- a/backends/factos_pog/README.md +++ b/backends/factos_pog/README.md @@ -25,13 +25,35 @@ context-stability guarantee. is the intended consistency boundary. It is an implementation strategy, not the definition of Event Sourcing. +Both dispatch functions return `factos_pog.Dispatch(event)`: the append metadata +plus the committed `factos.Recorded(event)` values for that dispatch. Application +code can feed those records into `factos.Reactor` values after the transaction +has accepted the facts. + ## Usage -Start a `pog` pool in your application supervision tree, run `migrate`, build a codec with `factos_pog.codec`, then call `dispatch_with_query` or `dispatch` with your domain decider and command. +Start a `pog` pool in your application supervision tree, run `migrate`, build a +codec with `factos_pog.codec`, then call `dispatch_with_query` or `dispatch` with +your domain decider and command. ```gleam let connection = pog.named_connection(pool_name) let assert Ok(Nil) = factos_pog.migrate(connection) + +let assert Ok(dispatch) = + factos_pog.dispatch_with_query( + connection, + stream: "tickets", + query: sale_query(), + decider: ticket_decider(), + codec: ticket_codec(), + command: BuyTicket("renata"), + ) + +let effects = factos.react_all(ticket_reactor(), dispatch.events) ``` -Your codec owns event serialization. The backend only persists bytes and query metadata. +Your codec owns event serialization. The backend only persists bytes and query +metadata. Your application owns any effects returned by reactors: it may run +them immediately, write them to durable infrastructure, retry them, or ignore +them during replay. diff --git a/docs/domain-driven-design.md b/docs/domain-driven-design.md index ba86c53..64e24bc 100644 --- a/docs/domain-driven-design.md +++ b/docs/domain-driven-design.md @@ -305,11 +305,12 @@ For example: 1. The domain accepts `PayInvoice` and produces `InvoicePaid`. 2. Application code stores that fact. -3. A handler reacts by sending a receipt email. -4. Another handler updates a reporting view. +3. A pure reactor inspects the committed recorded fact and returns effect values. +4. Application or infrastructure code sends a receipt email and updates a reporting view. The receipt email is important, but sending it is not the same as deciding -whether the invoice may be paid. +whether the invoice may be paid. The reactor can describe the needed effect, but +it should not hide IO inside the domain rule. ## Strategic and Tactical DDD @@ -354,6 +355,7 @@ Factos is not required to practice DDD. It is one small set of primitives for applications that want to model domain decisions from accepted facts. With Factos, the application still owns the domain language. The application -defines its commands, events, states, errors, and business rules. Factos provides -supporting types for pure decisions and event-backed consistency, while storage -and side effects remain outside the domain model. +defines its commands, events, states, errors, business rules, and effect values. +Factos provides supporting types for pure decisions, pure projections, pure +reactors, and event-backed consistency, while storage and effect execution remain +outside the domain model. diff --git a/docs/event-sourcing.md b/docs/event-sourcing.md index 654aa1a..6cb5087 100644 --- a/docs/event-sourcing.md +++ b/docs/event-sourcing.md @@ -82,7 +82,8 @@ The typical Factos flow is: 3. Write a `decide` function that returns `Result(List(Event), DomainError)`. 4. Define the command context with event types and tags. 5. Let a backend load matching facts and protect the append with the returned condition. +6. React to the committed recorded facts with pure reactors if application effects are needed. This style keeps Event Sourcing concrete. The important parts are plain Gleam -functions and types, while storage backends handle persistence, codecs, and -append guarantees. +functions and types, while storage backends handle persistence, codecs, append +guarantees, and the committed records that application code may react to.