Skip to content

Onyrew/EventAccess

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

EventAccess

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();

Why this exists

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.

Core concepts

  • IHandler — the hub. Owns all channels. Create, Register, Unregister, Exists, and Dispose (releases every channel).

  • IToken<T> — returned by Create<T>. Owns one channel's lifetime: Invoke, Clear (drop handlers, keep the channel), Release (remove the channel), and Dispose (== Release). Also is an IAccess<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

Semantics at a glance

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.

⚠️ Type safety — read this (by design)

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 runtime

You 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> from Create<T> and subscribing through it, rather than re-specifying <T> at scattered Register<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.

Handler signature & async

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. Register de-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.

Building & testing

dotnet build
dotnet test EventAccess.Tests/EventAccess.Tests.csproj

The 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).

Status & caveats

  • Targets net10.0.
  • All public types are currently declared internal — to consume this from another assembly you must make them public (see Roadmap).
  • Naming uses a project-specific convention (_args_type, _Intf/_Handler folders, leading underscores) that differs from typical .NET style.

Making it better

Highest-leverage improvements, roughly in order:

  1. Async dispatch — an InvokeAsync that awaits Func<object, T?, Task> handlers. This is the single biggest usability gap for modern C#.
  2. Make the surface public — nobody outside the assembly can use it while everything is internal. Pick a real public API and lock it down.
  3. 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 an int channel, not string") instead of a raw InvalidCastException.
  4. Ship as a NuGet package with an idiomatic naming pass (or a documented rationale for the current convention).
  5. Error isolation in Invoke — decide whether one throwing handler should abort the rest of the dispatch (today it does). Consider aggregating.

License

Copyright (c) 2023 Onyrew <onyrew@gmail.com>

About

No description, website, or topics provided.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages