Usage
Installation
Capsule is shipped as a set of Nuget packages:
- Core functionality such as invocation loop and synchronizer implementations. Also contains dependency injection registration extensions.
- C# source generator that generates interfaces and encapsulation boilerplate based on attributes.
- Test support library.
To get started, install the Capsule.Core
and Capsule.Generator
packages. Then, register the necessary dependencies with the AddCapsuleHost()
DI extension method.
Implementing Capsules
The capsule implementation is the class that needs to be made thread-safe. You create it, but do not add any synchronization primitives yourself.
By adding he [Capsule]
attribute to that class, the Capsule generator is instructed to add the following parts:
- The capsule interface is generated or referenced.
- A static extension class is generated, containing the hull and an
Encapsulate()
extension method. This is what turns your capsule implementation into a thread-safe object.
The following subsections provide further details on these two parts.
Interface Generation
Capsule generator is able to generate a capsule interface for each capsule implementation.
By default, Capsule considers the list of implemented interfaces on the Capsule implementation:
- If there is exactly one interface that is not
[CapsuleIgnore]
attributed, that interface is used and no additional interface is generated. - Otherwise, an interface with the same name as the implementation, but prefixed with an "I", will be generated.
Interface generation can be customized through CapsuleAttribute.InterfaceGeneration
:
Enable
: Generate an interface.Disable
: Do not generate an interface.Auto
: The default behavior described above applies.
The interface name can be customized through CapsuleAttribute.InterfaceName
:
- If this property is specified and non-null, that interface name will be used.
- Otherwise, the default behavior applies.
Note
If you bring your own interface, you'll need to ensure it matches the exposed methods and properties. Otherwise, the build will fail.
Exposing Methods & Properties
In addition to the [Capule]
attribute on the implementation class, you'll need to add the [Expose]
attribute to all methods and properties that you want to make accessible through the capsule interface. The following restrictions apply:
- The exposed methods must have a return type that is compatible with the chosen synchronization mode (see below).
- Properties are restricted to immutable getters and
PassThrough
synchronization mode must be used.
The synchronization mode defaults to AwaitCompletion
. Different modes can be specified through the ExposeAttribute.Synchronization
property.
Synchronization
Capsule can expose methods in different ways to match different use cases. The synchronization mode controls if/how invocations are awaited and whether thread-safety is guaranteed. Capsule defaults to "await completion" synchronization. This is the only mode that is thread-safe and makes encapsulated objects behave in an object-oriented way from the caller's perspective.
Synchronization Modes
The following synchronization modes are supported:
Synchronization Mode | Completes when | Sync / Async | Behavior |
---|---|---|---|
AwaitCompletion (default) |
implementation completes | async | |
AwaitEnqueueing |
invocation has been enqueued | sync or async (details see below) | |
AwaitReception |
invocation has been dequeued | async | |
PassThrough |
implementation completes | sync or async | |
AwaitCompletionOrPassThroughIfQueueClosed |
implementation completes | async |
Invocation Owner
Invocations are either caller-owned or loop-owned . Caller-owned invocations behave the same way as you'd expect any invocation on an object would behave: Results and exceptions are passed back to the caller.
Loop-owned invocations enable a "fire and forget" communication between objects. The caller can continue before the invocation has been executed, but as a result won't receive return values or exceptions. AwaitEnqueueing
is the recommended synchronization mode for "fire and forget" style communication.
Sync / Async
Caller-owned invocations must be async. This is due to the fact that the caller will need to be suspended until the invocation can be dequeued/executed.
A special case regarding sync/async is AwaitEnqueueing
synchronization mode. This mode only needs to enqueue the invocation, which is a synchronous operation. Thus, such methods will be exposed as synchronous on the interface, unless the interface was resolved from the list of implemented interfaces on the Capsule (see interface generation for details).
Thread-Safety
AwaitCompletion
, AwaitEnqueueing
and AwaitReception
modes provide thread safety , PassThrough
does not .
PassThrough
is not thread-safe because it doesn't enqueue the invocation, but executes it directly. It is only safe for immutable operations and is typically used with get-only properties that return an immutable field of the capsule implementation (e.g. an ID).
AwaitCompletionOrPassThroughIfQueueClosed
is a special case synchronization mode that behaves as AwaitCompletion
, but falls back to PassThrough
if the invocation queue has been closed. This is useful for DisposeAsync()
operations, where DisposeAsync()
may be called by DI infrastructure just before shutdown. At that point, the invocation queue has already been terminated and AwaitCompletion
would throw a CapsuleInvocationException
. When it falls back to PassThrough
, thread-safety is not guaranteed.
Run to Completion Semantics
All synchronization modes except PassThrough
will execute one invocation at a time (aka run-to-completion scheduling). Make sure that your implementation completes quickly, as a blocked or delayed implementation will delay all pending invocations. This means that it is generally not a good idea to use Task.Delay()
in capsule implementations (use a timer instead).
Exception Handling
Exceptions that throw out of capsule implementations are routed the same way as return values are, so caller-owned invocations will throw exceptions back to the caller.
Loop-owned synchronization modes throw exceptions back into the invocation loop, because there is no caller that waits for the invocation to complete. In a default Capsule setup, such exceptions will abort the invocation loop and consequently the hosted service that runs it. This in turn will terminate the application.
This behavior is consistent with how .NET background services treat uncaught exceptions in .NET 6 and newer. The rationale for this is that uncaught exceptions must not go unnoticed. Consequently, you'll need to ensure that expected exceptions are caught and handled in capsule implementations.
Optionally, the failure mode can be changed to Continue
through CapsuleOptions.FailureMode
. With this failure mode, uncaught loop-owned exceptions will be logged and then ignored.
Cancellation
Capsule treats cancellation tokens on exposed methods as any other parameter, so the tokens flow through to the capsule implementation unmodified and cancellation can be realized in the capsule implementation.
In case the cancellation leads to an OperationCanceledException
being thrown out of the capsule implementation, the same behavior as outlined in excpetion handling applies, so you'll probably want to catch these exceptions.
In any case, cancellation does not remove the invocation from the queue or otherwise change synchronization behavior.
Disposable Capsules
In case your capsule implementation manages disposable resources, you may need to make the capsule disposable, too. Depending on your usage patterns, there are two possibilities to achieve this:
The recommended approach is to implement IAsyncDisposable
. Like this, you'll be able to treat the dispose operation the same way as other methods. The DisposeAsync()
method can be exposed with [Expose]
, invocations will then flow through the invocation queue as expected. In case the capsules are registered with DI, you may want to use AwaitCompletionOrPassThroughIfQueueClosed
synchronization mode to avoid exceptions on app shutdown (see synchronization modes for details).
Another possibility is to implement IDisposable
and expose the Dispose()
method with synchronization mode PassThrough
. With this approach, Dispose()
will be called synchronously, thread-safety is not guaranteed. However, for cases where Dispose()
is only called by DI on app shutdown, this may be a suitable solution for you, because Capsule hosting will have terminated and processed all invocation queues by the time DI calls Dispose()
.
Opt-In Features
Async Initializer
Capsule implementations that need to perform asynchronous initialization can implement the CapsuleFeature.IInitializer
interface, which defines a Task InitializeAsync()
method.
Capsule will enqueue a single call to this method when a capsule is instantiated through Encapsulate()
, so the invocation is guaranteed to be the first one to end up in the invocation queue and will run as soon as the invocation loop is started.
Timers
Run-to-completion semantics dictate that individual runs should complete quickly. In other words, awaiting large delays or even sleeping is discouraged and will break responsiveness of the capsule, potentially leading to invocation queue exhaustion.
To work around this limitation, Capsule provides a timer service. The service can be used by making capsules implement CapsuleFeature.ITimers
. When implemented, Capsule will inject an ITimerService
during encapsulation.
Note
The timer service will not be available when the constructor of the capsule implementation runs as this would create a chicken-and-egg problem. If you need to start timers when the capsule is instantiated, use an async initializer.
You can then use the timer service to register callbacks with a timeout through StartSingleShot()
. When the timeout expires, the callback will be enqueued as just another invocation. Timers thus adheres to the thread-safety guarantees of the capsule.
Pending timers can also be cancelled. Either cancel a single timer through its TimerReference
, or cancel all timers through ITimerService.CancelAll()
.
For cases where only a single timer is needed, but that timer may be restarted multiple times, consider passing a discriminator
to StartSingleShot()
. The timer service will then ensure that at most one timer with the same discriminator exists, previous ones will be cancelled.
Testing
One of the advantages of Capsule is that implementations remain free of synchronization code like locks or similar. Consequently, implementations can simply be unit tested by omitting the encapsulation in tests.
Generally, the following approaches work best:
- Use the unencapsulated implementation in unit tests.
- Use the whole Capsule in integration tests (integration as in "tests the combination of several classes together").
Test Support Library
A test support library called is available. It provides fake implementations of Capsule infrastructure that simplifies certain test scenarios:
FakeTimerService
: AnITimerService
that can be injected into capsules that use Timers. Timer firing can be controlled from test code to avoid delays in tests.FakeSynchronizer
: AnICapsuleSynchronizer
that just executes invocations directly.