Skip to content

Commit

Permalink
Better singletons (#172)
Browse files Browse the repository at this point in the history
* Move singleton status to the state class

* A little bit of docs

* Fix styling

* Fix styling
  • Loading branch information
inxilpro authored Jan 8, 2025
1 parent eae83d5 commit 64fbd09
Show file tree
Hide file tree
Showing 24 changed files with 196 additions and 148 deletions.
49 changes: 23 additions & 26 deletions docs/attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@ class YourEvent extends Event
}
```

The `StateId` attribute takes a `state_type`, an optional [`alias`](https://verbs.thunk.dev/docs/reference/states#content-aliasstring-alias-state-state) string, and by default can [automatically generate](/docs/technical/ids#content-automatically-generating-ids)(`autofill`) a `snowflake_id` for you.
The `StateId` attribute takes a `state_type`, an optional [
`alias`](https://verbs.thunk.dev/docs/reference/states#content-aliasstring-alias-state-state) string, and by default
can [automatically generate](/docs/technical/ids#content-automatically-generating-ids)(`autofill`) a `snowflake_id` for
you.

### `#[AppliesToState]`

Another way to link states and events; like [`StateId`](#content-stateid), but using the attributes above the class instead of on each individual id.
Another way to link states and events; like [`StateId`](#content-stateid), but using the attributes above the class
instead of on each individual id.

```php
#[AppliesToState(GameState::class)]
Expand All @@ -34,7 +38,8 @@ class RolledDice extends Event
}
```

`AppliesToState` has the same params as `StateId`, with an additional optional `id` param (after `state_type`) if you want to specify which prop belongs to which state.
`AppliesToState` has the same params as `StateId`, with an additional optional `id` param (after `state_type`) if you
want to specify which prop belongs to which state.

```php
#[AppliesToState(state_type: GameState::class, id: foo_id)]
Expand All @@ -51,32 +56,17 @@ class RolledDice extends Event
}
```

Otherwise, with `AppliesToState`, Verbs will find the `id` for you based on your State's prefix (i.e. `ExampleState` would be `example`, meaning `example_id` or `example_ids` would be associated automatically).

### `#[AppliesToSingletonState]`

Use the `AppliesToSingletonState` attribute on an event class to tell Verbs that it should always be applied to a single state (e.g. `CountState`) across the entire application (as opposed to having different counts for different states).

Because we're using a [singleton state](/docs/reference/states#content-singleton-states), there is no need for the event to have a `$count_id`.

```php
#[AppliesToSingletonState(CountState::class)]
class IncrementCount extends Event
{
public function apply(CountState $state)
{
$state->count++;
}
}
```
Otherwise, with `AppliesToState`, Verbs will find the `id` for you based on your State's prefix (i.e. `ExampleState`
would be `example`, meaning `example_id` or `example_ids` would be associated automatically).

In addition to your `state_type` param, you may also set an optional `alias` string.

### `#[AppliesToChildState]`

Use the `AppliesToChildState` attribute on an event class to allow Verbs to access a nested state.

For our example, let's make sure our `ParentState` has a `child_id` property pointing to a `ChildState` by firing a `ChildAddedToParent` event:
For our example, let's make sure our `ParentState` has a `child_id` property pointing to a `ChildState` by firing a
`ChildAddedToParent` event:

```php
ChildAddedToParent::fire(parent_id: 1, child_id: 2);
Expand All @@ -103,14 +93,16 @@ class ParentState extends State
public int $child_id;
}
```

```php
class ChildState extends State
{
public int $count = 0;
}
```

Now that `ParentState` has a record of our `ChildState`, we can load the child *through* the parent with `AppliesToChildState`.
Now that `ParentState` has a record of our `ChildState`, we can load the child *through* the parent with
`AppliesToChildState`.

Let's show this by firing a `NestedStateAccessed` event with our new attribute:

Expand All @@ -134,9 +126,12 @@ class NestedStateAccessed extends Event
}
}
```
`AppliesToChildState` takes a `state_type` (your child state), `parent_type`, `id` (your child state id), and an optional `alias` string.

When you use `AppliesToChildState`, don't forget to also use `StateId` or [`AppliesToState`](/docs/technical/attributes#content-appliestostate) to identify the `parent_id`.
`AppliesToChildState` takes a `state_type` (your child state), `parent_type`, `id` (your child state id), and an
optional `alias` string.

When you use `AppliesToChildState`, don't forget to also use `StateId` or [
`AppliesToState`](/docs/technical/attributes#content-appliestostate) to identify the `parent_id`.

<!-- @!todo we can maybe not feature this one? Need to remember what it does -->
<!-- ### `#[Listen]`
Expand Down Expand Up @@ -167,4 +162,6 @@ class YourEvent extends Event
}
}
```
(You may also use `Verbs::unlessReplaying`, mentioned in [one-time effects](/docs/reference/events/#content-one-time-effects))

(You may also use `Verbs::unlessReplaying`, mentioned
in [one-time effects](/docs/reference/events/#content-one-time-effects))
23 changes: 16 additions & 7 deletions docs/state-hydration-snapshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,25 +28,34 @@

In Verbs, we use state snapshots to conveniently hydrate states in memory.

When you `load()` a State (or retrieve a `singleton()` state)
When you `load()` a State

- If a snapshot exists, the state is hydrated by loading and deserializing the snapshot data.
- If not, the system reconstructs the state by applying the relevant events stored in the event store.
- Once hydrated, the state object is kept in memory within the application. Subsequent access to this state does not require fetching the snapshot from the database again unless the state is deleted from memory or the application restarts.
- Once hydrated, the state object is kept in memory within the application. Subsequent access to this state does not
require fetching the snapshot from the database again unless the state is deleted from memory or the application
restarts.

## Dehydration

When `Verbs::commit()` is called, the event queue is processed and all affected state values are serialized and written to the `VerbSnapshot` table in the database.
When `Verbs::commit()` is called, the event queue is processed and all affected state values are serialized and written
to the `VerbSnapshot` table in the database.

## Serialization

Verbs serializes and deserializes data in order to easily store and retrieve it.

<!-- verbatim from config -->
Verbs uses the [Symfony Serializer component](https://symfony.com/components/Serializer) to serialize your PHP Event objects to JSON.
Verbs uses the [Symfony Serializer component](https://symfony.com/components/Serializer) to serialize your PHP Event
objects to JSON.

The default normalizers should handle most stock Laravel applications, but you may need to add your own normalizers for certain object types, which you can do in `config/verbs.php`.
The default normalizers should handle most stock Laravel applications, but you may need to add your own normalizers for
certain object types, which you can do in `config/verbs.php`.
<!-- // -->

You can also use our interface `SerializedByVerbs` in tandem with trait `NormalizeToPropertiesAndClassName` on classes to support custom types.
You can also use our interface `SerializedByVerbs` in tandem with trait `NormalizeToPropertiesAndClassName` on classes
to support custom types.

You can see good implentation of this in one of our [examples](https://github.com/hirethunk/verbs/blob/main/examples/Monopoly/src/Game/Spaces/Space.php), `examples/Monopoly/src/Game/Spaces/Space.php`
You can see good implentation of this in one of
our [examples](https://github.com/hirethunk/verbs/blob/main/examples/Monopoly/src/Game/Spaces/Space.php),
`examples/Monopoly/src/Game/Spaces/Space.php`
24 changes: 7 additions & 17 deletions docs/states.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,6 @@ in [event lifecycle](/docs/technical/event-lifecycle).

## Loading a State

All state instances are singletons, scoped to an [id](/docs/technical/ids). i.e. say we had a Card Game app--if we apply
a `CardDiscarded` event, we make sure only the `CardState` state with its globablly unique `card_id` is affected.

To retrieve the State, simply call load:

```php
Expand Down Expand Up @@ -148,29 +145,22 @@ Route::get('/users/{user_state}', function(UserState $user_state) {

## Singleton States

You may want a state that only needs one iteration across the entire application--this is called a singleton state.
Singleton states require no id, since there is no need to differentiate among state instances.
You may want a state that only needs one iteration across the entire applicationthis is called a singleton state.
Singleton states require no ID because there is only ever one copy in existence across your entire app.

In our events that apply to a singleton state, we simply need to use the
`AppliesToSingletonState` [attribute](/docs/technical/attributes#content-appliestosingletonstate).
To tell Verbs to treat a State as a singleton, extend the `SingletonState` class, rather than `State`.

```php
#[AppliesToSingletonState(CountState::class)]
class IncrementCount extends Event
class CountState extends State implements SingletonState
{
public function apply(CountState $state)
{
$state->count++;
}
// ...
}
```

This event uses `AppliesToSingletonState` to tell Verbs that it should always be applied to a single `CountState` across
the entire application (as opposed to having different counts for different situations).

### Loading the singleton state

Since singleton's require no IDs, simply call the `singleton()` method.
Since singletons require no IDs, simply call the `singleton()` method. Trying to load a singleton state in any
other way will result in a `BadMethodCall` exception.

```php
YourState::singleton();
Expand Down
42 changes: 23 additions & 19 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ We enjoy improving Verbs by providing easy, readable testing affordances.

When testing verbs events, you'll need to call [commit](/docs/reference/events#content-committing) manually.

You may continue manually adding `Verbs::commit()` after each `Event::fire()` method; however, we've created `Verbs::commitImmediately` to issue a blanket commit on all events you fire in tests.
You may continue manually adding `Verbs::commit()` after each `Event::fire()` method; however, we've created
`Verbs::commitImmediately` to issue a blanket commit on all events you fire in tests.

```php
beforeEach(function () {
Expand All @@ -19,7 +20,8 @@ You may also implement the `CommitsImmediately` interface directly on an Event.

The following Test `assert()` methods are available to thoroughly check your committing granularly.

Before using these methods, add `Verbs::fake()` to your test so Verbs can set up a fake event store to isolate the testing environment.
Before using these methods, add `Verbs::fake()` to your test so Verbs can set up a fake event store to isolate the
testing environment.

```php
Verbs::assertNothingCommitted();
Expand All @@ -29,9 +31,11 @@ Verbs::assertNotCommitted(...);

## State Factories

In tests, you may find yourself needing to fire and commit several events in order to bring your State to the point where it actually needs testing.
In tests, you may find yourself needing to fire and commit several events in order to bring your State to the point
where it actually needs testing.

The `State::factory()` method allows you to bypass manually building up the State, functioning similarly to `Model::factory()`.
The `State::factory()` method allows you to bypass manually building up the State, functioning similarly to
`Model::factory()`.

This allows you to call:

Expand All @@ -58,15 +62,20 @@ Or, in the case of a [singleton state](/docs/reference/states#content-singleton-
ChurnState::factory()->create(['churn' => 40]);
```

Next, we'll get into how these factories work, and continue after with some [Verbs factory methods](testing#content-factory-methods) you may already be familiar with from Eloquent factories.
Next, we'll get into how these factories work, and continue after with
some [Verbs factory methods](testing#content-factory-methods) you may already be familiar with from Eloquent factories.

### `VerbsStateInitialized`

Under the hood, these methods will fire (and immediately commit) a new `VerbsStateInitialized` event, which will fire onto the given state, identified by the id argument (if id is null, we assume it is a singleton) and return a copy of that state.
Under the hood, these methods will fire (and immediately commit) a new `VerbsStateInitialized` event, which will fire
onto the given state, identified by the id argument (if id is null, we assume it is a singleton) and return a copy of
that state.

This is primarily designed for booting up states for testing. If you are migrating non-event-sourced codebases to Verbs, when there is a need to initiate a state for legacy data, it's better to create a custom `MigratedFromLegacy` event.
This is primarily designed for booting up states for testing. If you are migrating non-event-sourced codebases to Verbs,
when there is a need to initiate a state for legacy data, it's better to create a custom `MigratedFromLegacy` event.

You may also change the initial event fired from the StateFactory from `VerbsStateInitialized` to an event class of your choosing by setting an `$intial_event` property on your State Factory.
You may also change the initial event fired from the StateFactory from `VerbsStateInitialized` to an event class of your
choosing by setting an `$intial_event` property on your State Factory.

```php
class ExampleStateFactory extends StateFactory
Expand All @@ -75,11 +84,13 @@ class ExampleStateFactory extends StateFactory
}
```

`VerbsStateInitialized` implements the `CommitsImmediately` interface detailed [above](testing#content-verbscommit), so if you change from this initial event makes sure to extend the interface on your replacement event.
`VerbsStateInitialized` implements the `CommitsImmediately` interface detailed [above](testing#content-verbscommit), so
if you change from this initial event makes sure to extend the interface on your replacement event.

### Factory Methods

Some methods accept Verbs [IDs](/docs/technical/ids), which, written longform, could be any of these types: `Bits|UuidInterface|AbstractUid|int|string`.
Some methods accept Verbs [IDs](/docs/technical/ids), which, written longform, could be any of these types:
`Bits|UuidInterface|AbstractUid|int|string`.

For brevity, this will be abbreviated in the following applicable methods as `Id`.

Expand All @@ -99,14 +110,6 @@ Set the state ID explicitly (cannot be used with `count`).
UserState::factory()->id(123)->create();
```

#### `singleton()`

Mark that this is a singleton state (cannot be used with `count`).

```php
UserState::factory()->singleton()->create();
```

#### `state(callable|array $data)`

Default data (will be overridden by `create`).
Expand Down Expand Up @@ -168,7 +171,8 @@ If you'd like to chain behavior after your Factory `create()` executes, do so in

#### `configure()`

The configure method in your custom factory allows you to set `afterMaking` and `afterCreating` effects (see [laravel docs](https://laravel.com/docs/11.x/eloquent-factories#factory-callbacks)).
The configure method in your custom factory allows you to set `afterMaking` and `afterCreating` effects (
see [laravel docs](https://laravel.com/docs/11.x/eloquent-factories#factory-callbacks)).

##### `afterMaking()` & `afterCreating()`

Expand Down
8 changes: 4 additions & 4 deletions examples/Counter/src/Events/IncrementCount.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@

namespace Thunk\Verbs\Examples\Counter\Events;

use Thunk\Verbs\Attributes\Autodiscovery\AppliesToSingletonState;
use Thunk\Verbs\Attributes\Autodiscovery\AppliesToState;
use Thunk\Verbs\Event;
use Thunk\Verbs\Examples\Counter\States\CountState;

/**
* In our most basic example of event sourcing, we just use a single event
* to increment a counter. This event uses `AppliesToSingletonState` to tell
* Verbs that it should always be applied to a single `CountState` across the
* to increment a counter. Because CountState is a SingletonState object,
* Verbs will always apply this event to a single `CountState` across the
* entire application (as opposed to having different counts for different
* situations).
*
* Because we're using a singleton state, there is no need for the event to
* have a `$count_id`.
*/
#[AppliesToSingletonState(CountState::class)]
#[AppliesToState(CountState::class)]
class IncrementCount extends Event
{
public function apply(CountState $state)
Expand Down
4 changes: 2 additions & 2 deletions examples/Counter/src/Events/IncrementCountTwice.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

namespace Thunk\Verbs\Examples\Counter\Events;

use Thunk\Verbs\Attributes\Autodiscovery\AppliesToSingletonState;
use Thunk\Verbs\Attributes\Autodiscovery\AppliesToState;
use Thunk\Verbs\Event;
use Thunk\Verbs\Examples\Counter\States\CountState;

#[AppliesToSingletonState(CountState::class)]
#[AppliesToState(CountState::class)]
class IncrementCountTwice extends Event
{
public function handle()
Expand Down
4 changes: 2 additions & 2 deletions examples/Counter/src/States/CountState.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

namespace Thunk\Verbs\Examples\Counter\States;

use Thunk\Verbs\State;
use Thunk\Verbs\SingletonState;

class CountState extends State
class CountState extends SingletonState
{
public int $count = 0;
}
2 changes: 1 addition & 1 deletion examples/Counter/tests/InitializeStateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use Thunk\Verbs\Models\VerbEvent;

it('State factory initializes a state', function () {
$count_state = CountState::factory()->singleton()->create([
$count_state = CountState::factory()->create([
'count' => 1337,
]);

Expand Down
4 changes: 2 additions & 2 deletions examples/Subscriptions/src/Events/GlobalReportGenerated.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

namespace Thunk\Verbs\Examples\Subscriptions\Events;

use Thunk\Verbs\Attributes\Autodiscovery\AppliesToSingletonState;
use Thunk\Verbs\Attributes\Autodiscovery\AppliesToState;
use Thunk\Verbs\Attributes\Hooks\Once;
use Thunk\Verbs\Event;
use Thunk\Verbs\Examples\Subscriptions\Models\Report;
use Thunk\Verbs\Examples\Subscriptions\States\GlobalReportState;

#[AppliesToSingletonState(GlobalReportState::class)]
#[AppliesToState(GlobalReportState::class)]
class GlobalReportGenerated extends Event
{
#[Once]
Expand Down
3 changes: 1 addition & 2 deletions examples/Subscriptions/src/Events/SubscriptionCancelled.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
namespace Thunk\Verbs\Examples\Subscriptions\Events;

use Thunk\Verbs\Attributes\Autodiscovery\AppliesToChildState;
use Thunk\Verbs\Attributes\Autodiscovery\AppliesToSingletonState;
use Thunk\Verbs\Attributes\Autodiscovery\AppliesToState;
use Thunk\Verbs\Event;
use Thunk\Verbs\Examples\Subscriptions\Models\Subscription;
Expand All @@ -13,7 +12,7 @@

#[AppliesToState(state_type: SubscriptionState::class, id: 'subscription_id', alias: 'subscription')]
#[AppliesToChildState(state_type: PlanReportState::class, parent_type: SubscriptionState::class, id: 'plan_id', alias: 'plan')]
#[AppliesToSingletonState(state_type: GlobalReportState::class, alias: 'report')]
#[AppliesToState(state_type: GlobalReportState::class, alias: 'report')]
class SubscriptionCancelled extends Event
{
public int $subscription_id;
Expand Down
Loading

0 comments on commit 64fbd09

Please sign in to comment.