Skip to content
Manojbabu edited this page Aug 2, 2026 · 1 revision

Interlink Wiki

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.


Table of contents

  1. Features
  2. Packages
  3. Installation
  4. Setup
  5. Request / response
  6. Commands with no return value (Unit)
  7. Notifications
  8. Pipeline behaviors
  9. Pre and post processors
  10. Exceptions
  11. Interlink.Extensions.Logging
  12. Interlink.Extensions.Validation
  13. Interlink.AspNetCore
  14. Interlink.Analyzers
  15. ASP.NET Core controller examples
  16. API reference (overview)
  17. Versioning and releases
  18. Troubleshooting

Features

  • 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)

Packages

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.


Installation

dotnet add package Interlink

Optional:

dotnet add package Interlink.Extensions.Logging
dotnet add package Interlink.Extensions.Validation
dotnet add package Interlink.AspNetCore
dotnet add package Interlink.Analyzers

Setup

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

Request / response

Define a query and handler

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

Send

var pets = await sender.Send(new GetAllPetsQuery(), cancellationToken);

If no handler is registered, Send throws HandlerNotFoundException.


Commands with no return value (Unit)

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”.

Types

// 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);
}

Example

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


Notifications

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.


Pipeline behaviors

Signature

Task<TResponse> Handle(
    TRequest request,
    RequestHandlerDelegate<TResponse> next,
    CancellationToken cancellationToken);

Example

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;
    }
}

Ordering

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 and post processors

  • 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.


Exceptions

HandlerNotFoundException

Thrown when no handler can be resolved for a request type.

public class HandlerNotFoundException : InvalidOperationException
{
    public Type RequestType { get; }
    public Type? HandlerType { get; }
}

Interlink.Extensions.Logging

dotnet add package Interlink.Extensions.Logging
builder.Services.AddInterlinkLogging();

Registers LoggingBehavior<TRequest, TResponse> which logs start, success + duration, and errors.


Interlink.Extensions.Validation

dotnet add package Interlink.Extensions.Validation
builder.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.


Interlink.AspNetCore

dotnet add package Interlink.AspNetCore
builder.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).


Interlink.Analyzers

Roslyn analyzer that reports ILINK001 when a type implements IRequest<TResponse> (or IRequest) but no matching IRequestHandler<,> exists in the compilation.

Install on one project

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

Example

// 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");
}

Visual Studio Error List

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.

Local development reference

<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" />

Package layout (maintainers)

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>

Testing a packed analyzer locally

dotnet pack src/Interlink.Analyzers -c Release -o ./nupkgs

nuget.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 --clear

Multi-project / modular monolith

MSBuild 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.


ASP.NET Core controller examples

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

API reference (overview)

// 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])

Versioning and releases

  • NuGet package versions are immutable. Fixes require a new version (e.g. 1.5.11.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 push with --skip-duplicate.
  • Additive APIs (such as Unit / IRequest) are non-breaking for existing IRequest<T> users.

Troubleshooting

Analyzer works as ProjectReference but not as PackageReference

  1. Confirm the nupkg contains:
    • analyzers/dotnet/cs/Interlink.Analyzers.dll
    • build/Interlink.Analyzers.props
  2. Consumer references Interlink + Interlink.Analyzers with IncludeAssets including analyzers.
  3. Clear caches: dotnet build-server shutdown and dotnet nuget locals all --clear.
  4. Verify obj/project.assets.json lists the analyzer DLL.
  5. Prefer the props-based pack layout (see Interlink.Analyzers).

<Reference HintPath="...Analyzers.dll"> does nothing

Use <Analyzer Include="..." /> or a ProjectReference with OutputItemType="Analyzer".

Warning only in build log, not Error List

Use Error List filter Build Only (or rebuild and try Build + IntelliSense). Compilation-end rules are not pure IntelliSense diagnostics.

Handler not found at runtime

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

Pipeline behavior order unexpected

Set [PipelineOrder(n)] or AddBehavior(typeof(...), order: n). Lower runs first.


Related roadmap ideas

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.