LowCodeHub.MinimalEndpoints 0.0.6

dotnet add package LowCodeHub.MinimalEndpoints --version 0.0.6
                    
NuGet\Install-Package LowCodeHub.MinimalEndpoints -Version 0.0.6
                    
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="LowCodeHub.MinimalEndpoints" Version="0.0.6" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="LowCodeHub.MinimalEndpoints" Version="0.0.6" />
                    
Directory.Packages.props
<PackageReference Include="LowCodeHub.MinimalEndpoints" />
                    
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 LowCodeHub.MinimalEndpoints --version 0.0.6
                    
#r "nuget: LowCodeHub.MinimalEndpoints, 0.0.6"
                    
#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 LowCodeHub.MinimalEndpoints@0.0.6
                    
#: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=LowCodeHub.MinimalEndpoints&version=0.0.6
                    
Install as a Cake Addin
#tool nuget:?package=LowCodeHub.MinimalEndpoints&version=0.0.6
                    
Install as a Cake Tool

LowCodeHub.MinimalEndpoints

A comprehensive utility library for ASP.NET Core Minimal APIs — modular endpoint registration, validation and logging filters, language/timezone awareness, JSON localization, in-memory and Redis-backed event bus, CQRS-lite operation dispatcher, ProblemDetails helpers, and a global exception handler. Everything you need to build production Minimal APIs without boilerplate.

NuGet License: MIT

Why This Library?

Feature LowCodeHub.MinimalEndpoints Raw Minimal APIs MediatR
Modular registration Reflection scanner over marker or explicit assemblies Manual MapGet/Post Manual
Validation Endpoint filter — sync + async Manual Pipeline behavior
Operations (CQRS) Built-in IOperation + ErrorOr<T> Manual Full MediatR
Event bus In-memory + Redis with DLQ Manual INotification
Manual mapper IMapper with DI handler registration Manual AutoMapper
Localization JSON-based IStringLocalizer Resource files N/A
Language/timezone Middleware — header-driven context Manual N/A
Correlation ID Middleware — multi-header support Manual N/A
Success responses Return your model or ASP.NET Core typed results Results.Ok() etc. N/A
Failure responses RFC 9457 ProblemDetails with IProblemDetailsService Manual N/A
Observability OpenTelemetry metrics + traces for event bus Manual N/A

Installation

dotnet add package LowCodeHub.MinimalEndpoints

Quick Start

Discovery is reflection-based. You can scan the assembly that contains a marker type, or pass explicit assemblies when endpoints and handlers live in multiple projects.

using LowCodeHub.MinimalEndpoints.Extensions;

// Register only the pieces your app uses.
builder.Services.AddValidators<Program>();
builder.Services.AddOperations<Program>();
builder.Services.AddManualMapper<Program>();
builder.Services.AddDualMappers<Program>();

// Or pass multiple feature assemblies explicitly to each registration method.
// builder.Services.AddOperations(
//     typeof(UsersModule).Assembly,
//     typeof(OrdersModule).Assembly);

// Choose your event-bus backend.
builder.Services.AddEventBus();                  // in-memory
// OR
builder.Services.AddRedisEventBus(o =>           // multi-instance
{
    o.ConnectionString = "localhost:6379";
    o.QueueKey = "domain-events";
});

builder.Services.AddCorrelationId();
builder.Services.AddLanguageAwareness(defaultLanguage: "en");
builder.Services.AddTimeZoneAwareness();
builder.Services.AddTimeZoneAwareJson();         // optional — every DateTime[Offset] projects into caller's TZ
builder.Services.AddDefault500ExceptionHandler();

var app = builder.Build();

app.UseExceptionHandler();
app.UseCorrelationId();
app.UseLanguageAwareness();
app.UseTimeZoneAwareness();

// Maps every IModule in the API assembly.
app.MapModules<Program>();

// Or map multiple feature assemblies explicitly.
// app.MapModules(
//     typeof(UsersModule).Assembly,
//     typeof(OrdersModule).Assembly);

Table of Contents


Modular Endpoint Registration

Define modules implementing IModule. Implementations are mapped by the reflection scanner using app.MapModules<TScanner>() or app.MapModules(...).

public sealed class OrdersModule : IModule
{
    public static void AddRoutes(IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/orders")
            .AddLogging();

        group.MapEndpoint<GetOrdersEndpoint>();
        group.MapEndpoint<CreateOrderEndpoint>();
    }
}

Use IMinimalEndpoint for endpoint classes that are owned by a module. Endpoint classes are not scanned globally; this keeps grouping, prefixes, authorization, filters, and OpenAPI metadata under the module's control.

public sealed class GetOrdersEndpoint : IMinimalEndpoint
{
    public static void AddRoute(IEndpointRouteBuilder app)
    {
        app.MapGet("/", () => Results.Ok());
    }
}

public sealed class CreateOrderEndpoint : IMinimalEndpoint
{
    public static void AddRoute(IEndpointRouteBuilder app)
    {
        app.MapPost("/", (CreateOrderRequest request) => Results.Created())
           .AddValidator<CreateOrderRequest>();
    }
}

Endpoint Filters

Validation Filter

Register validators with AddValidators<TScanner>() or AddValidators(...). Apply them as endpoint filters:

// Implement a validator
public sealed class CreateOrderValidator : IMinimalValidator<CreateOrderRequest>
{
    public IEnumerable<ValidationFailure> Validate(CreateOrderRequest request)
    {
        if (string.IsNullOrWhiteSpace(request.Name))
            yield return new ValidationFailure("Name", "Name is required");
    }
}

// Async validators are also supported
public sealed class UniqueOrderValidator : IAsyncMinimalValidator<CreateOrderRequest>
{
    public async IAsyncEnumerable<ValidationFailure> ValidateAsync(
        CreateOrderRequest request, CancellationToken ct)
    {
        if (await _repo.ExistsAsync(request.Name, ct))
            yield return new ValidationFailure("Name", "Order already exists");
    }
}
// Apply to endpoints
app.MapPost("/orders", CreateOrder)
   .AddValidator<CreateOrderRequest>();

Logging Filter

Adds structured request logging with timing, status code, and a logger scope enriched with the correlation ID, language and timezone of the current request. Failures (exceptions thrown by the handler) are logged with the elapsed time and re-thrown so the global exception handler can take over.

// Per-endpoint
app.MapPost("/orders", CreateOrder).AddLogging();

// Group-wide (every endpoint underneath inherits the filter)
app.MapGroup("/orders").AddLogging()
   .MapPost("/", CreateOrder);

// Opt out for a noisy endpoint
[NoLogging]
public static IResult Health() => Results.Ok();

Output looks like:

info: LowCodeHub.MinimalEndpoints.Endpoint[0]
      POST /orders → 201 in 12.4 ms (HTTP: POST /orders)
      EndpointName: HTTP: POST /orders, CorrelationId: 0d…, Language: ar, TimeZone: Africa/Cairo

The legacy generic form AddLogging<TEndpoint>() still works for backward compatibility — but new code should prefer the parameterless form because the endpoint display name is captured in the log scope automatically.


Operations (CQRS-Lite)

The operations pattern provides a structured way to define units of work that return ErrorOr<TResult>. Each operation is a self-contained handler with single responsibility, dispatched through a single IOperation service.

Define Request and Handler

// Request defines the return type
public record CreateOrderRequest(string Name, List<Item> Items) : IOperationRequest<OrderDto>;

// One handler per operation
public class CreateOrderHandler(IOrderRepository repo) : IOperationHandler<CreateOrderRequest, OrderDto>
{
    public async Task<ErrorOr<OrderDto>> HandleAsync(
        CreateOrderRequest request, CancellationToken ct)
    {
        if (await repo.ExistsAsync(request.Name, ct))
            return Error.Conflict("Order.Duplicate", "Order already exists");

        var order = await repo.CreateAsync(request, ct);
        return new OrderDto(order.Id, order.Name);
    }
}

Operation handlers are registered by AddOperations<TScanner>() or AddOperations(...).

Use in Endpoints

app.MapPost("/orders", async (CreateOrderRequest req, IOperation op, CancellationToken ct) =>
{
    var result = await op.ExecuteAsync(req, ct);

    return result.IsError
        ? result.FirstError.ToProblem()                                              // RFC 9457 ProblemDetails
        : TypedResults.Created($"/orders/{result.Value.Id}", result.Value);
});

Chain Operations

Short-circuit on first error using ThenAsync:

app.MapPost("/orders", async (CreateOrderRequest req, IOperation op, CancellationToken ct) =>
{
    var result = await op.ExecuteAsync(req, ct)
        .ThenAsync(order => op.ExecuteAsync(new SendConfirmationRequest(order.Id), ct));

    return result.IsError
        ? result.ToProblem()
        : TypedResults.Ok();
});

Responses — success vs. failure

The package draws a hard line between the two:

  • Success → your model or ASP.NET Core typed results — return the payload directly when the default 200 OK is enough, or use TypedResults.Ok(value), TypedResults.Created(location, value), TypedResults.Accepted(location, value), and TypedResults.NoContent() when you need a specific status.
  • Failure → ProblemDetails (RFC 9457)Error.ToProblem(), .ToBadRequest(), .ToNotFound(), .ToConflict(), .ToUnprocessableEntity(), .ToBusinessFailure(), .ToUnauthorized(), .ToForbidden(), .ToLocked(), .ToInternalServerError().
// Success
order;                                             // Minimal APIs serialize returned models as 200 OK
TypedResults.Ok(order);
TypedResults.Created($"/orders/{order.Id}", order);
TypedResults.NoContent();

// Failure — every helper writes through IProblemDetailsService so app-wide CustomizeProblemDetails fires.
error.ToProblem();              // status auto-mapped from Error.Type
error.ToBadRequest();
error.ToNotFound();
error.ToConflict();
error.ToUnprocessableEntity();
error.ToBusinessFailure();      // alias for 422

// ErrorOr<T> shortcut
result.ToProblem();             // == result.FirstError.ToProblem()

The status auto-mapping for ToProblem() follows the standard ErrorOr → HTTP convention: Validation → 400, Unauthorized → 401, Forbidden → 403, NotFound → 404, Conflict → 409, Failure → 422, Unexpected → 500.


Domain Event Bus

In-Memory Event Bus

builder.Services.AddEventBus();
  • Bounded channel for backpressure
  • Hosted background processor
  • Handlers can be registered with AddEventBus<TScanner>() or AddEventBus(assemblies)
// Define events and handlers
public record OrderCreated(Guid OrderId) : IDomainEvent;

public sealed class OrderCreatedHandler : IDomainEventHandler<OrderCreated>
{
    public ValueTask Handle(OrderCreated domainEvent, CancellationToken ct)
    {
        // Handle event...
        return ValueTask.CompletedTask;
    }
}

// Publish
await eventBus.Publish(new OrderCreated(orderId), ct);

Redis-Backed Event Bus

For multi-instance deployments:

builder.Services.AddRedisEventBus(options =>
{
    options.ConnectionString = "localhost:6379";
    options.QueueKey = "domain-events";
    options.ProcessingQueueKey = "domain-events:processing";
    options.PopTimeoutSeconds = 5;
    options.MaxRetryAttempts = 3;
});

Redis mode includes:

  • Reliable processing queue (BRPOPLPUSH)
  • Ack on success
  • Nack + requeue on failure

Dead Letter Queue

Map admin endpoints for inspecting and reprocessing failed events:

app.MapDeadLetterEndpoints("/admin/dlq");

Provides GET (list), DELETE (remove), and POST (reprocess) endpoints.


Event Bus Health Check

The Redis-backed event bus registers a health check automatically:

Check Tags
eventbus-redis eventbus, redis, readiness
app.MapHealthChecks("/health/ready", new() { Predicate = hc => hc.Tags.Contains("readiness") });

The health check verifies that the Redis connection is available. The in-memory event bus does not register a health check.


Event Bus Observability

Built-in OpenTelemetry instrumentation under the LowCodeHub.MinimalEndpoints.EventBus source:

Metrics
Metric Type Description
eventbus.events.published Counter Domain events published to the bus
eventbus.events.publish.failed Counter Events that failed to publish
eventbus.events.processed Counter Events successfully processed by all handlers
eventbus.events.failed Counter Events where at least one handler failed
eventbus.events.retried Counter Events requeued for retry
eventbus.events.deadlettered Counter Events sent to the DLQ after exceeding max retries
eventbus.events.processing.duration Histogram (ms) Handler processing duration
Traces
Activity Kind Description
eventbus.publish Producer Publishing a domain event
eventbus.process Consumer Processing a domain event
builder.Services.AddOpenTelemetry()
    .WithTracing(t => t.AddSource("LowCodeHub.MinimalEndpoints.EventBus"))
    .WithMetrics(m => m.AddMeter("LowCodeHub.MinimalEndpoints.EventBus"));

Manual Mapper

A lightweight DI-based mapping system for converting between types without reflection-based mappers. All handlers are auto-discovered from assemblies at startup.

IMapper and Handlers

Define mapping logic by implementing IMapHandler<TSource, TDestination> (sync) or IMapAsyncHandler<TSource, TDestination> (async):

// Sync handler
public sealed class OrderToOrderDtoHandler : IMapHandler<Order, OrderDto>
{
    public OrderDto Handler(Order source)
        => new OrderDto(source.Id, source.Name, source.Total);
}

// Async handler (e.g., needs DB lookup)
public sealed class UserToUserDtoHandler : IMapAsyncHandler<User, UserDto>
{
    private readonly IPermissionRepository _repo;

    public UserToUserDtoHandler(IPermissionRepository repo) => _repo = repo;

    public async Task<UserDto> Handler(User source, CancellationToken ct)
    {
        var permissions = await _repo.GetPermissionsAsync(source.Id, ct);
        return new UserDto(source.Id, source.Name, permissions);
    }
}

Map handlers are registered by AddManualMapper<TScanner>() or AddManualMapper(...). Inject IMapper and call Map or MapAsync:

public class OrderService(IMapper mapper)
{
    public OrderDto ToDto(Order order)
        => mapper.Map<Order, OrderDto>(order);

    public async Task<UserDto> ToDtoAsync(User user, CancellationToken ct)
        => await mapper.MapAsync<User, UserDto>(user, ct);
}

IDualMapper

For bidirectional mapping between two types, implement IDualMapper<TFrom, TTo>:

public sealed class OrderDualMapper : IDualMapper<Order, OrderDto>
{
    public OrderDto MapTo(Order from)
        => new OrderDto(from.Id, from.Name, from.Total);

    public Order MapFrom(OrderDto to)
        => new Order { Id = to.Id, Name = to.Name, Total = to.Total };
}

Dual mappers are registered by AddDualMappers<TScanner>() or AddDualMappers(...). Inject the specific IDualMapper<TFrom, TTo> interface:

public class OrderController(IDualMapper<Order, OrderDto> mapper)
{
    public OrderDto ToDto(Order order) => mapper.MapTo(order);
    public Order FromDto(OrderDto dto) => mapper.MapFrom(dto);
}

Correlation ID

Track requests across services with correlation IDs:

builder.Services.AddCorrelationId(options =>
{
    options.HeaderNames = ["X-Correlation-ID", "X-Request-ID"];
    options.ResponseHeaderName = "X-Correlation-ID";
});

app.UseCorrelationId();

If no header is present, a new correlation ID is generated. Access it anywhere via CorrelationContext.CorrelationId.


Language Awareness

Reads language from request headers and sets the request culture. Honours full RFC 7231 Accept-Language syntax — comma-separated languages with q-values are ranked correctly, and an optional allowlist constrains the result to languages your app actually supports.

// Simple form
builder.Services.AddLanguageAwareness(defaultLanguage: "en");

// Full options
builder.Services.AddLanguageAwareness(opts =>
{
    opts.DefaultLanguage = "en";
    opts.SupportedLanguages = ["en", "ar", "fr"];   // anything else falls back to default
    opts.NormalizeToBaseLanguage = true;             // ar-EG → ar (default), set false for pt-BR vs pt-PT
    opts.HeaderNames = ["Language", "Accept-Language", "X-Culture"];
});

app.UseLanguageAwareness();

Access the current language via LanguageContext.Language (never null — falls back to the configured default).

The library also registers LanguageRequestCultureProvider, an ASP.NET Core IRequestCultureProvider that resolves the request culture from the same headers and applies the same allowlist. It plugs into the .NET localization pipeline when UseJsonLocalization or UseRequestLocalization is used.


Timezone Awareness

Reads the timezone from request headers and stores it in request context. The resolver accepts both IANA IDs (Africa/Cairo) and Windows IDs (Egypt Standard Time) — and translates between them — so the same client code works regardless of whether the server runs on Linux or Windows. Fixed offsets like +03:00 and the literals UTC / GMT / Z are also supported.

// Simple form — uses defaults: ["X-TimeZone", "TimeZone"]
builder.Services.AddTimeZoneAwareness();

// Custom headers
builder.Services.AddTimeZoneAwareness("X-TimeZone", "TimeZone");

// Full options
builder.Services.AddTimeZoneAwareness(opts =>
{
    opts.HeaderNames = ["X-TimeZone"];
    opts.DefaultZone = TimeZoneInfo.FindSystemTimeZoneById("Africa/Cairo");
});

app.UseTimeZoneAwareness();

Access the current timezone via TimeZoneContext.Zone (or .TimeZone — same thing).

For a higher-level API, inject IRequestClock — it gives you UTC, request-local time, and conversions all using the request's timezone:

public sealed class CreateOrderHandler(IRequestClock clock) : IOperationHandler<CreateOrderRequest, OrderDto>
{
    public Task<ErrorOr<OrderDto>> HandleAsync(CreateOrderRequest req, CancellationToken ct)
    {
        var localCutoff = clock.Now.Date.AddHours(17);   // 5pm in the caller's timezone
        // …
    }
}

Timezone-aware JSON

One-line wire-up — every DateTime / DateTime? / DateTimeOffset / DateTimeOffset? in your API response is projected into the caller's timezone:

builder.Services.AddTimeZoneAwareJson();

If you want this to be opt-in per property instead of global, decorate the property with [TimeZoneAware] and register the resolver:

builder.Services.AddTimeZoneAwareJsonResolver();

public record OrderDto(Guid Id, [property: TimeZoneAware] DateTime CreatedAt);

When deserializing, naive timestamps without an offset are interpreted in the request's timezone (rather than silently treated as UTC), so a payload of "2026-05-04T17:00:00" from a client in Africa/Cairo becomes 2026-05-04T17:00:00+03:00 on the server.


JSON Localization

JSON-based IStringLocalizer implementation:

builder.Services.AddJsonLocalization(options =>
{
    options.ResourcesPath = "Resources";
});

app.UseJsonLocalization("en", "ar");

Resource files:

  • Resources/en.json
  • Resources/ar.json

Supports nested keys with dot notation:

{
  "Errors": {
    "USER_NOT_FOUND": "User not found"
  }
}

Access: localizer["Errors.USER_NOT_FOUND"]


OpenAPI helpers

Document the standard error responses the package's filters and exception handler emit:

app.MapPost("/orders", CreateOrder)
   .AddValidator<CreateOrderRequest>()
   .WithStandardProblemResponses();        // adds 400, 401, 403, 404, 409, 422, 500

Or pick individual ones:

app.MapGet("/orders/{id}", GetOrder)
   .ProducesProblem(404)
   .ProducesProblem(401);

Global Exception Handler

Register a ProblemDetails-based global exception handler:

builder.Services.AddDefault500ExceptionHandler();
// …
app.UseExceptionHandler();

Unhandled exceptions are caught and returned as RFC 9457 ProblemDetails with a 500 status code — no stack-trace leakage in production. The handler routes through IProblemDetailsService, so any app-wide services.AddProblemDetails(opts => opts.CustomizeProblemDetails = …) callbacks you register will run on these responses too. The validation filter follows the same path, so your ProblemDetails customizations apply uniformly.


How It Works

┌─────────────────────────────────────────────────────────┐
│  ASP.NET Core Minimal API Pipeline                      │
│                                                         │
│  ┌─── Middleware ───────────────────────────────────┐   │
│  │ CorrelationId → Language → TimeZone              │   │
│  └──────────────────────────────────────────────────┘   │
│                                                         │
│  ┌─── Endpoint Filters ────────────────────────────┐   │
│  │ Validation Filter → Logging Filter               │   │
│  └──────────────────────────────────────────────────┘   │
│                                                         │
│  ┌─── Endpoint Handler ───────────────────────────┐    │
│  │ IOperation.ExecuteAsync(request)                │    │
│  │     ↓                                           │    │
│  │ IOperationHandler<TReq, TResult>.HandleAsync()  │    │
│  │     ↓                                           │    │
│  │ ErrorOr<TResult>                                │    │
│  │     ↓                                           │    │
│  │ TypedResults.Ok(model) / Error.ToProblem()      │    │
│  └─────────────────────────────────────────────────┘    │
│                                                         │
│  ┌─── Background ─────────────────────────────────┐    │
│  │ EventBus (In-Memory / Redis)                    │    │
│  │ → IDomainEventHandler<TEvent>                   │    │
│  └─────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────┘

Requirements

  • .NET 10 or later
  • ErrorOr 2.0+ (included as a dependency)
  • StackExchange.Redis 2.12+ (included — required for Redis event bus)

License

MIT © Ahmed Abuelnour

Product Compatible and additional computed target framework versions.
.NET 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.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on LowCodeHub.MinimalEndpoints:

Package Downloads
LowCodeHub.MinimalEndpoints.Mcp

Model Context Protocol integration for LowCodeHub.MinimalEndpoints, exposing opt-in API contracts as MCP tools over ASP.NET Core Streamable HTTP.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.0.6 122 5/18/2026
0.0.5 98 5/13/2026
0.0.4 102 5/12/2026
0.0.3 151 4/23/2026
0.0.2 1,434 3/27/2026
0.0.1 107 3/26/2026