Hmz.Core.SharedKernel
0.1.19
dotnet add package Hmz.Core.SharedKernel --version 0.1.19
NuGet\Install-Package Hmz.Core.SharedKernel -Version 0.1.19
<PackageReference Include="Hmz.Core.SharedKernel" Version="0.1.19" />
<PackageVersion Include="Hmz.Core.SharedKernel" Version="0.1.19" />
<PackageReference Include="Hmz.Core.SharedKernel" />
paket add Hmz.Core.SharedKernel --version 0.1.19
#r "nuget: Hmz.Core.SharedKernel, 0.1.19"
#:package Hmz.Core.SharedKernel@0.1.19
#addin nuget:?package=Hmz.Core.SharedKernel&version=0.1.19
#tool nuget:?package=Hmz.Core.SharedKernel&version=0.1.19
Hmz.Core.SharedKernel
Hmz.Core.SharedKernel 0.1.14 targets .NET 10 and provides reusable application, domain-support, EF Core, advanced-search, authorization, and ASP.NET Core boundary contracts.
Results and errors
Use Result and Result<T> for expected application failures:
using Hmz.Core.SharedKernel.Results;
public Result<Order> CreateOrder(string customerId)
{
if (string.IsNullOrWhiteSpace(customerId))
{
return Result.Failure<Order>(Error.Validation(
"order.customer-required",
"Customer is required.",
"customerId"));
}
return Result.Success(new Order(customerId));
}
Errors carry a stable code, safe message, transport-neutral category, and optional validation target. Failed results may contain multiple errors of one category. Use Match, Map, and Bind for composition. Unexpected failures remain exceptions and are handled at the application boundary.
Generate diagnostic codes with ErrorCode.Create:
public static readonly string ProductItemsNotFound = ErrorCode.Create(
"CM",
ErrorType.NotFound,
"product-items");
// CM.NOTF.PRODUCT-ITEMS
Use a short, stable application/module prefix. Do not repeat the error type in the semantic suffix.
Pagination
using Hmz.Core.SharedKernel.Contracts.Pagination;
return new PagedResponse<OrderResponse>(items, page, pageSize, totalCount);
Messaging and identity
Hmz.Core.SharedKernel.Messaging provides ICommand, IQuery, INotification, and corresponding handler contracts over Mediator abstractions.
Hmz.Core.SharedKernel.Identity.Abstractions.ICurrentUser is the shared current-user boundary. Applications own authentication, claims mapping, profile lookup, and authorization policy.
Integration events and messages
The Messaging sub-namespaces add cross-module communication for modular
monoliths. They distinguish two kinds of communication:
| Integration event | Message | |
|---|---|---|
| Meaning | A fact that already happened in one module | A notification that only matters now |
| Durability | Written to the producer's outbox in the same transaction as the state change; kept and replayable | Not persisted; lost if the process stops |
| Delivery | At least once, with retry, backoff, and dead-lettering; consumers are idempotent through an inbox | At most once, no retry |
| Use for | Keeping modules consistent | Cache invalidation, live notifications, telemetry |
| Namespace | Messaging.IntegrationEvents, .Outbox, .Inbox, .Transport |
Messaging.Messages |
Domain events (EventDispatcherInterceptor) remain in-process, after-commit
side effects inside one module and are not a substitute for either.
Defining and publishing an integration event
[IntegrationEventName("media.attachments-changed.v1")] // stable wire name; version it, never rename it
public sealed record MediaAttachmentsChanged(...) : IntegrationEvent;
// UseCase: each module exposes its own outbox interface, like its unit of work.
public interface ICatalogOutbox : IIntegrationEventOutbox;
outbox.Add(new MediaAttachmentsChanged(...)); // tracked only
await unitOfWork.SaveChangesAsync(ct); // commits the row change and the outbox row together
Infrastructure wires the outbox into the module DbContext:
// OnModelCreating
modelBuilder.AddOutboxMessages(); // producer: <schema>.OutboxMessages
modelBuilder.AddInboxMessages(); // consumer: <schema>.InboxMessages
// DbContext options
options.AddOutboxNotifications(serviceProvider); // wakes the processor right after commit
// Services
services.AddIntegrationEventOutbox<AppDbContext>(configuration);
services.AddScoped<ICatalogOutbox, CatalogOutbox>(); // : EfIntegrationEventOutbox<AppDbContext>
services.AddIntegrationEventHandler<MediaAttachmentsChanged, Handler, MediaDbContext>(configuration);
Delivery flow
- A request handler changes state and calls
outbox.Add(event); oneSaveChangesAsynccommits both rows. OutboxNotificationInterceptorsignals the module'sOutboxProcessor<TDbContext>after the commit (it also polls everyMessaging:Outbox:PollInterval).- The processor leases a batch in one short statement
(
UPDATE … SET "LockedUntil" … WHERE "Id" IN (SELECT … FOR UPDATE SKIP LOCKED) RETURNING *) and commits. No transaction stays open during delivery, any number of hosts can run processors, and rows of a crashed host become claimable when their lease expires. - Events are delivered outside any transaction, up to
Messaging:Outbox:DeliveryConcurrencyat a time, through the transport selected byMessaging:Transport. The built-inInMemoryIntegrationEventTransportis a bounded channel that completes only when every subscriber acknowledges; broker transports complete on the broker's publisher confirm. Each outcome is written back individually and releases the lease; a late failure never overwrites another host's success. IntegrationEventDispatcherruns each subscription in its own DI scope. The inbox wrapper opens a transaction on the consumer's DbContext, skips events already in its inbox, runs the handler, and records the inbox row in the same transaction.- On success the outbox row is marked processed. On failure it is retried
with exponential backoff up to
Messaging:Outbox:MaxAttempts, then dead-lettered. Processed rows are kept forProcessedRetentionandOutboxReplayer<TDbContext>can requeue them; consumers that already handled a replayed event skip it.
Handlers run in background scopes without an HTTP user, so they must not
depend on ICurrentUser; carry the actor in the event if it matters.
Events published while a handler runs inherit its correlation id and record
the handled event as their CausationId (IntegrationEventScope); outside a
handler the correlation id is the current trace id.
Module-private events
A module can publish an event that only it consumes: write it through its own
EfIntegrationEventOutbox<ModuleDbContext> and subscribe with
AddIntegrationEventHandler<E, H, ModuleDbContext>. Retry, backoff,
dead-lettering and inbox deduplication work as for cross-module events.
- Register one handler per private event. The dispatcher fails the whole envelope when any subscriber fails, so a second handler would be retried with it.
- Nothing marks an event as local yet.
OutboxProcessorsends every row to the singleIIntegrationEventTransport, so a broker transport would publish private events too. The first external transport needs a local-only marker that keeps them in process. - A handler runs inside the inbox transaction on the module's DbContext, which
InboxSubscriptionopens before it calls the handler. That one transaction saves the handler's changes and the inbox row, so any call the handler makes to another system holds the transaction open. Keep it to one short call, or move bulk external work to a job that reads outside any transaction.
Transports and scaling out
// A broker package registers its transport and its own consumer host:
services.AddIntegrationEventTransport<RabbitMqIntegrationEventTransport>("RabbitMq");
// The consumer host delivers each queue to one consumer, so queues retry independently:
await dispatcher.DispatchToConsumerAsync(envelope, consumerName, cancellationToken);
Switch environments with Messaging:Transport (InMemory by default); an
unregistered name fails at startup resolution with a clear error. Broker
consumer hosts can enumerate IntegrationEventRegistry.All to declare one
queue per consumer.
Observability
Register MessagingTelemetry.Name (Hmz.Core.Messaging) with OpenTelemetry
AddSource and AddMeter. Activities: deliver {event} (producer) and
handle {event} (consumer) tagged with message id, correlation id, causation
id, consumer, and attempt. Metrics: hmz.messaging.outbox.delivered,
.failed, .dead_lettered, hmz.messaging.outbox.delivery.duration (ms),
hmz.messaging.inbox.handled, and hmz.messaging.inbox.duplicates.
Fire-and-forget messages
services.AddMessageHandler<ProductCacheInvalidated, InvalidateProductCache>();
messageBus.Publish(new ProductCacheInvalidated(productId)); // returns false when dropped
Configuration
| Key | Default | Purpose |
|---|---|---|
Messaging:Enabled |
true |
Runs processors, transports, and housekeeping; disable for tooling-only hosts. |
Messaging:Transport |
InMemory |
Registered transport name to use. |
Messaging:Outbox:LeaseDuration |
00:02:00 |
Claim lease; keep above the slowest delivery. |
Messaging:Outbox:DeliveryConcurrency |
1 |
Deliveries in flight per processor; 1 keeps per-instance order. |
Messaging:Inbox:Retention |
45.00:00:00 |
Inbox purge age; must be at least ProcessedRetention. |
Messaging:Outbox:PollInterval |
00:00:05 |
Fallback poll when no commit signal arrives. |
Messaging:Outbox:BatchSize |
50 |
Rows claimed per batch. |
Messaging:Outbox:MaxAttempts |
10 |
Attempts before dead-lettering. |
Messaging:Outbox:MaxBackoff |
00:05:00 |
Backoff cap. |
Messaging:Outbox:ProcessedRetention |
30.00:00:00 |
Replay window before processed rows are purged. |
Messaging:TransportCapacity / TransportConcurrency |
256 / 4 |
In-memory transport queue and consumers. |
Messaging:MessageBusCapacity |
10000 |
Fire-and-forget queue length before messages are dropped. |
The outbox claim query uses PostgreSQL SKIP LOCKED syntax. Applications own
the outbox and inbox migrations in each module schema.
Known limitations (deferred)
Recorded during the 0.1.15 review and intentionally not implemented
yet; none can deadlock, and the first only matters with several instances.
- Lease per batch. A batch shares one lease taken at claim time. With
several instances and sequential delivery, rows late in a large batch can
outlive
LeaseDurationand be redelivered by another instance (the inbox absorbs it). Planned: renew the lease per message before delivery. - Claim sort under backlog. The pending index leads with
NextAttemptAtwhile the claim orders byOccurredAt, so a large backlog is sorted on each poll. Planned: a pending partial index on(OccurredAt, Id). - Unbatched retention deletes. Outbox and inbox purges run one delete per sweep. Planned: bounded batch deletes.
Secret comparison
Use FixedTimeSecretComparer.Equals(expected, actual) for application-owned
API keys, webhook signatures, and similar shared-secret checks. The helper
hashes both values to fixed-size buffers before using
CryptographicOperations.FixedTimeEquals, so differing input lengths do not
reintroduce an early-exit comparison.
Keep secret resolution and rotation in the consuming application. This helper does not read configuration, log values, define an authentication scheme, or replace a real identity provider when user identity and authorization are required.
Security also provides RandomCode.Create(alphabet, length) (cryptographically
random codes drawn uniformly from an alphabet) and Sha256Hex.Hash(value)
(lower-case hex SHA-256 of the UTF-8 bytes, Sha256Hex.Length characters), for
example to issue a one-time code and store only its hash. Sha256Hex.Hash(bytes)
hashes raw bytes. Sha256Hex.HashJson(value, serializerOptions) hashes the UTF-8
JSON of a value, for a fingerprint of stored content or an opaque ETag. Pass the
same options every time, and normalize decimals first with
Serialization.DecimalNormalizationExtensions.WithoutTrailingZeros():
150.0000m from a numeric(18,4) column and 150m are equal but serialize
differently.
Internal API keys
Http.Authentication authenticates internal callers (another service, a
worker, a scheduled job) with an API key, for internal APIs where a full
OAuth2 client-credentials flow is not worth it or no identity provider runs.
Members keep signing in through the identity provider.
A key reads <prefix>_<keyId>_<secret> (default prefix hmz). The key id is
public and finds the stored entry; the secret carries over 256 random bits
and only its SHA-256 hash is stored, so a leaked configuration or database
holds no usable key. Create a key once and hand Value to the client:
var key = ApiKey.Create(); // key.Value for the client, shown once
// store key.KeyId and key.SecretHash
The default store reads Authentication:ApiKeys (validated on start; keep
the values in the secret store, not in source control):
{ "Authentication": { "ApiKeys": { "Keys": {
"<keyId>": { "ClientId": "billing-worker", "SecretHash": "<64 hex>", "ExpiresAt": null }
} } } }
Rotate by adding the new key beside the old one, switching the client, then
removing the old entry. Register your own IApiKeyStore before AddApiKey()
to keep keys in a database.
builder.Services.AddAuthentication(/* the user scheme stays the default */)
.AddApiKey();
builder.Services.AddAuthorizationBuilder().AddInternalApiPolicy();
builder.Services.AddOpenApi(options => options.AddApiKeySecurity());
app.MapGroup("/internal").RequireInternalApiKey();
A request without the App-Api-Key header gets no result, so other schemes
still run; a malformed, unknown, expired or wrong key fails. Application
headers carry no X- prefix (deprecated by RFC 6648); another name is set
with AddApiKey(options => options.HeaderName = "...") and the same name
passed to AddApiKeySecurity(headerName: ...). The principal's
sub and name are the client id and api_key_id holds the key id for audit
logs. In an application that also signs in users, keep the user scheme as
the default: then a key only reaches endpoints that require the InternalApi
policy. In a service where the key is the only scheme, make it the default
and pass allProtectedOperations: true to AddApiKeySecurity.
To give a client more claims (roles, permissions), derive from
ApiKeyIdentityResolver, call the base and add claims, and register it as
IApiKeyIdentityResolver (before AddApiKey(), or replace the registration
afterwards). The resolver is asynchronous, so it can load the client's grants
from a database.
Permission authorization
Hmz.Core.SharedKernel.Authorization provides transport-neutral permission
contracts. Keep ICurrentUser focused on identity and populate a
request-scoped IPermissionSnapshotAccessor from application data after the
external identity has been resolved.
var snapshot = new PermissionSnapshot(
userId,
permissions.ToHashSet(StringComparer.Ordinal),
userPermissionStamp,
rolePermissionStamps);
permissionSnapshotAccessor.Set(snapshot);
PermissionChecker grants an exact permission or a deliberately configured
wildcard such as USER-MANAGEMENT.ROLES.*. Missing and empty snapshots deny
access. Applications should combine permissions from every assigned role
before constructing the snapshot.
The ASP.NET Core integration is under
Hmz.Core.SharedKernel.Http.Authorization:
services.AddPermissionAuthorization();
endpoint.RequirePermission(UserManagementPermissions.Roles.Manage);
Combine permissions with AND and OR through PermissionRule. A permission
code converts to a rule implicitly; each RequirePermission... call adds one
requirement and every requirement must pass, so chained calls are AND:
endpoint.RequireAllPermissions(Users.Read, Brands.Read); // A and B
endpoint.RequireAnyPermission(Product.Read, Product.Manage); // A or B
endpoint.RequireAnyPermission(Quotes.Approve, // A or (B and C)
PermissionRule.All(Quotes.Manage, Prices.Read));
endpoint.RequirePermission(Users.Read).RequirePermission(Brands.Read); // A and B
// Outside endpoints, against the current snapshot:
if (permissionChecker.Satisfies(PermissionRule.Any(Product.Read, Product.Manage))) { ... }
Rules validate their codes when built, render as text (A or (B and C)) for
documentation, and add PermissionRuleMetadata to the endpoint. Prefer a
dedicated permission over deep nesting when a combination describes a real job.
Use application-owned constants at endpoints rather than string literals.
Resource policies (resource- and attribute-based rules)
Permission checks answer "may this user do this kind of action at all". A resource policy answers "may this user do it to this record", combining the permission snapshot with the record (author, owner, status, amount) and the current user. Write one policy per resource type, in the feature folder of the use case that owns the resource:
public sealed class StoryPostPolicy(IPermissionChecker permissions, ICurrentUser user)
: IResourcePolicy<StoryPost>
{
public ValueTask<AccessDecision> EvaluateAsync(StoryPost post, string action, CancellationToken ct) =>
ValueTask.FromResult(action switch
{
StoryPostActions.Delete when post.AuthorId == user.UserId => AccessDecision.Allow(),
StoryPostActions.Delete when permissions.Satisfies(Feeds.Moderate) => AccessDecision.Allow(),
StoryPostActions.Delete => AccessDecision.Deny("Only the author or a moderator.", StoryErrorCodes.NotAuthor),
_ => AccessDecision.DenyUnknownAction(action),
});
}
services.AddResourcePolicy<StoryPost, StoryPostPolicy>();
// In a handler, a bot command or a background job:
var decision = await access.CheckAsync(post, StoryPostActions.Delete, ct);
if (!decision.Allowed) return Result.Failure(decision.ToError(StoryErrorCodes.NotAuthor));
- Child resources check their parent first:
AccessDecision.AllAsync(() => access.CheckAsync(comment.Post, "read", ct), () => OwnRules(comment))stops at the first denial. IResourceCapabilities<TResource, TCapabilities>returns what one record allows (for exampleCanEdit,CanReadCost). Query handlers use it to mask response fields, and clients use it to show actions.- Business invariants that hold for every actor (status transitions, totals,
separation of duties written into business law) belong in the domain model.
Policies decide only who may act. A long
actionswitch usually means status rules sit in the policy instead of the aggregate. Dispatch each action to a private method, and split a policy per action only when one action grows large. - Lists are filtered in the database (data scopes and named query filters), never row by row through a policy.
IAuthorizationService.AuthorizeAsync(user, resource, new ResourceActionRequirement(action))evaluates the same policy.IAccessDecisionObserverreceives every decision, for audit logs. Permission catalogs, role assignments, persistence, cache invalidation, and version-stamp resolution remain responsibilities of the owning application. Do not make JWT permission claims or a claims transformer the application permission source of truth.
Advanced search
Hmz.Core.SharedKernel.AdvancedSearch provides a validated, allow-listed query pipeline. Clients never supply raw Dynamic LINQ. Search values reach the database as parameters (UseParameterizedNamesInDynamicQuery), so EF Core reuses one compiled query per search shape instead of compiling one per value.
services.AddAdvancedSearch();
A feature owns its ISearchConfiguration, source query, projection, and optional external resolvers:
public ValueTask<PagedResponse<ProductListItem>> ExecuteAsync(
AdvancedSearchRequest request,
CancellationToken cancellationToken = default) =>
advancedSearch.ExecutePageAsync(
dbContext.Products.AsNoTracking(),
product => new ProductListItem(product.Id, product.Name, product.Price),
request,
ProductSearchConfiguration.Instance,
cancellationToken: cancellationToken);
The projection overload projects the source query before applying filters,
global search, ordering, counting, and paging. Therefore, MatchKey resolves
against the projected result type, not the persistence entity. This supports
client-facing field names, renamed fields, SmartEnum .Value members, and
aggregate values exposed by the projection while keeping the query
provider-translatable.
Important behavior:
- fields inside one global-search item are OR-ed;
- separate search items use the requested
SearchLogic; - filters default to AND;
- multiple positive values inside one filter are OR-ed;
- negative multi-value operators are AND-ed;
- external no-match results remain match-nothing;
- default ordering should end with a unique field;
- validation errors use exact request paths;
- scalar conversion and enum parsing use bounded, safe messages.
- filter
searchValuesaccepts JSON strings or numbers and normalizes both forms before typed validation.
TODO: Consider optimizing multi-value scalar equality filters to an SQL
IN-style value-collection Contains expression (and NOT IN for negative
filters). Keep the current expanded equality clauses as the compatibility
baseline until translation is verified across supported EF Core providers and
projection shapes.
AdvancedSearch.OpenApi supplies reusable metadata and a schema transformer. Endpoint-specific examples remain in the API feature that owns the search contract.
EF Core data support
Hmz.Core.SharedKernel.Data includes:
- auditable, soft-deletable, and row-version entity bases;
EfRepositoryBase<T>;IUnitOfWorkandEfUnitOfWork;- entity-configuration helpers;
- audit, soft-delete, and domain-event interceptors.
Repository mutation methods may only update the DbContext change tracker. Commit through the owning module's unit of work:
await repository.AddAsync(entity, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);
Keep provider-specific constraint interpretation and cross-context transaction policy in application Infrastructure.
EntityTypeConfigurationBuilderBase<TEntity, TId> configures row versions, soft delete (with the named filter) and audit columns for the interfaces an entity implements; derive every configuration from it and add only the entity's own mapping. Two helpers cover what remains common:
builder.Property(product => product.Status).IsRequired().HasSmartEnumConversion(); // stored as its value
builder.HasIndex(product => product.Sku).IsUnique().WhereNotDeleted(); // a deleted row frees the SKU
HasSmartEnumConversion<TEnum, TValue>() covers Smart Enums whose value is not an int.
Every query filter is named. A specification can bypass single filters with Query.IgnoreQueryFilters([names]) (from Data.Specifications); the others still apply. Ardalis' parameterless IgnoreQueryFilters() still drops all of them. EfRepositoryBase<T> evaluates specifications with HmzSpecificationEvaluator, which applies the named form for lists, projections, CountAsync and AnyAsync:
public sealed class DeletedNotesSpecification : Specification<Note>
{
public DeletedNotesSpecification() =>
Query.IgnoreQueryFilters([QueryFilterNames.SoftDelete]).Where(note => note.IsDeleted);
}
The filter name belongs to whoever declares the filter: QueryFilterNames for SharedKernel's own, an application constant for its data scopes.
Named query filters and data scopes
The soft-delete filter is named QueryFilterNames.SoftDelete. Applications add their own named filters for an interface, once, and SharedKernel applies them to every root entity type that implements it:
modelBuilder.ApplyQueryFilter<IBrandOwned>(
"Brand",
owned => !BrandScopeEnforced || owned.BrandId == ActiveBrandId);
Read request state from the DbContext (its own members, or an interface it implements): EF Core swaps that reference for the running context on each query, while any other captured value is frozen into the cached model.
Data.Scoping describes a partition such as a tenant or brand: IDataScope<TKey> is the request scope (IsEnforced, ScopeId), IDataScopeKind<TKey> names the filter and key property, and an entity implements IScopedEntity<TScope, TKey> once per scope it belongs to. DataScopeWriteGuardInterceptor<TScope, TKey> refuses to save a scoped entity outside the enforced partition (DataScopeViolationException); register it before the audit, soft-delete and domain-event interceptors.
Persistence errors
Use cases depend on the provider-neutral IPersistenceErrorClassifier from Data.Errors and its checks: IsUniqueViolation(ex, constraint?), IsForeignKeyViolation(ex, constraint?), IsStringTooLong, IsConcurrencyConflict and IsTransient. Register the PostgreSQL implementation once with services.AddPostgresPersistenceErrorClassifier(). Modules add constraint-specific checks as their own extension members (for example IsCollectionCodeUniqueViolation(ex)) rather than defining classifier interfaces.
Save interceptors
AuditableInterceptorBase and SoftDeleteInterceptorBase handle both SaveChanges and SaveChangesAsync. Derived interceptors override the before-save pair together, or the after-save pair together, and let ChangeTracker.Entries() run DetectChanges.
Demo data seeds through Data.Seeding: context.SeedIfEmptyAsync(() => samples) inserts only into an empty table (soft-deleted and other-scope rows count), and context.HasAnyRowsAsync<T>() guards seeds that build dependent rows. Reference data the application depends on is seeded item by item instead.
Validation helpers
Hmz.Core.SharedKernel.Validation has two layers.
Domain invariants throw for the named parameter and return the value for inline use:
Code = code.RequireWithMaxLength(MaxCodeLength, nameof(code)).ToUpperInvariant();
Description = description.OptionalWithMaxLength(MaxDescriptionLength, nameof(description));
BrandId = brandId.RequirePositive(nameof(brandId));
Strings: IsBlank, TrimToNull, TrimToNullWithLf (also turns every line ending into \n, for stored multi-line text), ToCodeKey (trimmed and upper-cased, separators kept; null stays null), ExceedsMaxLength (after trimming), RequireWithMaxLength, OptionalWithMaxLength. Clip(maxLength) shortens text that is recorded rather than validated (snapshots taken after a commit) and trims the cut end. TypedCodeNormalizer.Normalize(input, separators) removes separators and upper-cases a code a person typed, so abcde-fghjk equals ABCDEFGHJK. Numbers (any INumber<T>): RequirePositive, RequireNonNegative, RequireInRange, and for nullable values OptionalNonNegative, OptionalInRange. Decimals: HasAtMostDecimalPlaces(places) tells whether a fixed-scale column such as numeric(18,2) stores the value without loss (trailing zeros do not count, so 1.500m has at most 2).
Request validation returns a validation Error with the field target, or null when the value is valid, so a validator reports every field at once:
var validation = FieldErrors.Collect(
request.Sku.RequiredError(code, "sku", "SKU"),
request.Name.MaxLengthError(MaxNameLength, code, "name", "Name"),
request.WidthMm.NonNegativeError(code, "widthMm", "Width"));
if (validation.IsFailure)
return Result.Failure<ProductDetails>(validation.Errors);
Text folding and slugs
Hmz.Core.SharedKernel.Text folds accents and builds URL slugs the same way on every host, without culture, ICU or invariant globalization mode:
FoldLatinAccents()replaces each accented Latin letter (U+00C0–U+024F and U+1E00–U+1EFF letters whose NFD is an ASCII letter plus combining marks, andĐ/đ) with its ASCII letter in the same case, and leaves every other character as it is. It maps character by character from a fixed table and does not normalize, so a SQLtranslateover the same table gives the same result (for example a generated comparison-key column). Normalize decomposed (NFD) input to NFC first when it can occur.ToSlug()folds, drops combining marks, lower-cases ASCII letters and joins the remaining runs with one hyphen:Sofa "Đà Lạt" 2-chỗbecomessofa-da-lat-2-cho. The result matches^[a-z0-9]+(-[a-z0-9]+)*$or is empty.
Request validation
Validation runs request-shape checks before a handler, without tying them to a dispatcher. A validator lists field checks; every failure of every validator is returned at once, each with its target:
public sealed class CreateCollectionCommandValidator : RequestValidator<CreateCollectionCommand>
{
protected override IEnumerable<Error?> Validate(CreateCollectionCommand request) =>
[
request.Code.RequiredError(Codes.Invalid, "code", "Collection code"),
request.Code.MaxLengthError(Collection.MaxCodeLength, Codes.TooLong, "code", "Collection code"),
];
}
services.AddRequestValidatorsFromAssemblies(typeof(CreateCollectionCommand).Assembly);
services.AddMediator(options =>
options.PipelineBehaviors = [typeof(RequestValidationBehavior<,>)]);
IRequestValidator<TRequest> is asynchronous, so a validator can await a service when it must; checks that need the database (existence, uniqueness) still belong in the handler, and invariants in the domain. RequestValidationBehavior<,> (Messaging.Validation) is the Mediator adapter: it calls IRequestValidation and returns the failure as the handler's Result/Result<T> (built with ResultFailures). A custom dispatcher calls IRequestValidation.ValidateAsync(request, ct) the same way.
Smart Enum JSON
Serialization.SmartEnums is opt-in: an application without Smart Enums never calls it. options.AddSmartEnumConverters() adds one SmartEnumJsonConverterFactory that converts every Smart Enum as its value (SmartEnumJsonFormat.Name writes names). New Smart Enums need no registration. Call it on every JsonSerializerOptions that serializes Smart Enums: the API contract, stored JSON columns and caches. Integration event payloads already include the factory, so a Smart Enum inside an event travels as its value with no attribute. Another value-object enumeration plugs in the same way, with its own JsonConverterFactory. To use another format for one type, add its converter before the factory or put [JsonConverter(typeof(SmartEnumNameJsonConverter<T, int>))] on the type or property.
Enum options
Contracts.EnumOptions builds the response of a Smart Enum options endpoint. EnumOptionCatalog.Create(key, options) sorts EnumOption(Value, Code, DisplayName, ShortDesc, SortOrder) items by SortOrder and adds a strong ETag hashed from the key and the content. EnumOptionCatalog<T>.Create(key, items) keeps the given order and hashes items that carry extra data, such as a count per option, so a changed count changes the ETag. Return the ETag with Cache-Control and answer a matching If-None-Match with 304. .ProducesCachedOptions<TOption>() documents such an endpoint: 200 with a TOption[], 304, the opaque ETag on both, and an optional If-None-Match. It adds no 401, so chain .ProducesAuthorizationProblems() on a protected endpoint.
JSON helpers
Serialization.JsonElementExtensions.TryGetString(params names) returns the first of the named properties that holds a string, without throwing for other value kinds. JsonNamingExtensions.ToCamelCaseKey() converts a property name with JsonNamingPolicy.CamelCase.
HTTP boundary support
Hmz.Core.SharedKernel.Http currently provides:
- Result-to-Problem-Details mapping;
- validation problem results;
- global and request-body exception handlers;
- strong ETag parsing/formatting;
- precondition results and conditional-request OpenAPI helpers;
ProducesAuthorizationProblems(), which documents the 401 and 403 problem responses of a protected endpoint;[OpenApiDeprecated]on a request or response property, which marks itdeprecated: trueonceoptions.AddSchemaTransformer<DeprecatedPropertySchemaTransformer>()is registered (properties typed as a shared$refcomponent are left alone). The attribute targets properties only, so on a positional record write[property: OpenApiDeprecated];- streamed single-file multipart uploads and file-signature detection
(
Http.Uploads).
These types use ASP.NET Core and belong only at HTTP boundaries. Domain and UseCase projects should remain transport neutral.
ETags use quoted invariant row versions such as "42". Clients should treat the complete quoted value as opaque and return it unchanged through If-Match or If-None-Match.
RequiresStrongIfMatch(), ProducesStrongEntityTag(statusCodes) and SupportsIfNoneMatch() document row versions only: their header pattern is "[0-9]{1,10}", whatever the name suggests. A generated client rejects a hash or composite tag documented with them, so use the opaque helpers below for those.
A composite resource or option list has no single row version, so its ETag is an opaque strong tag computed from its content: ContentEntityTag.Create(key, value, serializerOptions) (namespace Contracts, transport neutral, so a query can compute it) returns "{key}-{SHA-256 lower-case hex}" of the value's JSON. Pass the serializer options the endpoint writes the body with; EnumOptionCatalog uses the same tag. Recompute it from the current state and check a write with EntityTagHttp.HandleIfMatch(context, currentEntityTag), which returns 428 when If-Match is missing, a validation problem when it is weak, *, a list or malformed, 412 when it differs, and null when the write may proceed. When the tag comes from state the endpoint must load first, use EntityTagHttp.HandleIfMatchAsync(context, loadCurrent, currentEntityTag, ct): it reads If-Match before loadCurrent runs (so 428 and the validation problem cost no load), maps a failed load through ToProblem, answers 412 on a mismatch, and otherwise returns an IfMatchResolution<T> whose Current is the loaded state:
var precondition = await EntityTagHttp.HandleIfMatchAsync(
context,
ct => sender.Send(new GetCurrentUserProfileQuery(userId), ct),
profile => ContentEntityTag.Create("profile", profile, serializerOptions),
cancellationToken);
if (!precondition.Passed)
return precondition.Problem;
var expectedRowVersion = precondition.Current.RowVersion; // not null once Passed
Document it with RequiresOpaqueIfMatch(), ProducesOpaqueEntityTag(statusCodes) and SupportsOpaqueIfNoneMatch(); the Strong helpers document only digit row versions.
The version travels only in the ETag header:
- Response bodies carry no row version. A response record that needs it for
WriteStrongkeeps it off the wire, for example[property: JsonIgnore] uint RowVersion. - Search and list responses carry no version. A client reads the single record (a
GETby id that returns the ETag) before it edits, then sends that ETag asIf-Match. - Browser clients on another origin can read the header only when the CORS policy exposes it:
WithExposedHeaders(HeaderNames.ETag, HeaderNames.Location, HeaderNames.ContentDisposition). If a client cannot see the ETag, fix the exposed headers; never copy the version into the body.
Register the shared Problem Details behavior in an API host:
services.AddHmzProblemDetails();
Malformed request binding is sanitized. Only messages represented by Serialization.SafeJsonException may expose bounded actionable details.
Streamed file uploads
MultipartUploads.ReadSingleFileAsync reads a multipart/form-data request
without buffering it and hands the first file section to a callback. The
section content is a BoundedReadStream that throws UploadTooLargeException
one byte past MaxFileBytes, so oversized files fail instead of being
truncated.
app.MapPost("files", (HttpContext context, ISender sender, CancellationToken ct) =>
MultipartUploads.ReadSingleFileAsync(
context,
new MultipartUploadOptions(maxFileBytes),
async (file, token) =>
{
var result = await sender.Send(
new UploadFileCommand(file.Content, file.FileName, file.DeclaredLength),
token);
return result.IsFailure ? result.ToProblem(context) : TypedResults.Ok(result.Value);
},
ct))
.DisableAntiforgery();
| Condition | Response | Code |
|---|---|---|
Not multipart/form-data |
415 problem | http.upload-unsupported-media-type |
| Missing/invalid boundary, truncated or malformed body | 400 validation problem (request) |
http.upload-invalid-boundary, http.upload-malformed |
Content-Length above MaxFileBytes + MultipartHeadroomBytes (default 1 MiB), file above MaxFileBytes, or any UploadTooLargeException from the callback |
413 problem | http.upload-too-large |
| No file section | 400 validation problem keyed by FileField |
http.upload-file-required |
Rules:
- The callback must consume
Contentbefore returning. FileNameandContentTypeare client-supplied: detect the real type from content and never use the name as a storage path.- Application exceptions may derive from
UploadTooLargeException(for example a per-purpose limit enforced after storage) and still map to 413. - Other exceptions, including storage
IOExceptions, propagate to the global exception handler.
File signatures
FileSignatures.Detect recognizes a file from its leading bytes, so the stored
content type never comes from the client. It returns a DetectedFileType
(Extension, ContentType, IsImage, IsVideo) or null:
| Content | Extension | Content type |
|---|---|---|
JPEG (FF D8 FF) |
.jpg |
image/jpeg |
PNG (89 50 4E 47 0D 0A 1A 0A) |
.png |
image/png |
WebP (RIFF + size + WEBP) |
.webp |
image/webp |
PDF (%PDF-) |
.pdf |
application/pdf |
MP4 (ftyp box with a video major brand such as isom, mp42, avc1) |
.mp4 |
video/mp4 |
QuickTime (ftyp box with major brand qt ) |
.mov |
video/quicktime |
WebM (EBML header with DocType webm) |
.webm |
video/webm |
HEIF/AVIF images and M4A audio share the ftyp container but have other
brands, so they are not reported as video; other Matroska files are not WebM.
Read FileSignatures.PrefixLength (64) bytes, because the WebM DocType sits
inside the EBML header.
var prefix = new byte[FileSignatures.PrefixLength];
var length = await content.ReadAtLeastAsync(prefix, prefix.Length, throwOnEndOfStream: false, ct);
var detected = FileSignatures.Detect(prefix.AsSpan(0, length));
if (detected is not { IsImage: true })
return Errors.UnsupportedFile();
The type has no ASP.NET Core dependency, so UseCase code may use it. Which
types an upload accepts, and the messages for rejected files, stay application
policy. FileSignatures.IsImage(contentType) and FileSignatures.IsVideo(contentType)
re-check a stored content type.
Additional dependencies
The current package references Mediator abstractions, ASP.NET Core/OpenAPI, EF Core (including EF Core Relational for messaging persistence), Npgsql (PostgreSQL error classification), System.Linq.Dynamic.Core, and temporary Ardalis building blocks for guards, shared entities, specifications, SmartEnum, repositories, and service introspection. Versions are centrally managed in Directory.Packages.props.
Build, test, and pack
dotnet build Hmz.Core.SharedKernel/Hmz.Core.SharedKernel.csproj `
--configuration Release
dotnet run `
--project tests/Hmz.Core.SharedKernel.UnitTests/Hmz.Core.SharedKernel.UnitTests.csproj `
--configuration Release `
--no-build `
-- --minimum-expected-tests 1
dotnet pack Hmz.Core.SharedKernel/Hmz.Core.SharedKernel.csproj `
--configuration Release `
--output <package-output>
Current version: 0.1.17, prepared for NuGet publication. Inspect the package before
publishing and never republish the same version; advance the project version
and changelog for any subsequent package-content change.
Extension rules
- Add only cross-project contracts with demonstrated reuse.
- Keep feature-specific models and policies in the owning application.
- Keep HTTP and EF Core dependencies in their explicit namespaces and out of Domain code.
- Add tests for compatibility-sensitive behavior and public contracts.
- Preserve existing comments; add or update comments only when behavior changes make them inaccurate or clarification is important.
| 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
- Ardalis.GuardClauses (>= 5.0.0)
- Ardalis.ListStartupServices (>= 1.1.4)
- Ardalis.SharedKernel (>= 5.0.0)
- Ardalis.SmartEnum (>= 8.2.0)
- Ardalis.Specification (>= 9.3.1)
- Ardalis.Specification.EntityFrameworkCore (>= 9.3.1)
- Mediator.Abstractions (>= 3.0.2)
- Microsoft.AspNetCore.OpenApi (>= 10.0.11)
- Microsoft.EntityFrameworkCore (>= 10.0.11)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.11)
- Microsoft.OpenApi (>= 2.12.2)
- Npgsql (>= 10.0.3)
- System.Linq.Dynamic.Core (>= 1.7.4)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.19 | 108 | 11/8/2026 |
| 0.1.18 | 123 | 11/1/2026 |
| 0.1.17 | 137 | 10/21/2026 |
| 0.1.16 | 105 | 10/19/2026 |
| 0.1.15 | 101 | 10/13/2026 |
| 0.1.14 | 104 | 10/9/2026 |
| 0.1.13 | 97 | 10/9/2026 |
| 0.1.13-pre03 | 95 | 10/9/2026 |
| 0.1.13-pre01 | 91 | 10/9/2026 |
| 0.1.12 | 100 | 10/8/2026 |
| 0.1.11 | 382 | 9/26/2026 |
| 0.1.10 | 138 | 9/21/2026 |
| 0.1.9 | 147 | 9/12/2026 |
| 0.1.8 | 145 | 9/7/2026 |
| 0.1.7 | 151 | 5/8/2026 |
| 0.1.6 | 139 | 5/7/2026 |
| 0.1.5 | 130 | 5/6/2026 |
| 0.1.4 | 134 | 4/23/2026 |
| 0.1.3 | 145 | 4/22/2026 |