The goal of this project is to build a high-performance alternative to MediatR while removing / minimizing several usability and performance drawbacks:
- Eliminate runtime reflection (a major MediatR cost) by using a source generator for compile-time discovery & registration.
- Reduce interface & model boilerplate (no need for separate IRequest / IRequestHandler pairs per request unless you prefer helper interfaces).
- Allow multiple related handlers to co-exist in a single class via simple attributes.
- Introduce an extensible Module system (e.g. Caching) implemented as system pipelines that run before user pipelines.
- Support named handlers so multiple handlers for the same Request/Response can be selected explicitly at Send time.
- Multi-project handler discovery via a single root aggregator + per-assembly lightweight registration (see
MultiProjectSetup.md).
Core usage requires referencing BOTH packages explicitly:
Space.Abstraction(automatically includes the Space.SourceGenerator analyzer; install this and you get compile-time registration)Space.DependencyInjection(runtime DI extensions + implementations of ISpace and registries)
Explicit requirement for multi-project solutions:
- Root aggregator project (the one with
<SpaceGenerateRootAggregator>true</SpaceGenerateRootAggregator>) MUST reference BOTHSpace.AbstractionandSpace.DependencyInjection. - Satellite class libraries that only declare handlers/pipelines/notifications SHOULD reference
Space.Abstraction(to bring the analyzer). They typically do NOT needSpace.DependencyInjection.
Optional modules (examples):
Space.Modules.InMemoryCache
Previous versions auto-brought abstractions via
Space.DependencyInjection. This changed so any project that uses attributes (even without DI) can get source generation by referencingSpace.Abstractionalone.
- Old:
Space.DependencyInjectionbrought abstractions + analyzer implicitly. - New:
Space.Abstractionbrings the analyzer.Space.DependencyInjectionprovides runtime DI only. - Migration:
- If you referenced only
Space.DependencyInjection, addSpace.Abstractionto every project that uses Space attributes. - Remove any explicit
Space.SourceGeneratorproject/NuGet references; they are no longer necessary whenSpace.Abstractionis present.
- If you referenced only
services.AddSpace(opt =>
{
opt.NotificationDispatchType = NotificationDispatchType.Parallel; // or Sequential
});
// Optional module provider(s)
services.AddSpaceInMemoryCache();
var provider = services.BuildServiceProvider();
ISpace space = provider.GetRequiredService<ISpace>();Add to exactly ONE project (host / composition root):
<PropertyGroup>
<SpaceGenerateRootAggregator>true</SpaceGenerateRootAggregator>
</PropertyGroup>And ensure the root aggregator project references BOTH packages:
<ItemGroup>
<PackageReference Include="Space.Abstraction" Version="X.Y.Z" />
<PackageReference Include="Space.DependencyInjection" Version="X.Y.Z" />
</ItemGroup>All other handler libraries should either omit the property or set it to false and reference only Space.Abstraction:
<PropertyGroup>
<SpaceGenerateRootAggregator>false</SpaceGenerateRootAggregator>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Space.Abstraction" Version="X.Y.Z" />
</ItemGroup>See MultiProjectSetup.md for full rationale and migration guidance.
A handler is a method annotated with [Handle] taking HandlerContext<TRequest> and returning ValueTask<TResponse>.
public sealed record UserLoginRequest(string UserName);
public sealed record UserLoginResponse(bool Success);
public class UserHandlers
{
[Handle]
public async ValueTask<UserLoginResponse> Login(HandlerContext<UserLoginRequest> ctx)
{
var userService = ctx.ServiceProvider.GetService<UserService>();
var loginModel = ctx.Request;
bool userExists = true; // demo
return new UserLoginResponse(userExists);
}
}
var loginResponse = await space.Send<UserLoginResponse>(new UserLoginRequest("sc"));You may optionally implement helper interfaces (e.g. IHandler<TReq,TRes>) for type safety hints, but the [Handle] attribute is mandatory for handler discovery. The interface alone does not register the handler.
Provide multiple handlers for same Request/Response:
public class PricingHandlers
{
[Handle(Name = "Default")] public ValueTask<PriceResult> Get(HandlerContext<PriceQuery> ctx) => ...;
[Handle(Name = "Discounted")] public ValueTask<PriceResult> GetDiscount(HandlerContext<PriceQuery> ctx) => ...;
}
var discounted = await space.Send<PriceResult>(new PriceQuery(...), name: "Discounted");Pipelines are middleware around handlers.
public class UserPipelines
{
[Pipeline(Order = 1)]
public async ValueTask<UserLoginResponse> PipelineHandler(PipelineContext<UserLoginRequest> ctx, PipelineDelegate<UserLoginRequest, UserLoginResponse> next)
{
var response = await next(ctx);
return response;
}
}Lower Order executes earlier. System Modules use very low (negative) orders to run before user pipelines.
Notification handlers react to published events.
services.AddSpace(opt =>
{
opt.NotificationDispatchType = NotificationDispatchType.Parallel; // or Sequential
});
public sealed record UserLoggedInSuccessfully(string UserName);
public class UserHandlersNotifications
{
[Notification]
public ValueTask LoginNotificationHandlerForFileLogging(NotificationContext<UserLoggedInSuccessfully> ctx)
{ return ValueTask.CompletedTask; }
[Notification]
public ValueTask LoginNotificationHandlerForDbLogging(NotificationContext<UserLoggedInSuccessfully> ctx)
{ return ValueTask.CompletedTask; }
}
await space.Publish(new UserLoggedInSuccessfully("sc"));Modules are system-provided pipeline layers triggered by decorating a handler method with a module attribute.
public sealed record UserDetail(string FullName, string EmailAddress);
public class UserHandlers
{
[Handle]
[CacheModule(Duration = 60)]
public ValueTask<List<UserDetail>> GetUserDetails(HandlerContext<int> ctx)
{
var userService = ctx.ServiceProvider.GetService<UserService>();
return ValueTask.FromResult(new List<UserDetail>());
}
}
services.AddSpaceInMemoryCache();public sealed class RedisCacheModuleProvider : ICacheModuleProvider
{
private readonly ConcurrentDictionary<string, object> storage = new();
public string GetKey<TRequest>(TRequest request) => request?.ToString();
public ValueTask Store<TResponse>(string key, TResponse resp, CacheModuleConfig cfg) { storage[key] = resp; return default; }
public bool TryGet<TResponse>(string key, out TResponse resp, CacheModuleConfig cfg)
{ resp = default; if(!storage.TryGetValue(key, out var o)) return false; resp = (TResponse)o; return true; }
}Register it instead of the default in-memory provider.
When adding a new module:
- Create an attribute implementing
ISpaceModuleAttribute. - Create a
SpaceModulesubclass decorated with[SpaceModule(ModuleAttributeType = typeof(YourModuleAttribute))]. - Provide a config model implementing
IModuleConfig. - Provide a provider implementing
IModuleProvider. - Offer an extension method to register provider/config.
- Pick a
PipelineOrderrelative to other system modules.
ISpace&SpaceRegistrycircular dependency (first handler may see null lazy ISpace).- Module scoping for named handlers still being refined.
- Attribute-level provider specification.
- Global default module configuration.
- Options pattern for module configs.
- Integrate
ILoggerFactoryfor built-in logging.
Space provides a lean, attribute & source-generator driven mediator approach with extensible modules and zero runtime reflection registration cost.
Uses GitHub Releases for publish.
- Stable: normal release (
vX.Y.Z) - Preview: pre-release (
vX.Y.Z-preview) - Validation: branch/PR build
See MultiProjectSetup.md.
MIT