-
Notifications
You must be signed in to change notification settings - Fork 0
Home
Interlink is a lightweight mediator library for .NET. It helps you decouple application code using request/response and notification patterns, with optional pipelines, validation, logging, ASP.NET Core integration, and a Roslyn analyzer.
- Package: Interlink
- Repository: manusoft/Interlink-cqrs-library
- License: MIT
- Features
- Packages
- Installation
- Setup
- Request / response
- Commands with no return value (Unit)
- Notifications
- Pipeline behaviors
- Pre and post processors
- Exceptions
- Interlink.Extensions.Logging
- Interlink.Extensions.Validation
- Interlink.AspNetCore
- Interlink.Analyzers
- ASP.NET Core controller examples
- API reference (overview)
- Versioning and releases
- Troubleshooting
- Request/response mediation (
ISender) - Publish/subscribe notifications (
IPublisher) - Pipeline behaviors (ordered)
- Pre/post processors
- Assembly scanning and DI registration (
AddInterlink) - Optional custom service factory
- Void-style commands via
IRequest+Unit HandlerNotFoundException- Optional packages: logging, FluentValidation, ASP.NET Core, analyzer
- Compatible with .NET Standard 2.0+ through modern .NET (see package TFMs)
| Package | Purpose |
|---|---|
| Interlink | Core mediator |
| Interlink.Extensions.Logging | Built-in logging behavior |
| Interlink.Extensions.Validation | FluentValidation pipeline behavior |
| Interlink.AspNetCore | Exception filter → ProblemDetails |
| Interlink.Analyzers | Roslyn analyzer (ILINK001 missing handler) |
Each package has its own README on NuGet. The core package ships the full usage docs.
dotnet add package InterlinkOptional:
dotnet add package Interlink.Extensions.Logging
dotnet add package Interlink.Extensions.Validation
dotnet add package Interlink.AspNetCore
dotnet add package Interlink.Analyzersbuilder.Services.AddInterlink();
// or scan a specific assembly:
builder.Services.AddInterlink(typeof(MyHandler).Assembly);With options:
builder.Services.AddInterlink(options =>
{
options.AddBehavior(typeof(MyBehavior<,>), order: 0);
options.ServiceFactory = type => /* optional custom resolver */;
}, typeof(MyHandler).Assembly);With extensions:
builder.Services.AddInterlink(typeof(MyHandler).Assembly);
builder.Services.AddInterlinkLogging();
builder.Services.AddInterlinkValidation(typeof(MyValidator).Assembly);
builder.Services.AddInterlinkAspNetCore();using Interlink;
using Interlink.Contracts;
public sealed record GetAllPetsQuery : IRequest<List<string>>;
public sealed class GetAllPetsHandler : IRequestHandler<GetAllPetsQuery, List<string>>
{
public Task<List<string>> Handle(GetAllPetsQuery request, CancellationToken cancellationToken)
{
var pets = new List<string> { "Dog", "Cat", "Fish" };
return Task.FromResult(pets);
}
}var pets = await sender.Send(new GetAllPetsQuery(), cancellationToken);If no handler is registered, Send throws HandlerNotFoundException.
Interlink does not use a real C# void return on handlers (the pipeline is generic over TResponse). Instead it uses Unit — an empty struct meaning “no data”.
// Command marker (same as IRequest<Unit>)
public interface IRequest : IRequest<Unit> { }
// Optional short handler interface
public interface IRequestHandler<in TRequest> : IRequestHandler<TRequest, Unit>
where TRequest : IRequest { }
public readonly struct Unit
{
public static readonly Unit Value = default;
public static Task<Unit> Task => Task.FromResult(Value);
}public sealed record CreatePetCommand(string Name) : IRequest;
public sealed class CreatePetHandler : IRequestHandler<CreatePetCommand>
{
public Task<Unit> Handle(CreatePetCommand request, CancellationToken cancellationToken)
{
// save...
return Unit.Task; // or Task.FromResult(Unit.Value)
}
}
// Caller — no response value to use
await sender.Send(new CreatePetCommand("Rex"));Unit.Value is an empty struct. It has no payload; it only satisfies Task<TResponse>.
public sealed class UserCreated(string userName) : INotification
{
public string UserName { get; } = userName;
}
public sealed class SendWelcomeEmail : INotificationHandler<UserCreated>
{
public Task Handle(UserCreated notification, CancellationToken cancellationToken)
{
// send email...
return Task.CompletedTask;
}
}
await publisher.Publish(new UserCreated("ada"));Multiple handlers can handle the same notification type. They are invoked sequentially.
Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken);public sealed class TimingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
var sw = Stopwatch.StartNew();
var response = await next(cancellationToken);
sw.Stop();
// log sw.ElapsedMilliseconds
return response;
}
}Lower order runs first (outermost).
[PipelineOrder(1)]
public sealed class FirstBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
=> next(cancellationToken);
}Or when registering:
options.AddBehavior(typeof(FirstBehavior<,>), order: 1);
options.AddBehavior(typeof(SecondBehavior<,>), order: 2);Behaviors without an order are treated as int.MaxValue.
- Pre-processors run before the pipeline.
- Post-processors run after a successful pipeline.
public sealed class MyPre : IRequestPreProcessor<GetAllPetsQuery>
{
public Task Process(GetAllPetsQuery request, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
public sealed class MyPost : IRequestPostProcessor<GetAllPetsQuery, List<string>>
{
public Task Process(GetAllPetsQuery request, List<string> response, CancellationToken cancellationToken)
=> Task.CompletedTask;
}Discovered automatically by AddInterlink assembly scanning.
Thrown when no handler can be resolved for a request type.
public class HandlerNotFoundException : InvalidOperationException
{
public Type RequestType { get; }
public Type? HandlerType { get; }
}dotnet add package Interlink.Extensions.Loggingbuilder.Services.AddInterlinkLogging();Registers LoggingBehavior<TRequest, TResponse> which logs start, success + duration, and errors.
dotnet add package Interlink.Extensions.Validationbuilder.Services.AddInterlinkValidation();
// or scan validators:
builder.Services.AddInterlinkValidation(typeof(CreatePetValidator).Assembly);public sealed class CreatePetValidator : AbstractValidator<CreatePetCommand>
{
public CreatePetValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
}
}On failure, throws FluentValidation.ValidationException before the handler runs.
dotnet add package Interlink.AspNetCorebuilder.Services.AddControllers();
builder.Services.AddInterlinkAspNetCore();Registers InterlinkExceptionFilter:
| Exception | HTTP | Response |
|---|---|---|
HandlerNotFoundException |
404 | ProblemDetails |
FluentValidation.ValidationException |
400 | ValidationProblemDetails |
FluentValidation support is detected at runtime (no hard dependency from the ASP.NET Core package).
Roslyn analyzer that reports ILINK001 when a type implements IRequest<TResponse> (or IRequest) but no matching IRequestHandler<,> exists in the compilation.
<PackageReference Include="Interlink" Version="x.y.z" />
<PackageReference Include="Interlink.Analyzers" Version="x.y.z">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>You need both packages: the analyzer resolves IRequest<> / IRequestHandler<,> from the Interlink reference. Without Interlink, the analyzer loads but emits nothing.
// Warning ILINK001
public sealed record GetSomething : IRequest<string>;
// Adding this clears the warning
public sealed class GetSomethingHandler : IRequestHandler<GetSomething, string>
{
public Task<string> Handle(GetSomething request, CancellationToken cancellationToken)
=> Task.FromResult("ok");
}ILINK001 is a compilation-end diagnostic:
| Filter | Typical behavior |
|---|---|
| Build Only | Shows after Build/Rebuild |
| IntelliSense Only | Usually does not show |
| Build + IntelliSense | May show after build once the analyzer is loaded correctly |
dotnet build always prints the warning when it applies.
<ProjectReference Include="..\..\src\Interlink.Analyzers\Interlink.Analyzers.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />Do not use <Reference HintPath="...Analyzers.dll"> — that is not an analyzer registration.
Correct direct DLL registration:
<Analyzer Include="path\to\Interlink.Analyzers.dll" />A working analyzer package should contain at least:
analyzers/dotnet/cs/Interlink.Analyzers.dll
build/Interlink.Analyzers.props
Example props (forces the same mechanism as <Analyzer Include="...">):
<Project>
<ItemGroup>
<Analyzer Include="$(MSBuildThisFileDirectory)..\analyzers\dotnet\cs\Interlink.Analyzers.dll"
Condition="Exists('$(MSBuildThisFileDirectory)..\analyzers\dotnet\cs\Interlink.Analyzers.dll')" />
</ItemGroup>
</Project>dotnet pack src/Interlink.Analyzers -c Release -o ./nupkgsnuget.config:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="local" value="./nupkgs" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>Restore/build the consumer. Bump the version or clear the NuGet cache when repacking the same version:
dotnet nuget locals all --clearMSBuild loads Directory.Build.props by walking up from each .csproj, not from the .sln path.
Recommended layout:
MySolution/
├── Directory.Build.props ← repo root (applies to all projects below)
├── MySolution.sln
├── src/
│ ├── Modules/
│ │ ├── Catalog/
│ │ │ ├── Catalog.Application/ ← requests + handlers
│ │ │ └── ...
│ │ └── Orders/
│ └── Hosts/
│ └── MyApp.Api/
└── tests/
Root Directory.Build.props — all projects:
<Project>
<ItemGroup Condition="'$(MSBuildProjectExtension)' == '.csproj'">
<PackageReference Include="Interlink.Analyzers" Version="x.y.z">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>Opt-in per Application module:
<!-- Directory.Build.props -->
<Project>
<PropertyGroup>
<EnableInterlinkAnalyzers Condition="'$(EnableInterlinkAnalyzers)' == ''">false</EnableInterlinkAnalyzers>
</PropertyGroup>
<ItemGroup Condition="'$(EnableInterlinkAnalyzers)' == 'true'">
<PackageReference Include="Interlink.Analyzers" Version="x.y.z">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project><!-- Catalog.Application.csproj -->
<PropertyGroup>
<EnableInterlinkAnalyzers>true</EnableInterlinkAnalyzers>
</PropertyGroup>Or enable for a whole module folder via src/Modules/Catalog/Directory.Build.props.
| Project kind | Analyzer useful? |
|---|---|
| Application (requests/handlers) | Yes |
| API host (if it declares requests) | Yes |
| Infrastructure only | Optional |
| Tests | Optional |
Core Interlink package: add only where you call ISender / define handlers.
[ApiController]
[Route("api/[controller]")]
public class PetsController(ISender sender) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<List<string>>> GetAll(CancellationToken ct)
{
var pets = await sender.Send(new GetAllPetsQuery(), ct);
return Ok(pets);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreatePetRequest body, CancellationToken ct)
{
await sender.Send(new CreatePetCommand(body.Name), ct);
return Created();
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id, CancellationToken ct)
{
await sender.Send(new DeletePetCommand(id), ct);
return NoContent();
}
}
public sealed record CreatePetRequest(string Name);| HTTP | Interlink | Typical result |
|---|---|---|
| GET | IRequest<T> |
Ok(result) |
| POST / PUT / DELETE | IRequest |
Created() / NoContent()
|
// Contracts
IRequest<TResponse>
IRequest // : IRequest<Unit>
INotification
Unit
// Handlers
IRequestHandler<TRequest, TResponse>
IRequestHandler<TRequest> // : IRequestHandler<TRequest, Unit>
INotificationHandler<TNotification>
// Mediator
ISender.Send<TResponse>(IRequest<TResponse>, CancellationToken)
ISender.Send(IRequest, CancellationToken)
IPublisher.Publish<TNotification>(TNotification, CancellationToken)
// Pipeline
RequestHandlerDelegate<TResponse>
IPipelineBehavior<TRequest, TResponse>
PipelineOrderAttribute
// Processors
IRequestPreProcessor<TRequest>
IRequestPostProcessor<TRequest, TResponse>
// DI
services.AddInterlink([configure], [assemblies])- NuGet package versions are immutable. Fixes require a new version (e.g.
1.5.1→1.5.2). - Packages in the suite may version independently. Only bump packages you changed.
- Git tags (e.g.
v1.5.2) are repo-level and can only be used once. They do not have to match every package version. - Prefer tag-based or manual
dotnet nuget pushwith--skip-duplicate. - Additive APIs (such as
Unit/IRequest) are non-breaking for existingIRequest<T>users.
- Confirm the nupkg contains:
analyzers/dotnet/cs/Interlink.Analyzers.dllbuild/Interlink.Analyzers.props
- Consumer references Interlink + Interlink.Analyzers with
IncludeAssetsincludinganalyzers. - Clear caches:
dotnet build-server shutdownanddotnet nuget locals all --clear. - Verify
obj/project.assets.jsonlists the analyzer DLL. - Prefer the props-based pack layout (see Interlink.Analyzers).
Use <Analyzer Include="..." /> or a ProjectReference with OutputItemType="Analyzer".
Use Error List filter Build Only (or rebuild and try Build + IntelliSense). Compilation-end rules are not pure IntelliSense diagnostics.
- Handler class must be concrete and implement the correct closed generic interface.
- Assembly must be passed to
AddInterlink(or be the calling assembly when scanning defaults apply). - Service lifetime is scoped by default registration — ensure a scope exists (ASP.NET Core request scope is fine).
Set [PipelineOrder(n)] or AddBehavior(typeof(...), order: n). Lower runs first.
Possible future directions (not all shipped):
- Streaming requests (
IAsyncEnumerable) - Notification publish strategies (parallel / stop-on-error)
- Notification pipeline behaviors
- OpenTelemetry / metrics package
- Stronger analyzer rules (duplicate handlers, etc.)
- Further AOT-friendly registration
This wiki reflects Interlink usage patterns and packaging guidance collected for maintainers and consumers of the library.