Skip to content

Commit a669ab3

Browse files
Merge pull request #58 from salihcantekin/feature/send-request-overload
Feature/send request overload
2 parents eb20498 + 9a42d25 commit a669ab3

File tree

20 files changed

+494
-54
lines changed

20 files changed

+494
-54
lines changed

SpaceSolution.sln

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{02EA681E-C
2727
docs\Pipelines.md = docs\Pipelines.md
2828
docs\PlannedImprovements.md = docs\PlannedImprovements.md
2929
docs\ProjectDoc.en.md = docs\ProjectDoc.en.md
30+
docs\Versioning.md = docs\Versioning.md
3031
EndProjectSection
3132
EndProject
3233
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "github", "github", "{13AA0D40-B562-4388-B5F9-C4612A784505}"

docs/DeveloperRecommendations.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,13 @@
44
- Integrate with `LoggerFactory` for advanced logging.
55
- Follow interface and config standards when implementing custom modules.
66
- Review the `Space.Modules.InMemoryCache` implementation for module development best practices.
7+
- Prefer the strongly-typed Send API: `Send<TRequest, TResponse>(TRequest, string? name = null)` with `TRequest : IRequest<TResponse>`.
8+
- For dynamic scenarios, use `Send<TResponse>(IRequest<TResponse>)` or `Send<TResponse>(object)`.
9+
- Keep handlers/pipelines ValueTask-based where possible to minimize allocations.
10+
- Favor singleton lifetime to hit Space’s fast-path (no scope allocations) for hot paths.
11+
- Avoid reflection at runtime; rely on the source generator output for registrations.
12+
- Pipelines: keep them small; prefer ordered composition and share state via `PipelineContext.Items`.
13+
- Benchmarks: run `tests/Space.Benchmarks` to compare typed/IRequest/object Send paths.
14+
- Always run `dotnet format` before committing.
715

8-
For more suggestions, see [ProjectDoc.txt](ProjectDoc.txt).
16+
For more, see [ProjectDoc.txt](ProjectDoc.txt).

docs/Handlers.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Handlers in Space are methods marked with the `[Handle]` attribute. They process
44

55
## Example
66
```csharp
7-
public record UserLoginRequest(string UserName);
7+
public record UserLoginRequest(string UserName) : IRequest<UserLoginResponse>;
88
public record UserLoginResponse(bool Success);
99

1010
public partial class UserHandlers
@@ -13,32 +13,38 @@ public partial class UserHandlers
1313
public async ValueTask<UserLoginResponse> Login(HandlerContext<UserLoginRequest> ctx)
1414
{
1515
var userService = ctx.ServiceProvider.GetService<UserService>();
16-
var loginModel = ctx.Request;
17-
var userExists = await userService.Login(loginModel);
18-
return new UserLoginResponse() { Success = userExists };
16+
var userExists = await userService.Login(ctx.Request);
17+
return new UserLoginResponse(userExists);
1918
}
2019
}
2120
```
2221

2322
## Usage
23+
Preferred strongly-typed usage:
2424
```csharp
2525
ISpace space = serviceProvider.GetRequiredService<ISpace>();
26-
var loginResponse = await space.Send<UserLoginResponse>(new UserLoginRequest { UserName = "sc" });
26+
var loginResponse = await space.Send<UserLoginRequest, UserLoginResponse>(new UserLoginRequest("sc"));
2727
```
2828

29+
Additional overloads:
30+
- IRequest overload: `await space.Send<UserLoginResponse>(IRequest<UserLoginResponse> request, string? name = null)`
31+
- Object overload: `await space.Send<UserLoginResponse>(object request, string? name = null)`
32+
33+
> Rule: In `Send<TRequest, TResponse>`, `TRequest` must implement `IRequest<TResponse>`. This is enforced at compile time.
34+
2935
Handlers can be named using the `Name` parameter and invoked by name:
3036
```csharp
3137
[Handle(Name = "CustomHandler")]
3238
public ValueTask<ResponseType> CustomHandler(HandlerContext<RequestType> ctx) { ... }
3339

34-
var response = await space.Send<ResponseType>(request, name: "CustomHandler");
40+
var response = await space.Send<RequestType, ResponseType>(new RequestType(...), name: "CustomHandler");
3541
```
3642

3743
## Selecting default handler with IsDefault
3844
When multiple handlers exist for the same request/response pair, you can mark one as the default so that `Send` without a name uses it.
3945

4046
```csharp
41-
public record PriceQuery(int Id);
47+
public record PriceQuery(int Id) : IRequest<PriceResult>;
4248
public record PriceResult(string Tag);
4349

4450
public class PricingHandlers

docs/KnownIssues.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
# Known Issues
1+
# Known Issues / Breaking Changes
2+
3+
- Breaking: `ISpace.Send<TRequest, TResponse>` now requires `TRequest : IRequest<TResponse>`.
4+
- Migration:
5+
- If your request already implements `IRequest<TRes>`, nothing to change.
6+
- If not, either add `IRequest<TRes>` to your request type, or use `Send<TRes>(IRequest<TRes> request)` or `Send<TRes>(object request)` overloads.
7+
- Benefit: compile-time safety and better discoverability.
28

39
- Circular dependency in `ISpace` and `SpaceRegistry`: The first handler receives a null `ISpace(Lazy)` instance, which is set on subsequent requests.
410
- ~~When using handler names, modules are applied to all handlers instead of only those with the attribute.~~ (Fixed)

docs/Pipelines.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ Pipelines in Space act as middleware for handler execution. They are methods mar
44

55
## Example
66
```csharp
7+
public record UserLoginRequest(string UserName) : IRequest<UserLoginResponse>;
8+
public record UserLoginResponse(bool Success);
9+
710
public partial class UserHandlers
811
{
912
// Runs first (lower Order executes earlier in the chain)
@@ -29,6 +32,7 @@ public partial class UserHandlers
2932
You can implement pipeline interfaces for type safety and build-time validation:
3033
```csharp
3134
public class MyPipeline : IPipelineHandler<MyRequest, MyResponse>
35+
where MyRequest : IRequest<MyResponse>
3236
{
3337
public ValueTask<MyResponse> HandlePipeline(PipelineContext<MyRequest> ctx, PipelineDelegate<MyRequest, MyResponse> next)
3438
=> next(ctx);
@@ -42,6 +46,9 @@ public class MyPipeline : IPipelineHandler<MyRequest, MyResponse>
4246
- Items are cleared automatically between requests.
4347

4448
```csharp
49+
public record MyReq(string Text) : IRequest<MyRes>;
50+
public record MyRes(string Text);
51+
4552
public class SampleHandlers
4653
{
4754
// Global pipelines (no handle name) for the same request/response
@@ -55,17 +62,17 @@ public class SampleHandlers
5562
var res = await next(ctx);
5663
// Read again later if needed
5764
var tid = (string)ctx.GetItem("trace-id");
58-
return res; // or: return res with { Text = $"{res.Text}:P1:{tid}" };
65+
return res with { Text = $"{res.Text}:P1={tid}" };
5966
}
6067

6168
// Order = 2 executes after P1 (closer to the handler)
6269
[Pipeline(Order = 2)]
6370
public async ValueTask<MyRes> P2(PipelineContext<MyReq> ctx, PipelineDelegate<MyReq, MyRes> next)
6471
{
6572
// Access value set by P1
66-
var tid = (string)ctx.GetItem("trace-id"); // not null
73+
var tid = (string)ctx.GetItem("trace-id");
6774
var res = await next(ctx);
68-
return res; // or: return res with { Text = $"{res.Text}:P2:{tid}" };
75+
return res with { Text = $"{res.Text}:P2={tid}" };
6976
}
7077
}
7178
```

src/Space.Abstraction/ISpace.cs

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
1+
using System;
2+
using System.ComponentModel;
13
using System.Threading;
24
using System.Threading.Tasks;
35

46
namespace Space.Abstraction;
57

68
public interface ISpace
79
{
8-
// Core typed overload
9-
ValueTask<TResponse> Send<TRequest, TResponse>(TRequest request, string name = null, CancellationToken ct = default)
10-
where TRequest : notnull
11-
where TResponse : notnull;
10+
ValueTask Publish<TRequest>(TRequest request, CancellationToken ct = default);
11+
ValueTask Publish<TRequest>(TRequest request, NotificationDispatchType dispatchType, CancellationToken ct = default);
1212

13-
// Object path kept for dynamic scenarios
14-
ValueTask<TResponse> Send<TResponse>(object request, string name = null, CancellationToken ct = default) where TResponse : notnull;
13+
// Preferred strongly-typed send; enforces TRequest : IRequest<TResponse>
14+
ValueTask<TResponse> Send<TRequest, TResponse>(TRequest request, string name = null, CancellationToken ct = default)
15+
where TRequest : notnull, IRequest<TResponse>
16+
where TResponse : notnull;
1517

16-
// Notifications: no name parameter
17-
ValueTask Publish<TRequest>(TRequest request, CancellationToken ct = default) where TRequest : notnull;
18+
// Convenience overload for IRequest<TResponse>
19+
[EditorBrowsable(EditorBrowsableState.Always)]
20+
ValueTask<TResponse> Send<TResponse>(IRequest<TResponse> request, string name = null, CancellationToken ct = default)
21+
where TResponse : notnull;
1822

19-
// Per-call dispatcher override using enum
20-
ValueTask Publish<TRequest>(TRequest request, NotificationDispatchType dispatchType, CancellationToken ct = default) where TRequest : notnull;
23+
// Object-based send with explicit response type
24+
[EditorBrowsable(EditorBrowsableState.Always)]
25+
ValueTask<TResponse> Send<TResponse>(object request, string name = null, CancellationToken ct = default)
26+
where TResponse : notnull;
2127
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using System;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
using System.ComponentModel;
5+
using Space.Abstraction.Contracts;
6+
7+
namespace Space.Abstraction;
8+
9+
public static class ISpaceStrictExtensions
10+
{
11+
/// <summary>
12+
/// Preferred strongly-typed Send that enforces TRequest : IRequest&lt;TResponse&gt; at compile time.
13+
/// </summary>
14+
[EditorBrowsable(EditorBrowsableState.Always)]
15+
public static ValueTask<TResponse> SendStrict<TRequest, TResponse>(this ISpace space, TRequest request, string name = null, CancellationToken ct = default)
16+
where TRequest : notnull, IRequest<TResponse>
17+
where TResponse : notnull
18+
=> space.Send<TRequest, TResponse>(request, name, ct);
19+
}

src/Space.Abstraction/Registry/HandlerRegistry.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ public void RegisterHandler<TRequest, TResponse>(
4747
}
4848

4949
handlerMap[key] = entryObj;
50+
51+
// Map (TRequest, TResponse) to the last registered entry (unnamed or named).
52+
// This supports unnamed Send resolving to the last handler, and with OrderedHandlers
53+
// default-marked handlers are registered last.
5054
handlerMapByType[(typeof(TRequest), typeof(TResponse))] = entryObj;
5155
}
5256

@@ -118,6 +122,13 @@ internal bool TryGetHandlerEntryByRuntimeType(Type requestType, Type responseTyp
118122

119123
if (string.IsNullOrEmpty(name))
120124
{
125+
// Prefer explicit unnamed mapping if present
126+
if (readOnlyHandlerMap != null && readOnlyHandlerMap.TryGetValue((requestType, string.Empty, responseType), out var unnamed))
127+
{
128+
entryObj = unnamed;
129+
return true;
130+
}
131+
121132
if (readOnlyHandlerMapByType != null && readOnlyHandlerMapByType.TryGetValue((requestType, responseType), out var direct))
122133
{
123134
entryObj = direct;
@@ -159,6 +170,20 @@ public ValueTask<TResponse> DispatchHandler<TRequest, TResponse>(IServiceProvide
159170
return typeEntry.Invoke(ctx);
160171
}
161172

173+
// Fallback: enumerate to find the last registered handler for (TRequest,TResponse)
174+
SpaceRegistry.HandlerEntry<TRequest, TResponse> lastMatch = null;
175+
foreach (var kv in readOnlyHandlerMap)
176+
{
177+
if (kv.Key.Item1 == typeof(TRequest) && kv.Key.Item3 == typeof(TResponse) && kv.Value is SpaceRegistry.HandlerEntry<TRequest, TResponse> e)
178+
{
179+
lastMatch = e; // preserve insertion order, this becomes the last registered
180+
}
181+
}
182+
if (lastMatch != null)
183+
{
184+
return lastMatch.Invoke(ctx);
185+
}
186+
162187
throw new InvalidOperationException($"Handler not found for type {typeof(TRequest)} -> {typeof(TResponse)} and name '{name}'");
163188
}
164189

@@ -202,6 +227,13 @@ private ValueTask<object> DispatchHandlerInternal(object request, string name, T
202227
// Prefer disambiguated lookup when responseType provided
203228
if (responseType != null)
204229
{
230+
// Prefer explicit unnamed mapping if present
231+
if (readOnlyHandlerMap != null && readOnlyHandlerMap.TryGetValue((type, string.Empty, responseType), out var explicitUnnamed) && explicitUnnamed is SpaceRegistry.IObjectHandlerEntry explicitUnnamedEntry)
232+
{
233+
var ctx = HandlerContextStruct.Create(execProvider, request, space, ct);
234+
return explicitUnnamedEntry.InvokeObject(ctx);
235+
}
236+
205237
if (readOnlyHandlerMapByType.TryGetValue((type, responseType), out var handlerObjRt) && handlerObjRt is SpaceRegistry.IObjectHandlerEntry objectHandlerRt)
206238
{
207239
var ctx = HandlerContextStruct.Create(execProvider, request, space, ct);

src/Space.DependencyInjection/Space.cs

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ private static class GenericDispatcherCache<TRes>
3636

3737
[MethodImpl(MethodImplOptions.AggressiveInlining)]
3838
public ValueTask<TResponse> Send<TRequest, TResponse>(TRequest request, string name = null, CancellationToken ct = default)
39-
where TRequest : notnull
39+
where TRequest : notnull, IRequest<TResponse>
4040
where TResponse : notnull
4141
{
4242
if (ct.IsCancellationRequested)
@@ -172,18 +172,67 @@ static async ValueTask<TResponse> AwaitLite(ValueTask<TResponse> task, IServiceS
172172
}
173173
}
174174

175-
// Constrained overloads to avoid boxing for IRequest<TResponse>
176175
[MethodImpl(MethodImplOptions.AggressiveInlining)]
177-
public ValueTask<TResponse> Send<TRequest, TResponse>(in TRequest request, CancellationToken ct = default)
178-
where TRequest : struct, IRequest<TResponse>
176+
public ValueTask<TResponse> Send<TResponse>(IRequest<TResponse> request, string name = null, CancellationToken ct = default)
179177
where TResponse : notnull
180-
=> Send<TRequest, TResponse>(request, null, ct);
178+
{
179+
if (ct.IsCancellationRequested)
180+
return ValueTask.FromCanceled<TResponse>(ct);
181181

182-
[MethodImpl(MethodImplOptions.AggressiveInlining)]
183-
public ValueTask<TResponse> Send<TRequest, TResponse>(TRequest request, CancellationToken ct = default)
184-
where TRequest : class, IRequest<TResponse>
185-
where TResponse : notnull
186-
=> Send<TRequest, TResponse>(request, null, ct);
182+
if (IsFastPath(spaceRegistry.HandlerLifetime))
183+
{
184+
// Try runtime-type lookup without expression/MakeGenericMethod
185+
if (spaceRegistry.TryGetHandlerEntryByRuntimeType(request.GetType(), typeof(TResponse), name, out var entryObj)
186+
&& entryObj is SpaceRegistry.IObjectHandlerEntry entry)
187+
{
188+
var hctx = HandlerContextStruct.Create(rootProvider, request, this, ct);
189+
var vto = entry.InvokeObject(hctx);
190+
191+
if (vto.IsCompletedSuccessfully)
192+
return new ValueTask<TResponse>((TResponse)vto.Result!);
193+
194+
return AwaitFast1(vto);
195+
196+
static async ValueTask<TResponse> AwaitFast1(ValueTask<object> vt)
197+
=> (TResponse)await vt.ConfigureAwait(false);
198+
}
199+
200+
// Fallback: object dispatch through registry (still no expression compile)
201+
var vtoFallback = spaceRegistry.DispatchHandler(request, name, typeof(TResponse), rootProvider, ct);
202+
203+
if (vtoFallback.IsCompletedSuccessfully)
204+
return new ValueTask<TResponse>((TResponse)vtoFallback.Result!);
205+
206+
return AwaitFast2(vtoFallback);
207+
208+
static async ValueTask<TResponse> AwaitFast2(ValueTask<object> vt)
209+
=> (TResponse)await vt.ConfigureAwait(false);
210+
}
211+
212+
// Scoped path
213+
using var scope = scopeFactory.CreateScope();
214+
if (ct.IsCancellationRequested)
215+
return ValueTask.FromCanceled<TResponse>(ct);
216+
217+
var vts = spaceRegistry.DispatchHandler(request, name, typeof(TResponse), scope.ServiceProvider, ct);
218+
219+
if (vts.IsCompletedSuccessfully)
220+
return new ValueTask<TResponse>((TResponse)vts.Result!);
221+
222+
return AwaitScoped(vts, scope);
223+
224+
static async ValueTask<TResponse> AwaitScoped(ValueTask<object> vt, IServiceScope scope)
225+
{
226+
try
227+
{
228+
return (TResponse)await vt.ConfigureAwait(false);
229+
}
230+
finally
231+
{
232+
scope.Dispose();
233+
}
234+
}
235+
}
187236

188237
#endregion
189238

@@ -311,4 +360,5 @@ async ValueTask SlowPublishScoped(TRequest req, NotificationDispatchType dt, Can
311360
}
312361
}
313362

363+
314364
}

src/Space.SourceGenerator/Diagnostics/DiagnosticGenerator.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ internal class DiagnosticGenerator
2121

2222
new MissingPipelineAttributeRule(),
2323
new PipelineAttributeRule()
24+
// SEND001 disabled for now (kept in code for future use)
25+
// new SendGenericResponseMismatchRule()
2426
];
2527

2628
internal static bool ReportDiagnostics(SourceProductionContext spc, Compilation compilation, HandlersCompileWrapperModel model)

0 commit comments

Comments
 (0)