Skip to content

Commit 7c02fe8

Browse files
committed
Strongly-typed actor IDs and actor-specific bus overloads
Every actor now has a typed ID wrapper — including string-keyed actors like Account(string id) which get Account.NewId("1") — making it impossible to route a message to the wrong actor or forget to prefix the raw grain key. Bus overloads are actor-specific: only the messages each actor actually handles appear as typed Execute/Query extension methods on that actor's ID type, giving full compile-time safety and accurate IntelliSense.
1 parent 9a93f60 commit 7c02fe8

12 files changed

Lines changed: 307 additions & 236 deletions

File tree

AGENTS.md

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ Emits OpenTelemetry spans (using `ActivitySource`) and metrics (using `Meter`) f
156156

157157
| Package | Generators | Runs in |
158158
|---------|-----------|---------|
159-
| `CloudActors.Abstractions.CodeAnalysis` | `ActorStateGenerator`, `ActorMessageGenerator`, `ActorIdBusOverloadGenerator`, `ActorBusOverloadGenerator`, `ActorPrimitiveIdGenerator`, `EventSourcedGenerator`, `CloudActorsAttributeGenerator` | Actor domain class library |
159+
| `CloudActors.Abstractions.CodeAnalysis` | `ActorStateGenerator`, `ActorMessageGenerator`, `ActorBusOverloadGenerator`, `ActorPrimitiveIdGenerator`, `EventSourcedGenerator`, `CloudActorsAttributeGenerator` | Actor domain class library |
160160
| `CloudActors.CodeAnalysis` | `ActorGrainGenerator`, `ActorIdFactoryGenerator`, `ActorsAssemblyGenerator` | Orleans silo / host project |
161161

162162
### Incremental pipeline conventions
@@ -201,20 +201,30 @@ Filters out generic types (`!x.IsGenericType`) and internal/special types to avo
201201

202202
Uses `CompilationProvider.Select` to discover all actors with typed IDs and generates an `IActorIdFactory` implementation registered via `[ModuleInitializer]` through `ActorIdFactory.Generated`.
203203

204-
### ActorIdBusOverloadGenerator (`CloudActors.Abstractions.CodeAnalysis`)
204+
### ActorBusOverloadGenerator (`CloudActors.Abstractions.CodeAnalysis`)
205205

206-
Generates typed `IActorBus` extension methods for actors with non-string IDs. Two cases:
207-
- **Primitive ID** (e.g. `long`, `Guid`): uses the generated `{Actor}.{Actor}Id` wrapper type
208-
- **Typed ID** (e.g. `ProductId`): uses the actual ID type directly
206+
Generates strongly-typed, **actor-specific** `IActorBus` extension methods. For each actor, only the messages it actually handles (via declared handler methods) get typed overloads. Uses `CreateSyntaxProvider` to find `[Actor]` classes in the current project.
209207

210-
To avoid `CS0111`, overloads are **only generated for ID types used by exactly one actor**. If two actors share the same underlying primitive type, no typed overloads are emitted.
208+
ID parameter type depends on the actor's ID:
209+
- **String or primitive ID** (`string`, `long`, `Guid`, …): uses the generated `{Actor}.{Actor}Id` wrapper; routes to `$"actortype/{id.Id}"`. All actors whose first constructor parameter is a BCL primitive or `string` go through this path — there are never plain `string` bus overloads.
210+
- **Typed ID** (`IParsable<T>` / `IStructId<T>`): uses the ID type directly; routes to `$"actortype/{id}"`.
211+
- **No recognized ID**: no overloads are emitted (unreachable for any valid `[Actor]` class).
212+
213+
One file per actor (`{Actor}.Bus.g.cs`) containing all its overloads:
214+
- Void command → `Task ExecuteAsync(IActorBus, Id, Msg)`
215+
- Command with return → `Task<T> ExecuteAsync(IActorBus, Id, Msg)`
216+
- Query → `Task<T> QueryAsync(IActorBus, Id, Msg)`
217+
218+
**Important**: `IActorCommand<T>` extends `IActorCommand`, so message types implementing `IActorCommand<T>` satisfy both `IsActorCommand()` (generic) and `IsActorVoidCommand()` (non-generic). The `voidCommands` extraction explicitly excludes types that also pass `IsActorCommand()` to keep the two buckets mutually exclusive.
211219

212220
### ActorPrimitiveIdGenerator (`CloudActors.Abstractions.CodeAnalysis`)
213221

214-
For actors whose primary constructor takes a primitive BCL value type (e.g. `long`, `Guid`), generates:
215-
- A nested `readonly record struct {Actor}Id(T Id)` wrapper
216-
- A static `NewId(T id)` factory method
217-
- For `Guid` IDs: an additional parameterless `NewId()` that calls `Guid.CreateVersion7()` (if available on the runtime) or `Guid.NewGuid()`
222+
For actors whose primary constructor takes **any BCL primitive or `string`** (e.g. `string`, `long`, `Guid`), generates a nested typed wrapper and factory:
223+
- `readonly record struct {Actor}Id(T Id)` — e.g. `AccountId(string Id)`, `OrderId(long Id)`
224+
- `static {Actor}Id NewId(T id)` factory method
225+
- For `Guid` IDs only: an additional parameterless `NewId()` using `Guid.CreateVersion7()` (.NET 9+) or `Guid.NewGuid()`
226+
227+
This covers **all** actors with BCL-typed constructor IDs, including string-keyed ones. A string actor like `Account(string id)` gets `Account.AccountId(string Id)` and `Account.NewId("1")`, ensuring callers cannot accidentally pass a raw prefixed grain key (`"account/1"`) to the wrong actor.
218228

219229
### EventSourcedGenerator (`CloudActors.Abstractions.CodeAnalysis`)
220230

@@ -243,11 +253,12 @@ Helper that discovers and invokes the STJ (`System.Text.Json`) source generator
243253

244254
## Typed Actor IDs
245255

246-
Actors can use non-string IDs in three ways:
256+
Every actor has a strongly-typed ID — CloudActors never emits plain `string` bus overloads. The ID flavor depends on the actor's constructor:
257+
258+
1. **BCL primitive or string** (`string`, `long`, `Guid`, …): `ActorPrimitiveIdGenerator` generates a nested `{Actor}Id(T Id)` wrapper struct. Both `Account(string id)` and `Order(long id)` fall into this category; callers use `Account.NewId("1")` / `Order.NewId(42)`.
259+
2. **Structured / library ID** (`IParsable<T>` / `IStructId<T>`): The ID type is used directly in bus overloads (e.g. `ProductId`). Works automatically with [StructId](https://github.com/devlooped/StructId), StronglyTypedId, Vogen, etc.
247260

248-
1. **Primitive ID** (`long`, `Guid`, etc.): A nested `{Actor}Id` wrapper struct is generated; `NewId()` helper is available.
249-
2. **Typed ID** via `IParsable<T>` / `IFormattable`: The ID type (e.g. `ProductId`) is used directly. Typed `IActorBus` extension overloads are generated.
250-
3. **StructId**: Types implementing `IStructId` (from the [StructId](https://github.com/devlooped/StructId) package) are detected automatically.
261+
All actors have typed IDs, providing compile-time safety against passing the wrong message to the wrong actor.
251262

252263
ID format stored in Orleans: `"{actortype}/{id}"` (e.g. `"account/42"`, `"product/p1"`).
253264

readme.md

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,35 @@ project, and more incremental behavior added as users opt-in to certain features
315315

316316
## Typed Actor IDs
317317

318-
Instead of identifying actors with plain strings, you can use strongly-typed IDs.
318+
All actors get a strongly-typed IDs for free — CloudActors emits proper typed overloads so
319+
you never have to use raw `string` composite keys for bus calls, which prevents accidentally
320+
passing the wrong ID format (e.g. `"account/1"` vs. `"1"`).
321+
322+
### String IDs
323+
324+
When the actor's first constructor parameter is `string`, the generator produces a `{Actor}Id`
325+
wrapper struct so callers use type-safe IDs against the bus, while getting useful completion from
326+
overloads exposing the applicable messages the actor can handle:
327+
328+
```csharp
329+
[Actor]
330+
public partial class Account(string id)
331+
{
332+
public string Id { get; } = id;
333+
// ...
334+
}
335+
336+
// Generated:
337+
// public readonly record struct AccountId(string Id);
338+
// public static AccountId NewId(string id) => new(id);
339+
```
340+
341+
```csharp
342+
var id = Account.NewId("1");
343+
344+
await bus.ExecuteAsync(id, new Deposit(100));
345+
var balance = await bus.QueryAsync(id, GetBalance.Default);
346+
```
319347

320348
### Primitive IDs
321349

@@ -379,18 +407,20 @@ var price = await bus.QueryAsync(id, new GetPrice());
379407

380408
### Typed Bus Overloads
381409

382-
For any actor with a typed ID (primitive or structured), the generator produces typed
383-
`IActorBus` extension overloads so you never have to format the ID string manually:
410+
For every actor, the generator produces typed `IActorBus` extension overloads — one per
411+
handled message — so you can never accidentally pass the wrong message to the wrong actor:
384412

385413
```csharp
386-
// Instead of:
387-
await bus.ExecuteAsync("order/42", new PlaceOrder(...));
388-
389-
// You can use the typed overload:
414+
// Only valid messages for Order are available via OrderId:
390415
await bus.ExecuteAsync(new OrderId(42), new PlaceOrder(...));
416+
417+
// Compile error: Deposit is not a valid message for Order
418+
await bus.ExecuteAsync(new OrderId(42), new Deposit(100)); //
391419
```
392420

393-
The ID is always stored and routed as `"{actortype}/{id}"` (e.g. `"order/42"`, `"product/..."`).
421+
The ID is always routed as `"{actortype}/{id}"` (e.g. `"order/42"`, `"product/..."`)
422+
as expected by Orleans, but the typed overloads shield you from that detail and provide
423+
type safety.
394424

395425
## Telemetry and Monitoring
396426

Lines changed: 121 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,150 @@
11
using System.Linq;
2+
using System.Text;
23
using Microsoft.CodeAnalysis;
34
using Microsoft.CodeAnalysis.CSharp.Syntax;
4-
using static Devlooped.CloudActors.AnalysisExtensions;
55

66
namespace Devlooped.CloudActors;
77

8+
/// <summary>
9+
/// Generates strongly-typed, message-specific <see cref="IActorBus"/> extension methods for each actor.
10+
/// For actors with a typed ID, emits (TypedId, ConcreteMessage) overloads.
11+
/// For actors with a string ID, emits (string, ConcreteMessage) overloads.
12+
/// Only messages actually handled by the actor (declared handler methods) get overloads.
13+
/// </summary>
814
[Generator(LanguageNames.CSharp)]
915
class ActorBusOverloadGenerator : IIncrementalGenerator
1016
{
1117
public void Initialize(IncrementalGeneratorInitializationContext context)
1218
{
13-
var interfaces = context.CompilationProvider
14-
.Select((c, _) => (
15-
VoidCommand: c.GetTypeByMetadataName("Devlooped.CloudActors.IActorCommand")?.ToDisplayString(FullName),
16-
Command: c.GetTypeByMetadataName("Devlooped.CloudActors.IActorCommand`1")?.ToDisplayString(FullName),
17-
Query: c.GetTypeByMetadataName("Devlooped.CloudActors.IActorQuery`1")?.ToDisplayString(FullName)))
18-
.WithTrackingName(TrackingNames.BusInterfaces);
19-
20-
var messages = context.SyntaxProvider.CreateSyntaxProvider(
19+
var actors = context.SyntaxProvider.CreateSyntaxProvider(
2120
predicate: static (node, _) =>
22-
node is TypeDeclarationSyntax tds &&
23-
tds.BaseList?.Types.Count > 0,
21+
node is ClassDeclarationSyntax cds &&
22+
cds.AttributeLists.Count > 0,
2423
transform: static (ctx, ct) =>
2524
{
26-
if (ctx.SemanticModel.GetDeclaredSymbol(ctx.Node, ct) is not INamedTypeSymbol type)
27-
return default;
25+
if (ctx.SemanticModel.GetDeclaredSymbol(ctx.Node, ct) is not INamedTypeSymbol symbol)
26+
return (ActorModel?)null;
2827

29-
if (!type.IsActorMessage())
30-
return default;
28+
if (!symbol.IsActor())
29+
return null;
3130

32-
var voidCommand = ctx.SemanticModel.Compilation.GetTypeByMetadataName("Devlooped.CloudActors.IActorCommand");
33-
var command = ctx.SemanticModel.Compilation.GetTypeByMetadataName("Devlooped.CloudActors.IActorCommand`1");
34-
var query = ctx.SemanticModel.Compilation.GetTypeByMetadataName("Devlooped.CloudActors.IActorQuery`1");
31+
var iParsable = ctx.SemanticModel.Compilation.GetTypeByMetadataName("System.IParsable`1");
32+
var guidType = ctx.SemanticModel.Compilation.GetTypeByMetadataName("System.Guid");
33+
var hasCreateVersion7 = guidType?.GetMembers("CreateVersion7")
34+
.OfType<IMethodSymbol>()
35+
.Any(m => m.IsStatic && m.Parameters.Length == 0) == true;
3536

36-
return ModelExtractors.ExtractActorMessageModel(type, voidCommand, command, query);
37+
return ModelExtractors.ExtractActorModel(symbol, iParsable, hasCreateVersion7);
3738
})
3839
.Where(static x => x != null)
3940
.Select(static (x, _) => x!.Value)
4041
.WithTrackingName(TrackingNames.BusOverloads);
4142

42-
context.RegisterSourceOutput(messages, (ctx, model) =>
43+
// Emit the base partial class only when at least one actor has emittable overloads.
44+
// Using Collect+Select(bool) keeps this a single cached bool — not per-actor — so the
45+
// base file is only added/removed when the set of emittable actors crosses zero.
46+
var hasOverloads = actors
47+
.Where(static a =>
48+
(a.VoidCommands.Length > 0 || a.Commands.Length > 0 || a.Queries.Length > 0) &&
49+
(a.IsPrimitiveId || a.IsTypedId))
50+
.Collect()
51+
.Select(static (arr, _) => arr.Length > 0);
52+
53+
context.RegisterSourceOutput(hasOverloads, static (ctx, hasAny) =>
54+
{
55+
if (!hasAny)
56+
return;
57+
58+
ctx.AddSource("ActorBusExtensions.g.cs",
59+
"""
60+
// <auto-generated/>
61+
using System.ComponentModel;
62+
[EditorBrowsable(EditorBrowsableState.Never)]
63+
public static partial class ActorBusExtensions { }
64+
""");
65+
});
66+
67+
// Per-actor overloads remain incremental: each actor's file is independently cached.
68+
context.RegisterSourceOutput(actors, (ctx, actor) =>
4369
{
44-
var file = $"{model.FileName}.g.cs";
70+
if (actor.VoidCommands.Length == 0 && actor.Commands.Length == 0 && actor.Queries.Length == 0)
71+
return;
72+
73+
var grainType = actor.Name.ToLowerInvariant();
4574

46-
switch (model.Kind)
75+
string busIdTypeName;
76+
string idExpr;
77+
78+
if (actor.IsPrimitiveId)
79+
{
80+
busIdTypeName = $"{actor.FullName}.{actor.Name}Id";
81+
idExpr = $"$\"{grainType}/{{id.Id}}\"";
82+
}
83+
else if (actor.IsTypedId && actor.IdTypeFullName != null)
4784
{
48-
case ActorMessageKind.VoidCommand:
49-
ctx.AddSource(file,
50-
$$"""
51-
using System.Threading.Tasks;
52-
using Devlooped.CloudActors;
53-
54-
static partial class ActorBusExtensions
55-
{
56-
public static Task ExecuteAsync(this IActorBus bus, string id, {{model.FullName}} command)
57-
=> bus.ExecuteAsync(id, (IActorCommand)command);
58-
}
59-
""");
60-
break;
61-
62-
case ActorMessageKind.Command:
63-
ctx.AddSource(file,
64-
$$"""
65-
using System.Threading.Tasks;
66-
using Devlooped.CloudActors;
67-
68-
static partial class ActorBusExtensions
69-
{
70-
public static Task<{{model.ReturnTypeFullName}}> ExecuteAsync(this IActorBus bus, string id, {{model.FullName}} command)
71-
=> bus.ExecuteAsync<{{model.ReturnTypeFullName}}>(id, command);
72-
}
73-
""");
74-
break;
75-
76-
case ActorMessageKind.Query:
77-
ctx.AddSource(file,
78-
$$"""
79-
using System.Threading.Tasks;
80-
using Devlooped.CloudActors;
81-
82-
static partial class ActorBusExtensions
83-
{
84-
public static Task<{{model.ReturnTypeFullName}}> QueryAsync(this IActorBus bus, string id, {{model.FullName}} query)
85-
=> bus.QueryAsync<{{model.ReturnTypeFullName}}>(id, query);
86-
}
87-
""");
88-
break;
85+
busIdTypeName = actor.IdTypeFullName;
86+
idExpr = $"$\"{grainType}/{{id}}\"";
8987
}
88+
else
89+
{
90+
// Actor with no recognized ID type — no typed overloads can be generated.
91+
return;
92+
}
93+
94+
var sb = new StringBuilder();
95+
sb.AppendLine("// <auto-generated/>");
96+
sb.AppendLine("#nullable enable");
97+
sb.AppendLine("using System.Runtime.CompilerServices;");
98+
sb.AppendLine("using System.Threading.Tasks;");
99+
sb.AppendLine("using Devlooped.CloudActors;");
100+
sb.AppendLine();
101+
sb.AppendLine("static partial class ActorBusExtensions");
102+
sb.AppendLine("{");
103+
104+
foreach (var cmd in actor.VoidCommands.AsImmutableArray())
105+
{
106+
sb.AppendLine(
107+
$" /// <summary>Invokes a state-changing command on a <see cref=\"{actor.FullName}\"/> actor.</summary>");
108+
sb.AppendLine(
109+
$" public static Task ExecuteAsync(this IActorBus bus, {busIdTypeName} id, {cmd.Type} command,");
110+
sb.AppendLine(
111+
$" [CallerMemberName] string? callerName = default, [CallerFilePath] string? callerFile = default, [CallerLineNumber] int? callerLine = default)");
112+
sb.AppendLine(
113+
$" => bus.ExecuteAsync({idExpr}, (IActorCommand)command, callerName, callerFile, callerLine);");
114+
sb.AppendLine();
115+
}
116+
117+
foreach (var cmd in actor.Commands.AsImmutableArray())
118+
{
119+
var returnType = cmd.ReturnTypeFullName!;
120+
sb.AppendLine(
121+
$" /// <summary>Invokes a state-changing command on a <see cref=\"{actor.FullName}\"/> actor.</summary>");
122+
sb.AppendLine(
123+
$" public static Task<{returnType}> ExecuteAsync(this IActorBus bus, {busIdTypeName} id, {cmd.Type} command,");
124+
sb.AppendLine(
125+
$" [CallerMemberName] string? callerName = default, [CallerFilePath] string? callerFile = default, [CallerLineNumber] int? callerLine = default)");
126+
sb.AppendLine(
127+
$" => bus.ExecuteAsync<{returnType}>({idExpr}, command, callerName, callerFile, callerLine);");
128+
sb.AppendLine();
129+
}
130+
131+
foreach (var query in actor.Queries.AsImmutableArray())
132+
{
133+
var returnType = query.ReturnTypeFullName!;
134+
sb.AppendLine(
135+
$" /// <summary>Invokes a read-only query on a <see cref=\"{actor.FullName}\"/> actor.</summary>");
136+
sb.AppendLine(
137+
$" public static Task<{returnType}> QueryAsync(this IActorBus bus, {busIdTypeName} id, {query.Type} query,");
138+
sb.AppendLine(
139+
$" [CallerMemberName] string? callerName = default, [CallerFilePath] string? callerFile = default, [CallerLineNumber] int? callerLine = default)");
140+
sb.AppendLine(
141+
$" => bus.QueryAsync<{returnType}>({idExpr}, query, callerName, callerFile, callerLine);");
142+
sb.AppendLine();
143+
}
144+
145+
sb.AppendLine("}");
146+
147+
ctx.AddSource($"{actor.FileName}.Bus.g.cs", sb.ToString());
90148
});
91149
}
92150
}

0 commit comments

Comments
 (0)