High-performance Notification Pattern for .NET.
Record, don't throw.
Collect validation failures, warnings and domain notifications without relying on exceptions.
Scribe.Notifications is a high-performance implementation of the Notification Pattern for .NET.
Instead of throwing exceptions for expected validation failures, Scribe collects notifications and allows callers to inspect them at the end of an operation.
This approach produces:
- Cleaner domain code
- Better performance
- Richer validation feedback
- Improved observability
- Better separation between validation and control flow
Using exceptions for expected validation scenarios has drawbacks:
- Exceptions are expensive
- Only the first failure is returned
- Aggregating failures becomes difficult
- Validation logic leaks into control flow
Instead of this:
throw new ValidationException("Email is invalid.");Use this:
notifications.Add(
new NotificationMessage(
"USER_EMAIL_INVALID",
NotificationType.Error,
"Email is invalid."
)
);Scribe enables you to:
- Collect all validation failures
- Return rich feedback
- Attach metadata
- Improve diagnostics
- Avoid exception-driven validation flows
- High-performance Notification Pattern implementation
- Thread-safe notification storage
- Immutable readonly struct notifications
- Lazy evaluation APIs
- Span<T> support
- Async APIs powered by ValueTask
- Custom notification types
- Rich metadata support
- String interning optimizations
- FrozenDictionary-backed type registry
- Zero external dependencies
| Package | Version | Description |
|---|---|---|
Scribe.Notifications |
Core Notification Pattern implementation | |
Scribe.Notifications.Checks |
Reusable, observable domain checks |
# Core
dotnet add package Scribe.Notifications
# Checks (optional)
dotnet add package Scribe.Notifications.Checksusing Scribe.Notifications.Core.Notifications;
var notifications = new NotificationCollection();
notifications.Add(
NotificationType.Error,
"Email is invalid."
);
notifications.Add(
NotificationType.Warning,
"Name is too short."
);
if (notifications.HasErrors())
{
foreach (var error in notifications.GetErrors())
{
Console.WriteLine(error);
}
}public NotificationCollection ValidateUser(
string email,
int age)
{
var notifications = new NotificationCollection();
if (string.IsNullOrWhiteSpace(email))
{
notifications.Add(
new NotificationMessage(
"USER_EMAIL_REQUIRED",
NotificationType.Error,
"Email is required."
)
);
}
if (age < 18)
{
notifications.Add(
new NotificationMessage(
"USER_AGE_INVALID",
NotificationType.Error,
"User must be at least 18 years old."
)
);
}
return notifications;
}Scribe was designed for high-throughput applications.
Performance optimizations include:
- readonly struct notifications
- String interning
- Flyweight caching
- FrozenDictionary lookups
- Lazy evaluation
- Span<T> support
- ValueTask APIs
| Scenario | Mean | Allocated |
|---|---|---|
| Add with explicit ID | 91.06 ns | 264 B |
| Add without ID (auto-generated) | 1,278.79 ns | 368 B |
| HasErrors 2,000 items | 12.96 ns | — |
| HasErrors empty collection | 12.88 ns | — |
| GetErrors lazy 1,000 errors | 48,103.83 ns | 78.4 KB |
| GetErrorsAsList materialized | 19,797.52 ns | 78.3 KB |
| TryGetAsSpan zero-copy | 14.83 ns | — |
| AddRange 100 items | 129,835.13 ns | 25.9 KB |
Environment:
- BenchmarkDotNet v0.14.0
- .NET 10.0.2 Arm64
- Apple M1 Pro, 8 cores
Immutable readonly struct representing a single notification.
var notification = new NotificationMessage(
"ORDER_AMOUNT_INVALID",
NotificationType.Error,
"Order amount must be greater than zero."
);Represents notification categories.
Built-in types:
| Type | Severity | IsFailure |
|---|---|---|
| Error | 100 | true |
| Warning | 60 | false |
| Info | 20 | false |
| Success | 0 | false |
Custom types:
var critical = NotificationType.GetOrCreate(
"critical",
"Critical Error",
95,
true
);Thread-safe notification store.
var notifications = new NotificationCollection();
notifications.Add(
NotificationType.Error,
"Something went wrong."
);
bool hasErrors = notifications.HasErrors();app.MapPost("/users", (CreateUserRequest request) =>
{
var notifications = Validate(request);
if (notifications.HasErrors())
{
return Results.BadRequest(
notifications.GetErrorsAsList()
);
}
return Results.Ok();
});var blocked = NotificationType.GetOrCreate(
name: "blocked",
displayName: "Blocked",
severityLevel: 80,
isFailure: true
);
notifications.Add(
new NotificationMessage(
"ACCOUNT_BLOCKED",
blocked,
"Account is blocked."
)
);Scribe.Notifications.Checks brings reusable, observable domain validations built on top of the Notification Pattern.
Instead of duplicating business rules across services, you encapsulate them once and apply them anywhere:
using Scribe.Notifications.Checks;
using Scribe.Notifications.Checks.Extensions;
notifications.Apply(UserChecks.EmailMustBeValid(email));
notifications.Apply(UserChecks.MustBeAdult(age));
await notifications.ApplyAsync(UserChecks.EmailNotAlreadyTakenAsync(email, ct));A Check is the observable result of a domain validation, it carries zero or more notifications produced during evaluation.
public static class UserChecks
{
public static Check EmailMustBeValid(string email) =>
email.Contains('@')
? Check.Pass()
: Check.Fail("USER_EMAIL_INVALID", NotificationType.Error, "Email is invalid.");
public static async ValueTask<Check> EmailNotAlreadyTakenAsync(string email, CancellationToken ct)
{
var exists = await _repository.ExistsAsync(email, ct);
return exists
? Check.Fail("USER_EMAIL_TAKEN", NotificationType.Error, "Email is already taken.")
: Check.Pass();
}
}Apply multiple checks at once, all evaluated regardless of previous failures:
notifications.ApplyAll([
UserChecks.EmailMustBeValid(request.Email),
UserChecks.MustBeAdult(request.Age),
UserChecks.NameMustNotBeEmpty(request.Name)
]);You can build your own store by implementing INotificationStore, useful for decorating with logging, telemetry, or custom routing.
public class LoggingNotificationStore : INotificationStore
{
private readonly INotificationStore _inner;
private readonly ILogger _logger;
public LoggingNotificationStore(INotificationStore inner, ILogger logger)
{
_inner = inner;
_logger = logger;
}
public void Add(in NotificationMessage notification)
{
_logger.LogInformation("Notification added: {Id}", notification.Id);
_inner.Add(notification);
}
// Implement remaining INotificationStore members by delegating to _inner
}Scribe.Notifications.Checksreusable, observable domain checks
- Grouping and aggregation APIs
- Severity ordering
- Filtering extensions
- Localization
- .resx integration
- OpenTelemetry integration
- Metrics and diagnostics
Contributions are welcome.
If you have suggestions, bug reports, or feature requests, please open an issue first so we can discuss the proposal.
MIT License.
See LICENSE for details.
