LowCodeHub.MinimalEndpoints
0.0.6
dotnet add package LowCodeHub.MinimalEndpoints --version 0.0.6
NuGet\Install-Package LowCodeHub.MinimalEndpoints -Version 0.0.6
<PackageReference Include="LowCodeHub.MinimalEndpoints" Version="0.0.6" />
<PackageVersion Include="LowCodeHub.MinimalEndpoints" Version="0.0.6" />
<PackageReference Include="LowCodeHub.MinimalEndpoints" />
paket add LowCodeHub.MinimalEndpoints --version 0.0.6
#r "nuget: LowCodeHub.MinimalEndpoints, 0.0.6"
#:package LowCodeHub.MinimalEndpoints@0.0.6
#addin nuget:?package=LowCodeHub.MinimalEndpoints&version=0.0.6
#tool nuget:?package=LowCodeHub.MinimalEndpoints&version=0.0.6
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.
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
- Endpoint Filters
- Operations (CQRS-Lite)
- Typed Results
- Domain Event Bus
- Manual Mapper
- Correlation ID
- Language Awareness
- Timezone Awareness
- JSON Localization
- Global Exception Handler
- How It Works
- Requirements
- License
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), andTypedResults.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>()orAddEventBus(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.jsonResources/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
ErrorOr2.0+ (included as a dependency)StackExchange.Redis2.12+ (included — required for Redis event bus)
License
MIT © Ahmed Abuelnour
| Product | Versions 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. |
-
net10.0
- ErrorOr (>= 2.1.1)
- StackExchange.Redis (>= 2.13.1)
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.