Perspectives with Actions¶
Verified by tests
IPerspectiveWithActionsForTests, ApplyResultTests, ModelActionTests — library CI run #31657041675 (2026-08-13)
IPerspectiveWithActionsFor extends the standard perspective pattern with support for soft-delete and hard-delete (purge) operations. While IPerspectiveFor always returns an updated model, IPerspectiveWithActionsFor returns an ApplyResult<TModel> that can express deletion semantics alongside normal updates.
Overview¶
Standard perspectives (IPerspectiveFor) assume every event produces a model update. But real-world systems need to remove read models too:
- Soft delete: Mark a row as deleted (set
DeletedAt) while preserving it for audit queries - Hard delete (purge): Remove the row from the database entirely when data retention is not required
IPerspectiveWithActionsFor solves this by changing the Apply return type from TModel to ApplyResult<TModel>, giving the perspective control over the model's lifecycle.
When to use:
- Use IPerspectiveFor when events only create or update models
- Use IPerspectiveWithActionsFor when events may delete or purge models
IPerspectiveWithActionsFor Interface¶
IPerspectiveWithActionsFor Interface
namespace Whizbang.Core.Perspectives;
public interface IPerspectiveWithActionsFor<TModel, TEvent> : IPerspectiveWithActionsFor<TModel>
where TModel : class
where TEvent : IEvent {
ApplyResult<TModel> Apply(TModel currentData, TEvent eventData);
}
Type Parameters:
- TModel: The read model type (must be a reference type)
- TEvent: The event type this perspective handles
Key Difference from IPerspectiveFor: The return type is ApplyResult<TModel> instead of TModel, enabling the perspective to signal deletion operations.
ApplyResult Struct¶
ApplyResult<TModel> is a readonly struct that pairs an optional model with a ModelAction. It provides four static factory methods and three implicit conversions for ergonomic usage.
ApplyResult Factory Methods
namespace Whizbang.Core.Perspectives;
public readonly struct ApplyResult<TModel> where TModel : class {
public TModel? Model { get; }
public ModelAction Action { get; }
// Factory methods
public static ApplyResult<TModel> None(); // No change (skip update)
public static ApplyResult<TModel> Delete(); // Soft delete (row preserved)
public static ApplyResult<TModel> Purge(); // Hard delete (remove row)
public static ApplyResult<TModel> Update(TModel model); // Update model
// Implicit conversions
public static implicit operator ApplyResult<TModel>(TModel model); // Model -> Update
public static implicit operator ApplyResult<TModel>(ModelAction action); // Action -> ApplyResult
public static implicit operator ApplyResult<TModel>((TModel?, ModelAction) tuple); // Tuple -> ApplyResult
}
Return Type Semantics¶
| Factory Method | Model | Action | Runner Behavior |
|---|---|---|---|
Update(model) |
The updated model | None |
Upserts the model |
None() |
null |
None |
Skips update |
Delete() |
null |
Delete |
Soft delete (row preserved; see below) |
Purge() |
null |
Purge |
Hard delete (removes row) |
Implicit Conversions¶
The implicit conversions let you write concise Apply methods without explicitly constructing ApplyResult:
ApplyResult Implicit Conversions
using Whizbang.Core.Perspectives;
public class OrderPerspective : IPerspectiveWithActionsFor<OrderView, OrderUpdated>,
IPerspectiveWithActionsFor<OrderView, OrderCancelled> {
// Implicit conversion from TModel -> ApplyResult with None action
public ApplyResult<OrderView> Apply(OrderView current, OrderUpdated @event) {
return current with { UpdatedAt = @event.UpdatedAt };
}
// Implicit conversion from ModelAction -> ApplyResult with null model
public ApplyResult<OrderView> Apply(OrderView current, OrderCancelled @event) {
return ModelAction.Delete;
}
}
ModelAction Enum¶
ModelAction specifies the lifecycle action for a perspective model after an Apply method executes.
ModelAction Enum
namespace Whizbang.Core.Perspectives;
public enum ModelAction {
None = 0, // Keep the model as-is or use the returned model
Delete = 1, // Soft delete: row remains (set DeletedAt on the model yourself)
Purge = 2 // Hard delete: remove the row entirely
}
Delete vs Purge¶
| Delete (Soft) | Purge (Hard) | |
|---|---|---|
| Row in database | Preserved (set DeletedAt on the model in Apply) |
Removed entirely |
| Audit queries | Queryable via temporal lens | Gone forever |
| Model requirement | Convention: a DateTimeOffset? DeletedAt property your Apply sets |
No requirement |
| Use case | Orders, users, anything needing history | Temporary data, GDPR right-to-erasure |
| Reversible | Yes (replay without the delete event) | Yes (rebuild from event store) |
Mixing IPerspectiveFor and IPerspectiveWithActionsFor¶
A single perspective class can implement both IPerspectiveFor and IPerspectiveWithActionsFor for different event types. This is the recommended pattern: use IPerspectiveFor for events that only update, and IPerspectiveWithActionsFor for events that may delete.
Mixed Perspective
using Whizbang.Core;
using Whizbang.Core.Perspectives;
// Events
public record OrderCreatedEvent : IEvent {
[StreamId]
public Guid OrderId { get; init; }
public string CustomerName { get; init; } = string.Empty;
public decimal Total { get; init; }
public DateTime CreatedAt { get; init; }
}
public record OrderUpdatedEvent : IEvent {
[StreamId]
public Guid OrderId { get; init; }
public decimal? Total { get; init; }
public DateTime UpdatedAt { get; init; }
}
public record OrderCancelledEvent : IEvent {
[StreamId]
public Guid OrderId { get; init; }
public DateTimeOffset CancelledAt { get; init; }
}
public record OrderPurgedEvent : IEvent {
[StreamId]
public Guid OrderId { get; init; }
}
// Read model
public record OrderView {
[StreamId]
public Guid OrderId { get; init; }
public string CustomerName { get; init; } = string.Empty;
public decimal Total { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime? UpdatedAt { get; init; }
public DateTimeOffset? DeletedAt { get; init; }
}
// Perspective: IPerspectiveFor for updates, IPerspectiveWithActionsFor for deletes
public class OrderPerspective :
IPerspectiveFor<OrderView, OrderCreatedEvent>, // Update only
IPerspectiveFor<OrderView, OrderUpdatedEvent>, // Update only
IPerspectiveWithActionsFor<OrderView, OrderCancelledEvent>, // May soft-delete
IPerspectiveWithActionsFor<OrderView, OrderPurgedEvent> { // May hard-delete
// IPerspectiveFor: returns TModel directly
public OrderView Apply(OrderView currentData, OrderCreatedEvent @event) {
return new OrderView {
OrderId = @event.OrderId,
CustomerName = @event.CustomerName,
Total = @event.Total,
CreatedAt = @event.CreatedAt
};
}
public OrderView Apply(OrderView currentData, OrderUpdatedEvent @event) {
return currentData with {
Total = @event.Total ?? currentData.Total,
UpdatedAt = @event.UpdatedAt
};
}
// IPerspectiveWithActionsFor: returns ApplyResult<TModel>
public ApplyResult<OrderView> Apply(OrderView currentData, OrderCancelledEvent @event) {
// Soft delete - mark as deleted, preserve the row
return ApplyResult<OrderView>.Delete();
}
public ApplyResult<OrderView> Apply(OrderView currentData, OrderPurgedEvent @event) {
// Hard delete - remove the row entirely
return ApplyResult<OrderView>.Purge();
}
}
Pattern: Use IPerspectiveFor for create/update events where the model always survives. Switch to IPerspectiveWithActionsFor only for events that might remove the model.
Conditional Actions¶
An Apply method can choose different actions based on event data. This is useful when the same event type might update or delete depending on context.
Conditional Action
using Whizbang.Core;
using Whizbang.Core.Perspectives;
public record OrderArchivedEvent : IEvent {
[StreamId]
public Guid OrderId { get; init; }
public bool ShouldPurge { get; init; }
public DateTimeOffset ArchivedAt { get; init; }
}
public class ArchivePerspective : IPerspectiveWithActionsFor<OrderView, OrderArchivedEvent> {
public ApplyResult<OrderView> Apply(OrderView currentData, OrderArchivedEvent @event) {
if (@event.ShouldPurge) {
// GDPR erasure request - remove entirely
return ApplyResult<OrderView>.Purge();
}
// Normal archive - update the model with archive timestamp
return ApplyResult<OrderView>.Update(currentData with {
DeletedAt = @event.ArchivedAt
});
}
}
How the Generated Runner Handles Actions¶
The source-generated PerspectiveRunner inspects the ModelAction returned by each Apply call and takes the appropriate action at the end of the unit of work.
Event Stream: [Created] → [Updated] → [Cancelled]
↓ ↓ ↓
Apply Result: model₁ model₂ Delete()
↓ ↓ ↓
Runner State: track track keep model, mark written
↓
Unit of Work: Save final model + checkpoint ONCE
Updated
Shipped behavior: ModelAction.Delete does not automatically stamp DeletedAt. The runner keeps the model returned by the perspective (or the prior model when Delete() returns null) and upserts it at the end of the batch. To record a tombstone timestamp, set DeletedAt on the model yourself — either in the Apply method via Update(current with { DeletedAt = @event.CancelledAt }), or by returning a modified model alongside ModelAction.Delete using the tuple conversion.
Runner behavior per action:
-
ModelAction.None(update): The runner keeps the returned model and continues applying events. At the end of the batch, it upserts the final model and saves the checkpoint atomically. -
ModelAction.Delete(soft delete): The runner keeps the model (the one returned with the action, or the prior model if none was returned) and upserts it at save time. The row remains in the database for audit and temporal queries. SettingDeletedAtis the perspective's responsibility. -
ModelAction.Purge(hard delete): The runner sets apendingPurgeflag and nulls the model. All remaining events in the batch still advance the checkpoint but skip Apply calls (the model is null). At save time, the runner callsIPerspectiveStore.PurgeAsync()to remove the row entirely.
Important: Purge is terminal within a batch. Once a purge event is processed, subsequent events in the same batch advance the checkpoint but do not call Apply. If a new "created" event arrives in a later batch, the runner creates a fresh model.
Read Model Requirements¶
For Soft Delete¶
By convention, models that support soft delete include a DateTimeOffset? DeletedAt property, which the perspective's Apply method sets (the framework does not stamp it automatically):
Soft Delete Model
using Whizbang.Core;
public record OrderView {
[StreamId]
public Guid OrderId { get; init; }
public string CustomerName { get; init; } = string.Empty;
public decimal Total { get; init; }
public DateTimeOffset? DeletedAt { get; init; } // Required for soft delete
}
For Purge¶
No special model properties are required for purge. The row is removed entirely by IPerspectiveStore.PurgeAsync().
Testing Perspectives with Actions¶
Testing follows the same pure-function pattern as standard perspectives. No database mocking needed.
Testing Perspectives with Actions
using Whizbang.Core.Perspectives;
public class OrderPerspectiveTests {
[Test]
public async Task Apply_OrderCancelled_ReturnsSoftDeleteAsync() {
// Arrange
var perspective = new OrderPerspective();
var model = new OrderView {
OrderId = TrackedGuid.NewMedo(),
CustomerName = "Alice",
Total = 99.99m
};
var @event = new OrderCancelledEvent {
OrderId = model.OrderId,
CancelledAt = DateTimeOffset.UtcNow
};
// Act
ApplyResult<OrderView> result = perspective.Apply(model, @event);
// Assert - soft delete returns null model with Delete action
await Assert.That(result.Model).IsNull();
await Assert.That(result.Action).IsEqualTo(ModelAction.Delete);
}
[Test]
public async Task Apply_OrderPurged_ReturnsHardDeleteAsync() {
// Arrange
var perspective = new OrderPerspective();
var model = new OrderView {
OrderId = TrackedGuid.NewMedo(),
CustomerName = "Bob",
Total = 50.00m
};
var @event = new OrderPurgedEvent { OrderId = model.OrderId };
// Act
ApplyResult<OrderView> result = perspective.Apply(model, @event);
// Assert - hard delete returns null model with Purge action
await Assert.That(result.Model).IsNull();
await Assert.That(result.Action).IsEqualTo(ModelAction.Purge);
}
[Test]
public async Task Apply_MixedInterfaces_ChainCorrectlyAsync() {
// Arrange
var perspective = new OrderPerspective();
var empty = new OrderView();
// Act - chain create, update, then delete
var afterCreate = perspective.Apply(empty, new OrderCreatedEvent {
OrderId = TrackedGuid.NewMedo(),
CustomerName = "Carol",
Total = 75.00m,
CreatedAt = DateTime.UtcNow
});
var afterUpdate = perspective.Apply(afterCreate, new OrderUpdatedEvent {
OrderId = afterCreate.OrderId,
Total = 80.00m,
UpdatedAt = DateTime.UtcNow
});
ApplyResult<OrderView> afterCancel = perspective.Apply(afterUpdate, new OrderCancelledEvent {
OrderId = afterUpdate.OrderId,
CancelledAt = DateTimeOffset.UtcNow
});
// Assert
await Assert.That(afterCreate.CustomerName).IsEqualTo("Carol");
await Assert.That(afterUpdate.Total).IsEqualTo(80.00m);
await Assert.That(afterCancel.Action).IsEqualTo(ModelAction.Delete);
}
[Test]
public async Task Apply_IsPureFunction_OriginalModelUnchangedAsync() {
// Arrange
var perspective = new OrderPerspective();
var original = new OrderView {
OrderId = TrackedGuid.NewMedo(),
CustomerName = "Dave",
Total = 100.00m
};
// Act
var result = perspective.Apply(original, new OrderUpdatedEvent {
OrderId = original.OrderId,
Total = 200.00m,
UpdatedAt = DateTime.UtcNow
});
// Assert - original not mutated
await Assert.That(original.Total).IsEqualTo(100.00m);
await Assert.That(result.Total).IsEqualTo(200.00m);
}
}
Best Practices¶
DO¶
- Use
IPerspectiveForfor events that always produce a model update - Use
IPerspectiveWithActionsForonly for events that may delete - Include
DateTimeOffset? DeletedAton models that support soft delete, and set it inApply - Prefer
Delete()overPurge()unless data retention is explicitly not required - Use implicit conversions for clean, readable Apply methods
- Test each action type (update, delete, purge) independently
DON'T¶
- Don't use
IPerspectiveWithActionsForfor every event type when only one or two need deletion - Don't perform I/O in Apply methods (they remain pure functions)
- Don't forget that
Purgeis terminal within a batch (subsequent events skip Apply) - Don't rely on
Purgefor soft-delete scenarios (useDeleteto preserve the row)
Further Reading¶
Perspectives: - Perspectives Guide - Core perspective concepts, IPerspectiveFor, pure functions, and testing - Temporal Queries - Querying soft-deleted models via temporal lenses
Related Concepts: - Lenses - Query interfaces for read models (including deleted rows) - Receptors - Command handlers that produce events leading to deletes - Dispatcher - How to publish delete/purge events
Source Generators: - Perspective Discovery - How runners are generated for action perspectives
Version 1.0.0 - Foundation Release | Last Updated: 2026-03-26