A small, thread-safe, string-keyed publish/subscribe event hub for .NET, with a fluent API and token-based subscription lifetime.
Unlike C#'s built-in event keyword — where channels are compile-time members —
EventAccess lets you create event channels by name at runtime, hand out a
disposable token that owns the whole channel's teardown, and register/unregister
handlers with a fluent, chainable API.
using EventAccess;
using IHandler hub = new Handler();
// Create a channel named "user-login" carrying a string payload.
IToken<string>? login = hub.Create<string>("user-login");
// Subscribe (fluent, chainable). Handlers are Action<object sender, T? args>.
login!
.Register((sender, user) => Console.WriteLine($"Welcome {user}"))
.Register((sender, user) => Audit(user));
// Publish. Every handler runs; a snapshot is taken so handlers may
// register/unregister during dispatch without corrupting the list.
login.Invoke(this, "alice");
// Disposing the token releases the entire channel.
login.Dispose(); // or login.Release();Reach for EventAccess when you want all of these at once — the combination is what the built-ins don't give you:
| You need… | Built-in gap | EventAccess |
|---|---|---|
| Channels named at runtime (topics as data) | event needs compile-time members |
Create<T>(string key) |
| Deterministic teardown | WeakReferenceMessenger unsubscribes whenever the GC runs |
token.Dispose() releases now |
| One handle that owns a subscription's lifetime | Manual -= bookkeeping |
IToken<T> / IAccess<T> |
| Thread-safe register/invoke | event invocation lists aren't safe under concurrent mutation |
snapshot-under-lock dispatch |
If you only need typed, compile-time events, use the event keyword. If you need
async pipelines or request/response, use MediatR or IObservable<T> (Rx). This
library targets the runtime-keyed, synchronous, deterministic-lifetime niche.
-
IHandler— the hub. Owns all channels.Create,Register,Unregister,Exists, andDispose(releases every channel). -
IToken<T>— returned byCreate<T>. Owns one channel's lifetime:Invoke,Clear(drop handlers, keep the channel),Release(remove the channel), andDispose(==Release). Also is anIAccess<T>. -
IAccess<T>— the subscribe/unsubscribe surface:Register,Unregister, and the+/-operators as sugar:IAccess<int> a = token.Access; a += (s, x) => Console.WriteLine(x); // Register a -= someHandler; // Unregister
| Call | Effect | Missing key? |
|---|---|---|
Create<T>(key) |
Adds channel, returns token | returns null if key exists |
Register<T>(key, h) |
Adds handler (idempotent per delegate) | no-op |
Unregister<T>(key, h) |
Removes handler | no-op |
Invoke(sender, args) |
Snapshots handlers under lock, then calls each | no-op |
Clear() |
Removes all handlers, keeps channel | no-op |
Release() / token Dispose() |
Removes the channel entirely | no-op |
Register is idempotent: registering the same delegate instance twice fires it
once. Dispatch is snapshot-based: Invoke copies the handler list under the
lock and calls handlers outside the lock, so a handler may safely register or
unregister during dispatch.
A channel's payload type is not part of its key. The hub stores channels as
object keyed by string; the <T> on each call is an unchecked cast at the
access point. This is a deliberate trade-off to keep the API keyed purely by
string.
Consequence: accessing a key with a different type than it was created with is
a programming error that surfaces as an InvalidCastException at runtime — the
compiler cannot catch it:
hub.Create<int>("evt");
hub.Register<string>("evt", (s, x) => { }); // 💥 InvalidCastException at runtimeYou are responsible for using one consistent T per key. Recommended practice:
- Centralize key + type in one place (constants, or a typed wrapper you own) so a
key is never used with two different
Ts. - Prefer holding onto the
IToken<T>fromCreate<T>and subscribing through it, rather than re-specifying<T>at scatteredRegister<T>(key, …)call sites.
This behavior is intentional and is covered by a regression test
(MismatchedArgsType_OnSameKey_ThrowsInvalidCast_ByDesign) so it won't change
silently.
Handlers are Action<object sender, T? args> — classic .NET event shape, and
synchronous. There is no built-in async dispatch; if a handler needs to do async
work, it must marshal that itself (Invoke will not await it). See
Roadmap for the async story.
Gotcha — idempotent register + lambda caching.
Registerde-dupes by delegate instance. The C# compiler caches a lambda that captures nothing (or only captures fields), so passing "the same" lambda in a loop registers it once. If you need N distinct subscriptions, give each a distinct captured variable or a named method.
dotnet build
dotnet test EventAccess.Tests/EventAccess.Tests.csprojThe test project uses xUnit and reaches internal types via InternalsVisibleTo.
Coverage includes the fluent API, idempotent register, clear/release/dispose, the
+/- operators, the documented type-safety behavior, and concurrency storm
tests (register/unregister and clear racing against Invoke).
- Targets
net10.0. - All public types are currently declared
internal— to consume this from another assembly you must make thempublic(see Roadmap). - Naming uses a project-specific convention (
_args_type,_Intf/_Handlerfolders, leading underscores) that differs from typical .NET style.
Highest-leverage improvements, roughly in order:
- Async dispatch — an
InvokeAsyncthat awaitsFunc<object, T?, Task>handlers. This is the single biggest usability gap for modern C#. - Make the surface
public— nobody outside the assembly can use it while everything isinternal. Pick a real public API and lock it down. - Tighten the type-safety story — either (a) key by
(string, Type)so a mismatch is a silent miss instead of a crash, or (b) throw a descriptive exception ("key 'evt' is anintchannel, notstring") instead of a rawInvalidCastException. - Ship as a NuGet package with an idiomatic naming pass (or a documented rationale for the current convention).
- Error isolation in
Invoke— decide whether one throwing handler should abort the rest of the dispatch (today it does). Consider aggregating.
Copyright (c) 2023 Onyrew <onyrew@gmail.com>