LinKit.Core 3.2.1

dotnet add package LinKit.Core --version 3.2.1
                    
NuGet\Install-Package LinKit.Core -Version 3.2.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="LinKit.Core" Version="3.2.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="LinKit.Core" Version="3.2.1" />
                    
Directory.Packages.props
<PackageReference Include="LinKit.Core" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add LinKit.Core --version 3.2.1
                    
#r "nuget: LinKit.Core, 3.2.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package LinKit.Core@3.2.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=LinKit.Core&version=3.2.1
                    
Install as a Cake Addin
#tool nuget:?package=LinKit.Core&version=3.2.1
                    
Install as a Cake Tool

LinKit.Core

NuGet Version NuGet Downloads

LinKit.Core is the runtime and source-generator package for CQRS, dependency injection, Minimal API endpoints, background jobs, mapping, and messaging contracts. It generates static application wiring for supported patterns. Native AOT and trimming support depend on the exact API path and application dependencies; see AOT & Trimming.


Why LinKit?

LinKit uses C# source generation to move selected handler discovery and wiring work to compile time. Generated code reduces repetitive registration and dispatch code, while unsupported or dynamic paths remain application-specific.

Key Benefits:

  • Generated wiring: Static registrations and adapters for supported patterns.
  • Predictable composition: Explicit module registration instead of relying exclusively on runtime assembly scanning.
  • AOT-oriented: Selected features have smoke coverage; validate the complete application composition.
  • Clean API: Intent-driven, explicit, and easy to use.
  • Automated Boilerplate: For DI, API endpoints, background jobs, gRPC, messaging, and mapping.

LinKit Ecosystem

Package Description NuGet
LinKit.Core Required. Core CQRS, Endpoints, DI, Jobs, Mapping & Source Gen. NuGet
LinKit.Grpc gRPC server/client adapters generated for CQRS requests. NuGet
LinKit.Messaging.RabbitMQ RabbitMQ broker implementation for LinKit messaging abstractions. NuGet
LinKit.Messaging.Kafka Apache Kafka broker implementation for LinKit messaging abstractions. NuGet
LinKit.Outbox Transactional Outbox & Idempotent Inbox for distributed resilience. NuGet
LinKit.Outbox.Data Durable ADO.NET Outbox/Inbox stores for LinKit.Data providers. NuGet
LinKit.Data Source-generated ADO.NET repositories and schema synchronization. NuGet
LinKit.Caching In-memory cache provider with expiry, capacity management, and single-flight creation. NuGet
LinKit.Resilience Explicit retry, timeout, and circuit-breaker execution pipeline. NuGet
LinKit.Tenancy Async-flow tenant context and explicit message-header helpers. NuGet
LinKit.Sagas Saga step coordinator and replaceable repository contract. NuGet

Installation

dotnet add package LinKit.Core

Add modular packages as required by your architecture:

dotnet add package LinKit.Grpc
dotnet add package LinKit.Messaging.RabbitMQ
dotnet add package LinKit.Messaging.Kafka
dotnet add package LinKit.Outbox
dotnet add package LinKit.Outbox.Data
dotnet add package LinKit.Data
dotnet add package LinKit.Caching
dotnet add package LinKit.Resilience
dotnet add package LinKit.Tenancy
dotnet add package LinKit.Sagas

Kits Overview

1. CQRS Kit

A source-generated Mediator for implementing CQRS (Command Query Responsibility Segregation) with compile-time handler wiring for supported request/handler patterns.

The LinKit CQRS Kit analyzes the compilation and generates a mediator implementation and registration code. It supports modular registration and pipeline behaviors. Validate trimming and Native AOT for the actual handlers, behaviors, serializers, and dependencies used by your application.

Core Interfaces
  • ICommand: A "void" operation (returns Task<Unit>).
  • ICommand<TResponse>: An operation that changes state and returns a value.
  • IQuery<TResponse>: A data retrieval operation.

Step 1: Define Requests & Handlers

1. Create a Request:

public record CreateUserCommand(string Name) : ICommand<int>;

2. Create a Handler: Mark your handler with [CqrsHandler] for automatic discovery.

[CqrsHandler]
public class CreateUserHandler : ICommandHandler<CreateUserCommand, int>
{
    public async Task<int> HandleAsync(CreateUserCommand request, CancellationToken ct) 
        => await Task.FromResult(1);
}

Step 2: Multi-Assembly Registration ([CqrsContext])

In real-world applications (Clean Architecture, Modular Monoliths), your requests, handlers, and pipeline behaviors usually reside in different class libraries (e.g., Application.dll, Infrastructure.dll). Since Source Generators run per-project, generating multiple IMediator instances across different projects would cause Dependency Injection conflicts.

LinKit solves this elegantly with [CqrsContext]. It acts as a centralized registry using Assembly Marker Types. You define it once in your Host project (e.g., WebAPI), and the generator will automatically dive into the referenced assemblies, discover all handlers AND pipeline behaviors, and generate a single, unified, highly-optimized Mediator.

1. Create Marker Classes in your target projects: Create an empty class in any project that contains your CQRS logic. This class acts as an anchor for the generator.

// In your Application layer project
namespace MyApp.Application;
public sealed class ApplicationAssemblyMarker { }

// In your Infrastructure layer project
namespace MyApp.Infrastructure;
public sealed class InfrastructureAssemblyMarker { }

2. Define the Context in your Host project (WebAPI): In your startup project, apply [CqrsContext] to any class (even Program.cs) and pass the marker types.

using LinKit.Core.Cqrs;
using MyApp.Application;
using MyApp.Infrastructure;

// The LinKit source generator running in the WebAPI project will read this,
// scan the entire Application and Infrastructure assemblies, 
// and wire up ALL discovered Handlers AND Pipeline Behaviors automatically!
[CqrsContext(
    typeof(ApplicationAssemblyMarker),
    typeof(InfrastructureAssemblyMarker)
)]
public partial class GlobalCqrsRegistry 
{ 
}

Note: You can still use [CqrsHandler] or [CqrsBehavior] on individual classes for explicit local discovery in the host project. The generator will seamlessly combine local classes with the context-discovered ones.


Step 3: Advanced Pipeline Behaviors

LinKit's pipeline is generated at compile-time using a Clean Name matching engine. This means a behavior targeting ICommand will automatically match both ICommand and ICommand<TResponse>.

(Tip: If your behaviors are located in external assemblies, just ensure their assembly marker is included in your [CqrsContext] as shown in Step 2. LinKit will find them automatically!)

A. Global & Contract Behaviors ([CqrsBehavior])

Use this for cross-cutting concerns. You can target a specific marker interface or use typeof(object) for a truly global behavior.

// 1. Truly Global Behavior (Runs for EVERYTHING: Commands and Queries)
[CqrsBehavior(typeof(IRequest<>), Order = 1)]
public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> 
    where TRequest : IRequest<TResponse>
{ ... }

// 2. Contract-based Behavior (Runs only for Commands)
// LinKit automatically matches ICommand, ICommand<T>, and their implementations.
[CqrsBehavior(typeof(ICommand), Order = 2)]
public class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : ICommand<TResponse>
{ ... }
B. Specific Behaviors ([ApplyBehavior])

Use this for ad-hoc logic applied directly to a specific request class. These behaviors run closest to the handler.

[ApplyBehavior(typeof(ValidationBehavior<,>), typeof(IdempotencyBehavior<,>))]
public record ProcessPaymentCommand(Guid Id) : ICommand<bool>;

Execution Order:

  1. Global Behaviors (by Order)
  2. Contract Behaviors (by Order)
  3. Specific Behaviors (defined in [ApplyBehavior])
  4. Final Handler

Step 4: Register and Use

Registration:

// Program.cs
// This single call registers the generated Mediator, all Handlers, 
// and wires up the entire Pipeline.
builder.Services.AddLinKitCqrs();

Usage:

public class MyApi(IMediator mediator)
{
    public async Task Invoke()
    {
        // Use SendAsync for ICommand / ICommand<T>
        int userId = await mediator.SendAsync(new CreateUserCommand("Alice"));

        // Use QueryAsync for IQuery<T>
        var user = await mediator.QueryAsync(new GetUserQuery(userId));
    }
}

Notifications (INotification)

LinKit also supports publish/subscribe style notifications through INotification and INotificationHandler<TNotification>.

public record UserCreatedNotification(int UserId) : INotification;

[CqrsHandler]
public class SendWelcomeEmailHandler : INotificationHandler<UserCreatedNotification>
{
    public Task HandleAsync(UserCreatedNotification notification, CancellationToken ct)
        => Task.CompletedTask;
}

[CqrsHandler]
public class PushAuditLogHandler : INotificationHandler<UserCreatedNotification>
{
    public Task HandleAsync(UserCreatedNotification notification, CancellationToken ct)
        => Task.CompletedTask;
}

Publish from IMediator:

await mediator.PublishAsync(new UserCreatedNotification(userId)); // default: Sequential
await mediator.PublishAsync(new UserCreatedNotification(userId), PublishStrategy.Parallel);

Current notification behavior:

  • Default strategy is Sequential (handlers run one-by-one).
  • Parallel runs all handlers concurrently (Task.WhenAll).
  • Pipeline behaviors (IPipelineBehavior<,>) apply to command/query, not notification.
  • If a notification type has no discovered handler ([CqrsHandler] or [CqrsContext]), publish is a no-op (Task.CompletedTask).

2. Dependency Injection Kit

Usage Guide

1. Mark Your Services

// Shorthand lifetime attributes (Recommended): [AddScoped] // Automatically registers as IUserService (leaf interface) with Scoped lifetime public class UserService : IUserService { ... }

[AddSingleton(typeof(IDataProvider))] public class MyDataProvider : IDataProvider, IDisposable { ... }

[AddTransient(Key = "excel_report")] public class ExcelReportService : IReportService { ... }

// Or use the explicit [RegisterService] attribute: [RegisterService(Lifetime.Scoped)] public class LegacyUserService : IUserService { ... }

[RegisterService(Lifetime.Singleton, ServiceType = typeof(IDataProvider))] public class LegacyDataProvider : IDataProvider { ... }

2. Open Generics Support

To register Open Generic types, set the IsGeneric property to true.

[RegisterService(Lifetime.Scoped, IsGeneric = true)]
public class Repository<T> : IRepository<T> where T : class { ... }
3. Automatic Interface Discovery

If you do not specify a ServiceType, LinKit follows a smart hierarchy to choose the best interface:

  1. Convention: An interface matching the class name (e.g., MyService → IMyService).
  2. Leaf Interface: The most specific interface in the inheritance chain (closest parent).
  3. Self-Registration: If no interfaces are found, it registers the class itself.
4. Activation in Program.cs

The Source Generator automatically creates an extension method called AddLinKitDependency() in <RootNamespace>.Generated and emits a global using for that namespace in the current assembly. Call it once in your startup configuration:

var builder = WebApplication.CreateBuilder(args);

// Automatically registers all classes marked with [RegisterService]
builder.Services.AddLinKitDependency();

var app = builder.Build();

Generated namespaces are assembly-specific to prevent extension-method collisions when projects reference each other. Global usings are not transitive; to call a generated registration method from a referenced project, add using <ReferencedProjectRootNamespace>.Generated;.

Upgrade to 3.0

Generated Core APIs now live in the consuming assembly's <RootNamespace>.Generated namespace instead of the shared LinKit.Core namespace. The consuming assembly gets a generated global using automatically; projects that call generated APIs from a referenced assembly must explicitly import that assembly's generated namespace. This avoids duplicate extension methods when multiple projects reference LinKit.

RegisterServiceAttribute Parameters

Parameter Type Description
Lifetime int/Enum 0: Scoped, 1: Singleton, 2: Transient (based on your Enum definition).
ServiceType Type (Optional) Specify a specific interface/base class to register.
Key string (Optional) Registers the service as a Keyed Service.
IsGeneric bool (Optional) Set to true for Open Generic classes (Class<T>).

Why LinKit DI?

In standard DI registration, if a class implements multiple inherited interfaces:

public interface IRepository<T> { }
public interface IUserRepository : IRepository<User> { }
public class UserRepository : IUserRepository { }

LinKit is "context-aware"—it understands that you likely want to inject IUserRepository rather than the generic IRepository<User>, ensuring your dependencies are specific and clean.


Pro-Tips:

  • Multiple Registrations: If a class implements multiple independent interfaces (e.g., IServiceA and IServiceB), you can apply the attribute multiple times (requires AllowMultiple = true in attribute definition).
  • Native AOT: Source generation can reduce runtime discovery, but the complete application still needs trimming/AOT analysis and publish-time validation.

3. Endpoints Kit (Minimal APIs)

The Endpoints Kit generates Minimal API mappings for supported, endpoint-decorated CQRS requests, reducing hand-written route and dispatch wiring.

  • Zero Boilerplate: The request DTO is the API definition.
  • Automatic Binding: Infers [FromBody] for command payloads (POST/PUT/PATCH) and [AsParameters] for queries (GET/DELETE).
  • Flexible Response Handling: Intelligent result mapping that handles both Reference Types and Value Types (like bool, int). It automatically returns 200 OK for valid results and 404 Not Found for nulls without boxing overhead.
  • Keyed Mediator Support: Ability to inject specific Mediator instances using .NET 8+ Keyed Services.
  • Metadata & OpenAPI: Built-in support for Summary, Description, Tags, and Versions.
  • Security: Native support for Policies, Roles, and CORS.
Step 1: Define Endpoints

Decorate your ICommand or IQuery classes with HTTP shorthand attributes (or [ApiEndpoint]).

Example 1: A GET Query with Shorthand Attribute

using LinKit.Core.Endpoints;

// Shorthand: Maps to GET /users/{id}
[HttpGet("users/{id}")]
public record GetUserQuery(int Id) : IQuery<UserDto>;

// Or using [ApiEndpoint]:
[ApiEndpoint(ApiMethod.Get, "users/{id}")]
public record GetUserLegacyQuery(int Id) : IQuery<UserDto>;

Example 2: A POST Command with Shorthand Attribute

// Shorthand: Maps to POST /users/check-email
[HttpPost("users/check-email")]
public record CheckEmailCommand(string Email) : ICommand<bool>;

Example 3: Using Keyed Mediator & Security

If your architecture uses multiple Mediator instances (e.g., for different modules or cross-cutting concerns), use MediatorKey.

[ApiEndpoint(ApiMethod.Put, "users/{Id}/role",
    Name = "UpdateUserRole",
    Summary = "Updates user role",
    Roles = "Admin",
    MediatorKey = "IdentityMediator", // Uses [FromKeyedServices("IdentityMediator")]
    RateLimitPolicy = "Strict"
)]
public class UpdateRoleCommand : ICommand
{
    [FromRoute] public int Id { get; set; }
    public string NewRole { get; set; }
}

Example 4: Endpoint Filters (IEndpointFilter) & Custom Response Type

Intercept and enrich endpoint responses with generated endpoint metadata. Verify the emitted OpenAPI document for custom response and binding types:

public class EntityAuditEndpointFilter : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
    {
        var result = await next(context);
        // Enrich data directly on C# objects before JSON serialization
        return result;
    }
}

// Maps to: POST /api/entity-audit/get
[ApiEndpoint(
    ApiMethod.Post, 
    "api/entity-audit/get",
    ResponseType = typeof(IList<EntityAuditResponseModelExtend>), // Custom Swagger schema
    SuccessStatusCode = 200,
    ProducesStatusCodes = [StatusCodes.Status400BadRequest]
)]
[EndpointFilter(typeof(EntityAuditEndpointFilter), Order = 1)]
public class GetEntityAuditQuery : IQuery<IList<EntityAuditResponseModel>>
{
    public string EntityName { get; set; } = string.Empty;
}

Example 5: Specialized Responses (File, Stream, byte[], Redirect, Raw Content)

LinKit provides response helpers for binary downloads, streams, redirects, and custom content responses:

using LinKit.Core.Endpoints;

// 1. Direct File / Stream / Byte Array response
[ApiEndpoint(ApiMethod.Get, "catalog/export")]
public record ExportCatalogQuery : IQuery<FileResponse>;

[CqrsHandler]
public class ExportCatalogHandler : IQueryHandler<ExportCatalogQuery, FileResponse>
{
    public Task<FileResponse> HandleAsync(ExportCatalogQuery query, CancellationToken ct)
    {
        var csvBytes = "Id,Name\n1,Product A"u8.ToArray();
        return Task.FromResult(FileResponse.Bytes(
            csvBytes, 
            contentType: "text/csv", 
            fileDownloadName: "products.csv"
        ));
    }
}

// 2. Redirect Response
[ApiEndpoint(ApiMethod.Get, "orders/{id}/tracking")]
public record GetTrackingUrlQuery([FromRoute] int id) : IQuery<RedirectResponse>;

// 3. Raw Content Response (HTML, SVG, XML, plain text)
[ApiEndpoint(ApiMethod.Get, "health/badge")]
public record GetHealthBadgeQuery : IQuery<ContentResponse>;
Step 2: Route Grouping & Group Filters (Optional)

Define global prefixes, shared security policies, and group-wide filters for a group of endpoints using the [ApiRouteGroup] attribute at the Assembly level.

// In Program.cs or AssemblyInfo.cs
[assembly: ApiRouteGroup("/api/v1", Tag = "V1 API", RequireAuthorization = true, Filters = [typeof(CommonAuditFilter)])]

Associate an endpoint with a group using the Group property (it can be the prefix or the feature name):

[ApiEndpoint(ApiMethod.Get, "products", Group = "/api/v1")]
public class GetProductsQuery : IQuery<List<ProductDto>> { ... }
Step 3: Global Exception Handling

Map custom exceptions to HTTP status codes automatically with an optimized, non-reflection based middleware.

  1. Define Mappings:
using LinKit.Core.Endpoints;

[ApiExceptionMapping(StatusCodes.Status404NotFound, Title = "Resource Not Found")]
public class UserNotFoundException : Exception 
{
    public UserNotFoundException(int id) : base($"User {id} was not found.") { }
}

[ApiExceptionMapping(StatusCodes.Status409Conflict, LogLevel = "Warning")]
public class DuplicateEmailException : Exception { ... }
  1. Register Middleware:
var app = builder.Build();
app.UseGeneratedExceptionHandler(); // Highly optimized switch-based handler
Step 4: Register Endpoints

In your Program.cs, simply call MapGeneratedEndpoints. All your CQRS-based endpoints are discovered and mapped at compile-time.

var app = builder.Build();

app.UseGeneratedExceptionHandler(); 
app.UseAuthentication();
app.UseAuthorization();

// This single line maps all endpoints defined in your assembly
app.MapGeneratedEndpoints();

app.Run();

4. Background Job Kit

The Background Job Kit provides a configuration-driven system for executing jobs on a schedule. Generated registration links supported job definitions to runtime scheduling infrastructure. Shutdown waits are bounded; long-running jobs should be idempotent and applications requiring full drain should implement/verify graceful shutdown.

How It Works
  1. The [BackgroundJob] attribute tells the source generator to map a unique, human-readable name to a specific CQRS request type.
  2. At runtime, the registered BackgroundJobManager (an IHostedService) reads your JSON configuration.
  3. It uses the generated map to find the correct CQRS request for each configured job and uses the Mediator to execute it according to the specified schedule. The entire process is type-safe and performant.
  4. Each execution receives a unique ExecutionId (Guid.NewGuid().ToString("N")), assigned before the handler runs. It is passed to your command, included in structured logs, and stored in execution history when IsLogHistory is enabled. This is especially useful when MaxParallel > 1 or when the same job is triggered manually while a scheduled run is in progress.
  5. When you need to run a job immediately from application code (without waiting for the next schedule), inject IBackgroundJobTrigger and call TriggerAsync with the same job name as in configuration.
Usage

Step 1: Decorate a CQRS Request

Mark any BackgroundJobCommand that you want to be available as a background job with the [BackgroundJob] attribute, providing a unique name. The base class exposes EmbeddedData (from config or trigger override) and ExecutionId (auto-generated for each run).

[BackgroundJob("ProcessEndOfDayReport")]
public class ProcessEndOfDayReportCommand : BackgroundJobCommand
{
}

[CqrsHandler]
public class ProcessEndOfDayReportHandler : ICommandHandler<ProcessEndOfDayReportCommand>
{
    private readonly ILogger<ProcessEndOfDayReportHandler> _logger;

    public ProcessEndOfDayReportHandler(ILogger<ProcessEndOfDayReportHandler> logger)
    {
        _logger = logger;
    }

    public Task HandleAsync(ProcessEndOfDayReportCommand command, CancellationToken cancellationToken)
    {
        _logger.LogInformation(
            "Processing end-of-day report. ExecutionId: {ExecutionId}, Type: {Type}",
            command.ExecutionId,
            command.ReportType ?? "Standard");
        // ... your business logic here ...
        return Task.CompletedTask;
    }
}

Step 2: Create Your Configuration

Create a section in your appsettings.json or a separate JSON file to define the schedules for your jobs. The Name property in the JSON must match the name provided in the [BackgroundJob] attribute.

Example appsettings.json:

{
  "BackgroundJobConfig": {
    "BackgroundJobs": [
      {
        "Name": "HeartbeatCheck",
        "IsActive": true,
        "ScheduleType": "Interval",
        "TimeIntervalSeconds": 300
      },
      {
        "Name": "SendDailyNewsletter",
        "IsActive": true,
        "IsLogHistory": true,
        "RunOnStart": true,
        "ScheduleType": "Daily",
        "UtcOffsetHours": 7,
        "TimeOfDay": "08:00:00"
      },
      {
        "Name": "WeeklyDatabaseCleanup",
        "IsActive": true,
        "IsLogHistory": true,
        "ScheduleType": "Weekly",
        "TimeZoneId": "SE Asia Standard Time",
        "TimeOfDay": "03:30:00",
        "DayOfWeek": "Sunday"
      },
      {
        "Name": "ProcessEndOfDayReport",
        "IsActive": false,
        "ScheduleType": "Monthly",
        "TimeOfDay": "23:59:00",
        "DayOfMonth": 99,
        "MaxParallel": 2,
        "EmbeddedData": "{\"ReportType\": \"Financial\"}"
      }
    ]
  }
}

Step 3: Register the Background Job Service

In your Program.cs, call the AddBackgroundJobs extension method.

var builder = WebApplication.CreateBuilder(args);

// Register Background Job hosted service + generated mapper
builder.AddBackgroundJobs("BackgroundJobsConfig.json");

var app = builder.Build();

This registers the BackgroundJobManager and IBackgroundJobTrigger, which will automatically start, stop, and reload jobs based on your configuration.

Step 4 (Optional): Trigger a Job On Demand

Use IBackgroundJobTrigger when you want to run a configured background job immediately from a handler, endpoint, or admin action — without changing the schedule or waiting for the next tick.

using LinKit.Core.BackgroundJobs;

public class OrderCompletedHandler(IBackgroundJobTrigger jobTrigger)
{
    public async Task HandleAsync(OrderCompletedEvent notification, CancellationToken cancellationToken)
    {
        // Uses EmbeddedData from JSON config for this job
        await jobTrigger.TriggerAsync("ProcessEndOfDayReport", cancellationToken);

        // Or override EmbeddedData for this run only
        await jobTrigger.TriggerAsync(
            "ProcessEndOfDayReport",
            embeddedData: "{\"ReportType\": \"AdHoc\"}",
            cancellationToken);
    }
}

Behavior notes:

Scenario What happens
Job is active (IsActive: true) Runs through the live JobRunner, respects MaxParallel with scheduled runs, and does not reset the next scheduled delay.
Job exists in config but is inactive Runs once via the same execution pipeline (mapper, mediator, optional history). Useful for admin "run now" on disabled jobs.
Job name not in config Throws InvalidOperationException.
embeddedData argument Pass null to use EmbeddedData from JSON; pass a string to override it for that execution only.
ExecutionId Automatically generated per run. Available on command.ExecutionId in your handler and in JobExecutionHistory when history logging is enabled. Not configurable in JSON.

Note: For logic that is not tied to a configured background job, you can still call IMediator.SendAsync with your command directly. Use IBackgroundJobTrigger when you want the same path as scheduled jobs (generated mapper, EmbeddedData, ExecutionId, and IJobHistoryLogger when IsLogHistory is enabled).


Configuration Details

Below is a detailed description of each property available in the job configuration.

Property Type Description
Name string Required. The unique identifier for the job. This must exactly match the name provided in the [BackgroundJob("MyUniqueJobName")] attribute.
IsActive bool Determines if the job is enabled. If set to false, the job will not run. Changes are detected at runtime.
IsLogHistory bool Optional. If true, the job's execution history (start time, end time, success status, and errors) will be logged. Defaults to false.
RunOnStart bool If true, the job will execute once immediately upon application start (or when the job is activated via config change), and then follow its regular schedule. Defaults to false.
ScheduleType string Required. The scheduling mode. Can be one of four values: Interval, Daily, Weekly, Monthly.
TimeIntervalSeconds int Used only when ScheduleType is Interval. Defines the number of seconds to wait between each job execution.
TimeZoneId string Optional. Preferred timezone identifier (for example: SE Asia Standard Time, Asia/Ho_Chi_Minh). If valid, this takes precedence.
UtcOffsetHours double Optional fallback when TimeZoneId is not set/invalid. Supports values like 7, -7, 5.5. Valid range is -14 to +14.
TimeOfDay string Used for Daily, Weekly, and Monthly schedules. Defines local run time in the resolved timezone (TimeZoneId → UtcOffsetHours → UTC). Format: "HH:mm:ss".
DayOfWeek string Used only when ScheduleType is Weekly. The day of the week to run the job. Examples: "Monday", "Tuesday", etc.
DayOfMonth int Used only when ScheduleType is Monthly. The day of the month to run (1-31). Special Value: Use a large number (e.g., 99) to signify the last day of the current month.
MaxParallel int The maximum number of instances of this job that can run concurrently. Defaults to 1. Useful for long-running jobs to prevent overlap.
EmbeddedData string (JSON) An optional string value that is passed directly to the EmbeddedData property of your command. This can be a simple string, a JSON object, or any other format you wish to parse in your handler.

Note: ExecutionId is not a configuration property. LinKit generates a new 32-character identifier for every execution, sets it on BackgroundJobCommand.ExecutionId before invoking your handler, and exposes the same value via JobExecutionContextAccessor.CurrentExecutionId for the duration of the run.


Execution History Logging

LinKit provides built-in support for tracking the execution status of your background jobs. When "IsLogHistory": true is set in the job configuration, LinKit captures the execution ID, start time, end time, execution success, embedded data, and any exception messages.

1. Default File Logging Out of the box, LinKit logs histories to a highly optimized, memory-safe JSON-Lines file. This file is automatically created in the same directory as your configuration file (e.g., if you load JobsConfig.json, the history file will be JobsConfig_History.json).

Each line includes an ExecutionId you can use to correlate a history record with logs and handler output:

{"ExecutionId":"a1b2c3d4e5f6478990abcdef12345678","JobName":"SendDailyNewsletter","StartTime":"2026-06-29T08:00:00Z","EndTime":"2026-06-29T08:00:02Z","IsSuccess":true,"EmbeddedData":""}

2. Custom Database Logging For enterprise applications, you likely want to store this history in a Database (SQL Server, PostgreSQL, MongoDB, etc.) instead of a file. LinKit's architecture makes this extremely easy via standard Dependency Injection.

Simply implement the IJobHistoryLogger interface and register it in your Program.cs. LinKit will automatically bypass the default file logger and use yours instead:

using LinKit.Core.BackgroundJobs;

// 1. Create your custom logger implementation
public class MyDbJobHistoryLogger : IJobHistoryLogger
{
    private readonly ApplicationDbContext _dbContext;

    public MyDbJobHistoryLogger(ApplicationDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task LogAsync(JobExecutionHistory history, CancellationToken cancellationToken = default)
    {
        // Save the execution data to your database using EF Core, Dapper, etc.
        _dbContext.JobHistories.Add(history);
        await _dbContext.SaveChangesAsync(cancellationToken);
    }
}

// 2. Register it in Program.cs BEFORE AddBackgroundJobs
builder.Services.AddScoped<IJobHistoryLogger, MyDbJobHistoryLogger>();

// 3. Register the Background Jobs
builder.AddBackgroundJobs("BackgroundJobsConfig.json");

5. Mapping Kit

A source-generated mapper for supported property mappings. The generator emits direct C# mapping code where it can establish compatible source/destination members.

  • Type-Safe API: Uses lambda expressions (dest => dest.Property) to eliminate magic strings and catch errors at compile time.
  • Fluent Configuration: Chain .ForMember() calls to create readable and maintainable mapping rules.
  • Convention-Based: Automatically maps properties with matching names or [JsonPropertyName] / [JsonProperty] attributes.
  • No DI Required: Generates extension methods (.ToUserDto()) that can be used anywhere.
Usage

Step 1: Create a Mapper Context

Create a partial class marked with the [MapperContext] attribute. This class will contain your mapping configurations.

using LinKit.Core.Mapping;
using YourApp.Models.Entities;
using YourApp.Models.Dtos;

[MapperContext]
public partial class ApplicationMapperContext : IMappingConfigurator
{
    public void Configure(IMapperConfigurationBuilder builder)
    {
        // Define all your application's mappings here
        builder.CreateMap<User, UserDto>()
            // Examples of detailed configuration below
            ;

        builder.CreateMap<Order, OrderSummaryDto>();
    }
}

Step 2: Configure Mappings with the Fluent API

Use the CreateMap<TSource, TDestination>() method and chain .ForMember() calls to define custom mapping rules.

// Inside the Configure method from Step 1

builder.CreateMap<User, UserDto>()
    // 1. Map from a differently named property (type-safe)
    .ForMember(dest => dest.FullName, opt => opt.MapFrom(src => src.UserName))
    
    // 2. Ignore a property
    .ForMember(dest => dest.PasswordHash, opt => opt.Ignore())
    
    // 3. Perform complex transformations
    .ForMember(dest => dest.Initials, opt => opt.MapFrom(src => $"{src.FirstName[0]}{src.LastName[0]}"))
    
    // 4. Use a custom converter method (e.g., from a static helper class)
    .ForMember(
        dest => dest.AddressString, 
        opt => opt.ConvertWith(
            typeof(AddressFormatter), 
            nameof(AddressFormatter.Format), 
            src => src.Address
        )
    );

Step 3: Use the Generated Extension Methods

The source generator automatically creates .To...() and .To...List() extension methods. Just use them directly on your objects.

// Assuming 'user' is an instance of the User entity
var userDto = user.ToUserDto();

// For collections
// Assuming 'users' is an IEnumerable<User>
var dtoList = users.ToUserDtoList();
Mapping Conventions (Order of Precedence)

The mapper automatically maps properties that are not explicitly configured. It follows these rules in order:

  1. Explicit Configuration: Rules defined with .ForMember() are always applied first.
  2. [JsonPropertyName] / [JsonProperty] Matching: If a destination property has a [JsonPropertyName] or [JsonProperty] attribute, the mapper looks for a source property with the same attribute and name. This is useful for mapping between C# naming conventions and JSON/API conventions (e.g., FullName to full_name).
  3. Name Matching: If no attribute match is found, the mapper maps properties with the same name (case-insensitive). This includes nested objects and collections of the same type.
  4. Nested Object Mapping: If a property is a complex type (e.g., Address), and a map has been defined for it (CreateMap<Address, AddressDto>()), the mapper will automatically generate the call to .ToAddressDto().

6. Messaging Kit

Source-generated publisher/consumer for message brokers (RabbitMQ, Kafka).

  • Mark Messages: [Message] on your event/command.
  • Write Handlers: [CqrsHandler] for the message.
  • Register: builder.Services.AddLinKitMessaging(); and the broker package.
[Message("user-events", RoutingKey = "user.created", QueueName = "email-service-queue")]
public record UserCreatedEvent(int UserId, string Email);

[CqrsHandler]
public class UserCreatedHandler : ICommandHandler<UserCreatedEvent> { ... }

Publisher:

builder.Services.AddLinKitMessaging();
builder.Services.AddLinKitRabbitMQ(configuration);
// await publisher.PublishAsync(new UserCreatedEvent(...));

Consumer:

builder.Services.AddLinKitCqrs();
builder.Services.AddLinKitMessaging();
builder.Services.AddLinKitRabbitMQ(configuration);

7. gRPC Kit (via LinKit.Grpc)

The optional LinKit.Grpc package generates adapters between CQRS requests and protobuf-generated gRPC service/client types. It does not generate .proto contracts or remove the need to validate RPC mappings and runtime dependencies.

  • Zero Boilerplate: No need to manually write gRPC service classes. Just decorate your CQRS requests.
  • Automatic Mapping: Intelligently maps properties between your Protobuf-generated classes and your C# CQRS request/response DTOs.
  • Built-in Error Handling: Automatically translates common exceptions (like ValidationException) into appropriate gRPC status codes.
  • Seamless Integration: Works perfectly with the local IMediator to execute your existing handlers.
How It Works

The gRPC Kit consists of two parts: a Server Generator and a Client Generator.

  1. Server-Side: You decorate a CQRS request class with the [GrpcEndpoint] attribute. You specify which gRPC service (from your .proto file) and method this request corresponds to. The source generator then creates a complete gRPC service class implementation (LinKit...Service) that:

    • Inherits from your Protobuf-generated service base class (e.g., UserServiceBase).
    • Overrides the specified method.
    • Receives the gRPC request, maps it to your CQRS request.
    • Calls the local IMediator to execute the corresponding handler.
    • Maps the CQRS response back to the gRPC response.
    • Handles exceptions gracefully.
  2. Client-Side: (See next section for details) You decorate the same CQRS request with [GrpcClient]. This generates an IGrpcMediator implementation that allows you to send the CQRS request from a client application, which then makes the gRPC call transparently.


Server-Side Usage

Step 1: Define your .proto file

First, define your gRPC service and messages as you normally would.

Example users.proto:

syntax = "proto3";

option csharp_namespace = "MyCompany.Grpc.Contracts";

package users;

service UserService {
  rpc GetUserById (GetUserByIdRequest) returns (GetUserByIdResponse);
  rpc CreateUser (CreateUserRequest) returns (CreateUserResponse);
}

message GetUserByIdRequest {
  int32 user_id = 1;
}

message UserDtoMessage {
    int32 id = 1;
    string user_name = 2;
    string email = 3;
}

message GetUserByIdResponse {
  UserDtoMessage user = 1;
}

message CreateUserRequest {
    string user_name = 1;
    string email = 2;
}

message CreateUserResponse {
    int32 new_user_id = 1;
}

Step 2: Create Corresponding CQRS Requests and Handlers

Create your CQRS queries, commands, and handlers as usual. Ensure the properties match the fields in your .proto messages for automatic mapping.

The Query:

// Features/Users/GetUserQuery.cs
public class GetUserQuery : IQuery<UserDto> 
{
    // Property "UserId" will be mapped from "user_id" in the proto
    public int UserId { get; set; } 
}

The Command:

// Features/Users/CreateUserCommand.cs
// This command returns the new user's ID
public class CreateUserCommand : ICommand<int>
{
    public string UserName { get; set; }
    public string Email { get; set; }
}

The DTO:

// Dtos/UserDto.cs
public class UserDto
{
    public int Id { get; set; }
    public string UserName { get; set; }
    public string Email { get; set; }
}

(You would also have GetUserHandler and CreateUserHandler marked with [CqrsHandler])

Step 3: Decorate CQRS Requests with [GrpcEndpoint]

This is the key step. Add the [GrpcEndpoint] attribute to your CQRS request classes to link them to the gRPC methods defined in your .proto file.

Linking the Query:

using LinKit.Grpc;
using MyCompany.Grpc.Contracts; // Namespace from your .proto file

[GrpcEndpoint(typeof(UserService.UserServiceBase), "GetUserById")]
public class GetUserQuery : IQuery<UserDto> 
{
    public int UserId { get; set; } 
}
  • typeof(UserService.UserServiceBase): The gRPC service base class generated by protoc.
  • "GetUserById": The exact name of the RPC method in your service.

Linking the Command:

using LinKit.Grpc;
using MyCompany.Grpc.Contracts;

[GrpcEndpoint(typeof(UserService.UserServiceBase), "CreateUser")]
public class CreateUserCommand : ICommand<int>
{
    public string UserName { get; set; }
    public string Email { get; set; }
}

Step 4: Register and Map the gRPC Service

In your server's Program.cs, register the necessary LinKit services and map the generated gRPC service.

var builder = WebApplication.CreateBuilder(args);

// 1. Register standard LinKit CQRS and your handlers
builder.Services.AddLinKitCqrs();

// 2. Add gRPC core services
builder.Services.AddGrpc();

var app = builder.Build();

// 3. Map the source-generated gRPC service
//    The name is always "LinKit" + [YourServiceName]
app.MapGrpcService<LinKitUserService>();

app.Run();

That's it! You now have a fully functional gRPC service. When a client calls the GetUserById method, the generated LinKitUserService will:

  1. Instantiate a GetUserQuery.
  2. Map GetUserByIdRequest.UserId to GetUserQuery.UserId.
  3. Call _mediator.QueryAsync(theQuery).
  4. Receive the UserDto from your GetUserHandler.
  5. Map the UserDto to a GetUserByIdResponse.
  6. Return the response to the client.
Automatic Mapping Conventions

LinKit automatically maps properties between your CQRS objects and Protobuf messages. It follows a simple, case-insensitive name matching rule. For Protobuf's snake_case convention, LinKit will correctly map user_id to a C# property named UserId.

This includes:

  • Request Mapping: GrpcRequest → CqrsRequest
  • Response Mapping: CqrsResponse → GrpcResponse
  • Nested Lists: It can even map collections, like a List<ProductDto> in C# to a repeated ProductMessage in Protobuf, as long as the item types have matching properties.

Client-Side Usage

(This section can be updated with the new IGrpcMediator logic once you've finalized it)

To call the gRPC service from another .NET application, you use the Client-Side generator.

Step 1: Decorate the SAME CQRS Request with [GrpcClient]

In your client project, reference the shared CQRS request classes and decorate them again, this time with [GrpcClient].

// In the Client Application project
using LinKit.Grpc;
using MyCompany.Grpc.Contracts;

[GrpcClient(typeof(UserService.UserClient), "GetUserByIdAsync")]
public class GetUserQuery : IQuery<UserDto> 
{
    public int UserId { get; set; } 
}
  • typeof(UserService.UserClient): The gRPC client class generated by protoc.
  • "GetUserByIdAsync": The name of the asynchronous client method.

Step 2: Register the gRPC Client Kit

In your client's Program.cs, configure the IGrpcMediator and provide a channel to the server.

var builder = WebApplication.CreateBuilder(args);

// 1. Register the generated IGrpcMediator
builder.Services.AddLinKitGrpcClient();

// 2. Register a way to get the gRPC channel
//    This is a simple example. You can implement IGrpcChannelProvider
//    for more complex scenarios (e.g., service discovery).
builder.Services.AddSingleton<IGrpcChannelProvider>(
    new DefaultGrpcChannelProvider("https://localhost:7001") // Address of your gRPC server
);

Step 3: Use IGrpcMediator

Inject IGrpcMediator and use it just like the local IMediator. The API is identical.

public class MyFrontendService
{
    private readonly IGrpcMediator _grpcMediator;

    public MyFrontendService(IGrpcMediator grpcMediator)
    {
        _grpcMediator = grpcMediator;
    }

    public async Task<UserDto> GetUserFromRemoteService(int id)
    {
        var query = new GetUserQuery { UserId = id };
        
        // This looks like a local call, but it's making a gRPC request!
        return await _grpcMediator.QueryAsync(query);
    }
}

AOT & Trimming

LinKit uses source generation extensively, but a generator in one layer does not make every runtime path reflection-free. The repository's .NET 10 Native AOT smoke app currently covers selected Data, mapper, LINQ, SQLite, and durable Outbox paths. It does not certify every Core feature, gRPC/broker adapter, provider, target framework, operating system, or third-party dependency.

For a trimmed or Native AOT application:

  1. Register System.Text.Json generated contexts for messages and JSON columns.
  2. Enable trim/AOT analyzers and address warnings in the application project.
  3. Publish for the actual RID and run the published executable.
  4. Re-test when adding providers, interceptors, serializers, or dynamic extension points.

Some paths still contain reflection/dynamic fallbacks, including Data LINQ closure extraction and gRPC client activation when generated activation is unavailable. See ARCHITECTURE_AND_CODEGEN_GUIDE.md for the current feature-by-feature boundaries.


Advanced Configuration

  • All AddLinKit...() methods are additive and can be combined.
  • No manual registration of handlers or mappings is needed.
  • For custom mapping, use the Mapping Kit.
  • For custom gRPC channel, implement IGrpcChannelProvider.

Contributing

Contributions, issues, and feature requests are welcome!


Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.
  • net8.0

    • No dependencies.
  • net9.0

    • No dependencies.

NuGet packages (10)

Showing the top 5 NuGet packages that depend on LinKit.Core:

Package Downloads
LinKit.Grpc

Source-generated gRPC server and client adapters for LinKit CQRS requests.

O24OpenAPI.Framework

Package Description

LinKit.Messaging.RabbitMQ

RabbitMQ producer and consumer integration for LinKit messaging abstractions.

LinKit.Messaging.Kafka

Apache Kafka producer and consumer integration for LinKit messaging abstractions.

LinKit.Data

Source-generated ADO.NET data access and additive schema synchronization for LinKit; selected paths are covered by Native AOT smoke tests.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
3.2.1 68 10/31/2026
3.2.0 69 10/30/2026
3.1.0 66 10/29/2026
3.0.0 197 10/24/2026
2.3.0 237 9/27/2026
2.2.6 786 6/29/2026
2.2.5 423 5/19/2026
2.2.4 615 4/3/2026
2.2.3 494 4/2/2026
2.2.0 459 4/1/2026
2.1.9 465 3/26/2026
2.1.8 459 3/15/2026
2.1.7 434 3/14/2026
2.1.6 503 2/21/2026
2.1.5 549 1/13/2026
2.1.4 522 1/12/2026
Loading failed