Skip to content

Commit f5a864a

Browse files
Update README.md
1 parent a2f7326 commit f5a864a

File tree

1 file changed

+88
-7
lines changed

1 file changed

+88
-7
lines changed

README.md

Lines changed: 88 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,14 @@ public class JobSchedulerGrain(
4545

4646
## State Machines
4747

48-
* **Stack**
49-
* **Priority Queue**
50-
* **List Lookup**
51-
* **Set Lookup**
52-
* **Tree**
53-
* **Graph**
54-
* **Cancellation Token Source**
48+
* [Stack](#idurablestackt)
49+
* [Priority Queue](#idurablepriorityqueuetelement-tpriority)
50+
* [List Lookup](#idurablelistlookuptkey-tvalue)
51+
* [Set Lookup](#idurablesetlookuptkey-tvalue)
52+
* [Tree](#idurabletreet)
53+
* [Graph](#idurablegraphtnode-tedge)
54+
* [Cancellation Token Source](#idurablecancellationtokensource)
55+
* [Object](#idurableobjectt)
5556

5657
---
5758

@@ -243,6 +244,86 @@ public class LongRunningTaskGrain(
243244
}
244245
```
245246

247+
---
248+
### `IDurableObject<T>`
249+
250+
A streamlined container for a single complex object (POCO) that allows for direct mutation of its properties. The idea behind `IDurableObject<T>` came from the desire to create the "best of both worlds": an API with the simplicity of `IDurableValue<T>`, but with the ability to mutate a complex object directly like `IPersistentState<T>`.
251+
252+
**Useful for:** Managing any complex state class, like a `UserProfile`, `GameSession`, or `OrderDetails`, without the ceremony of creating new instances for every change.
253+
254+
```csharp
255+
// Direct mutation is the intended pattern.
256+
257+
state.Value.Counter++;
258+
state.Value.LastUpdated = DateTime.UtcNow;
259+
state.Value.Nested.Name = "New Nested Name";
260+
261+
await WriteStateAsync();
262+
263+
```
264+
265+
#### Why not use `IDurableValue<T>` for complex objects?
266+
267+
While possible, it's cumbersome and dare I say even error-prone. You must create a new object instance for every change, which can lead to verbose code and bugs if you forget to copy a property.
268+
269+
```csharp
270+
// Assume you have an IDurableValue<MyState> named 'state'.
271+
272+
// ------------- Incorrect approach -------------
273+
274+
// We intuitively mutate the object directly.
275+
// This changes the in-memory object, but the state machine
276+
// doesn't know a change occurred.
277+
278+
// The setter for 'state.Value' was never called,
279+
// so this write does nothing.
280+
281+
state.Value.Counter++;
282+
await WriteStateAsync();
283+
284+
// After a re-activation, the counter change above will be lost.
285+
286+
// ------------- Correct approach -------------
287+
288+
var newState = new MyState
289+
{
290+
Counter = state.Value.Counter + 1,
291+
Name = state.Value.Name
292+
};
293+
294+
// We need to assign the new instance to trigger the setter.
295+
296+
state.Value = newState;
297+
await WriteStateAsync();
298+
299+
// Now the change is persisted correctly.
300+
```
301+
302+
#### Why not just use `IPersistentState<T>`?
303+
304+
`DurableState<T>` (*the implementation of `IPersistentState<T>`*) exists primarily for **familiarity and migration** from the classic Orleans state model. It works, but it **has to** bring along some jargon and potential confusion:
305+
306+
- **Extra API Surface:** It exposes the full `IStorage<T>` contract, including `Etag`, `ClearStateAsync()`, etc., which are not needed when using `DurableGrain`.
307+
308+
- **Confusing Write Calls:** You can call `state.State.WriteStateAsync()`, which feels disconnected from the `DurableGrain`'s own `WriteStateAsync()` method.
309+
310+
`IDurableObject<T>` is the purpose-built, ergonomic choice for this library. It provides only the essential features (`Value`, `RecordExists`) in a cleaner package, making your grain code simpler and more predictable.
311+
312+
#### Why Can't `Value` be set to `null`?
313+
314+
This is a deliberate design choice for safety and predictability. The `get` accessor for `Value` guarantees it will never return `null` (*it creates a new instance if one doesn't exist*). Allowing the setter to accept `null` would create a confusing and inconsistent state.
315+
316+
```csharp
317+
// This is NOT allowed and will throw an ArgumentNullException:
318+
state.Value = null;
319+
```
320+
321+
By disallowing `null`, `IDurableObject<T>` ensures that you can always safely access and mutate the state object without needing to perform null checks, preventing a common source of `NullReferenceException`s.
322+
323+
#### Why no `Etag`?
324+
325+
An `Etag` is a token used to detect race conditions where multiple processes might update the same record. This component doesn't need one because Orleans ensures only a single grain activation is the "writer," and all changes are appended to an immutable log using a custom binary protocol. Any process attempting to write to the log outside the state machine would corrupt the state, making external updates inherently not feasible, and even unsafe.
326+
246327
---
247328

248329
If you find it helpful, please consider giving it a ⭐ and share it!

0 commit comments

Comments
 (0)