From 000ee73598390d31bd9c7839bc625231517045ea Mon Sep 17 00:00:00 2001 From: Igor Sinicyn Date: Sun, 12 Jul 2026 20:53:28 +0300 Subject: [PATCH 1/3] Fix namespaces. Docs cleanup. --- README.md | 109 ++++++---------- docs/Basics/AsyncLocal/CustomScope.md | 35 +++-- docs/Basics/AsyncLocal/README.md | 120 ++++++++++-------- docs/ServiceErrors/README.md | 56 ++++---- docs/docs.csproj | 4 + src/CodeMe.Basics/CodeMe.Basics.csproj | 1 + src/CodeMe.Basics/README.md | 78 ++---------- .../ScopedAsyncLocal.TestAccessor.cs | 2 +- .../Threading/ScopedAsyncLocal.cs | 6 +- src/CodeMe.ServiceErrors/README.md | 99 ++------------- .../Threading/CustomScopeTests.cs | 2 +- .../ScopedAsyncLocalInternalTests.cs | 2 +- .../Threading/ScopedAsyncLocalTests.cs | 2 +- 13 files changed, 189 insertions(+), 327 deletions(-) diff --git a/README.md b/README.md index f31ab70..6a15d9e 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,54 @@ -# CodeMe -CodeMe is a set of small, focused, reusable libraries aimed to reduce amount of boring boilerplate code in .NET applications. +# What is included? -# CodeMe.ServiceErrors +- CodeMe.ServiceErrors — describe service-level errors as first-class values, carry them as `ServiceError`, serialize them to DTOs, and map them to typed exceptions. See the [full documentation](docs/ServiceErrors/README.md). +- CodeMe.Basics — small infrastructure helpers such as `ScopedAsyncLocal` for ambient execution context and logical scopes. See the [full documentation](docs/Basics/AsyncLocal/README.md). -CodeMe.ServiceErrors is a library for describing service-level errors as first-class values and turning them into serializable payloads or exceptions. It is designed for APIs, background services, and distributed systems where you want a stable error contract across app boundaries. Check [documentation](https://github.com/ig-sinicyn/CodeMe/docs/ServiceErrors/README.md) for more details and examples. +# CodeMe.Basics -## Minimal example +CodeMe.Basics provides small, reusable infrastructure types that complement the BCL. -Well-known errors, testing for errors, conversions and DI registration: -```csharp -using CodeMe.ServiceErrors; -using CodeMe.ServiceErrors.DependencyInjection; -using CodeMe.ServiceErrors.Serializable; -using Microsoft.Extensions.DependencyInjection; -using static WellKnownOrderApiErrors; - -// DI registration -var services = new ServiceCollection(); -services - .AddServiceErrors(RootGroup) - .Add(typeof(WellKnownOrderApiErrors)); -using var provider = services.BuildServiceProvider(); -var errorFactory = provider.GetRequiredService(); - -// Error return and handling -var error = new ServiceError(OrderNotFound, "Order 42 was not found"); -// ... -if (error.Matches(OrderNotFound)) -{ - // handle the error -} +## ScopedAsyncLocal -// Error serialization and exception factory -ServiceError error = new ServiceError(OrderNotFound, "Order 42 was not found"); -ServiceErrorDto dto = errorFactory.CreateDto(error); -ServiceError errorFromDto = errorFactory.CreateError(dto); -// DTO content in JSON format: -// { -// "scheme": "problem", -// "application": "orders-api", -// "category": "orders", -// "code": "order-not-found", -// "statusCode": "NotFound", -// "message": "Order 42 was not found" -// } - -// returns OrderNotFoundException -IServiceException exception = errorFactory.CreateException(errorFromDto); -ServiceError errorFromException = exception.Error; - -// Well-known errors declaration -[ServiceErrors] -internal static class WellKnownOrderApiErrors -{ - public static readonly ErrorGroupUri RootGroup = ErrorGroupUri.Create("problem", "orders-api"); +`ScopedAsyncLocal` lets you carry ambient context through a logical execution flow without explicitly passing it through every method call. Use `ScopedAsyncLocal` for values such as request IDs, unit-of-work state, or tenant information. The value is automatically restored when the scope is disposed. - public static readonly ErrorGroupUri OrdersGroup = RootGroup.SubGroup("orders"); +```csharp +using CodeMe.Threading; - [ServiceException] - public static readonly ErrorDescriptor OrderNotFound = - ErrorDescriptor.NotFound(OrdersGroup, "order-not-found"); -} +var context = new ScopedAsyncLocal(); -// Typed exceptions -internal sealed class OrderNotFoundException : ServiceException +using (context.BeginScope("request-1")) { - public OrderNotFoundException(ServiceError error) - : base(AssertMatches(OrderNotFound, error)) - { - } - - public OrderNotFoundException(string message, Exception? innerException = null) - : base(OrderNotFound, message, innerException) - { - } + Console.WriteLine(context.Current); // request-1 } + +Console.WriteLine(context.Current); // null ``` -# CodeMe.Basics +`ScopedAsyncLocal` also supports asynchronous flows and nested scopes. For more details and additional examples, see the [full documentation](docs/Basics/AsyncLocal/README.md). + +# CodeMe.ServiceErrors + +CodeMe.ServiceErrors helps you describe service-level errors as first-class values and propagate them consistently across application boundaries. + +The package is designed for APIs, background services, and distributed systems where a stable error contract matters. It lets you: + +- define well-known error descriptors with stable URIs and semantics; +- carry errors as `ServiceError` values in your domain code; +- serialize them to `ServiceErrorDto` payloads; +- map them to typed exceptions when needed; +- register error definitions in DI for consistent creation and hydration. + +A simple example: + +```csharp +using CodeMe.ServiceErrors; + +var descriptor = ErrorDescriptor.NotFound( + ErrorGroupUri.Create("problem", "orders-api", "orders"), + "order-not-found"); + +var error = new ServiceError(descriptor, "Order 42 was not found"); +``` -CodeMe.Basics contains BCL-style extensions such as `ScopedAsyncLocal` and others. \ No newline at end of file +For more details, examples, and guidance on DI registration, serialization, and exception mapping, see the [full documentation](docs/ServiceErrors/README.md). \ No newline at end of file diff --git a/docs/Basics/AsyncLocal/CustomScope.md b/docs/Basics/AsyncLocal/CustomScope.md index bcbd8ff..7e1109b 100644 --- a/docs/Basics/AsyncLocal/CustomScope.md +++ b/docs/Basics/AsyncLocal/CustomScope.md @@ -1,10 +1,16 @@ +# Custom ambient contexts + +There are many scenarios in which you do not want to expose `AsyncLocal` or `ScopedAsyncLocal` directly to external code. This document uses a simplified unit-of-work example to show how you can introduce a custom ambient context on top of `ScopedAsyncLocal`. + +Usage + ```csharp using System.Data; using System.Data.Common; -using CodeMe.Basics.Threading; +using CodeMe.Threading; var unitOfWorkManager = new UnitOfWorkManager(); -var repository = new UserRepository(unitOfWorkManager); // Usually, this is injected via DI container. +var repository = new UserRepository(unitOfWorkManager); // Usually, this is injected via a DI container. await using (var unitOfWork = await unitOfWorkManager.BeginUnitOfWorkAsync()) { @@ -22,8 +28,11 @@ async ValueTask GetUserAsync(long id) return await unitOfWork.Connection.QueryFirstAsync(...); } +``` +Implementation +```csharp // Simplified version for demonstration purposes. In real-world scenarios, consider using a more robust implementation. public class UnitOfWorkManager { @@ -39,7 +48,7 @@ public class UnitOfWorkManager { // IMPORTANT: // The BeginScopeInitialization method SHOULD be called in the synchronous part of the method. - // Otherwise, the new AsyncLocal value will not be stored in the caller's execution context, + // Otherwise, the new AsyncLocal value will not be stored in the caller's execution context. var scope = _scopedAsyncLocal.BeginScopeInitialization(); return BeginUnitOfWorkAsync(scope, isolation); } @@ -49,8 +58,8 @@ public class UnitOfWorkManager IsolationLevel isolation) { // Asynchronous part. - // Performs step-by-step initialization of the UnitOfWork instance - // and assigns it to the scope. If any exception occurs, UnitOfWork (and all related resources) will be disposed. + // Performs a step-by-step initialization of the UnitOfWork instance + // and assigns it to the scope. If any exception occurs, the UnitOfWork instance (and all related resources) will be disposed. var result = new UnitOfWork(); try { @@ -62,7 +71,7 @@ public class UnitOfWorkManager var transaction = await connection.BeginTransactionAsync(isolation); result.Initialize(transaction); - // Assign fully constructed UnitOfWork to the scope. + // Assign the fully constructed UnitOfWork to the scope. // This is the only place where the scope value is set. scope.Initialize(result); @@ -87,11 +96,11 @@ public sealed class UnitOfWork : IAsyncDisposable public DbTransaction Transaction { get; private set; } - public void Initialize(DbConnection connection) => _connection = connection; + internal void Initialize(DbConnection connection) => _connection = connection; - public void Initialize(DbTransaction transaction) => _transaction = transaction; + internal void Initialize(DbTransaction transaction) => _transaction = transaction; - public void Initialize(IDisposable asyncScope) => _asyncScope = asyncScope; + internal void Initialize(IDisposable asyncScope) => _asyncScope = asyncScope; public async ValueTask CommitAsync() { @@ -108,10 +117,8 @@ public sealed class UnitOfWork : IAsyncDisposable public ValueTask DisposeAsync() { // IMPORTANT: - // For performance-sensitive code - // it is recommended to Dispose scope in synchronous part of DisposeAsync. - // The trick slightly reduces the memory usage - // as it clears AsyncLocal value in the caller's execution context. + // For performance-sensitive code, it is recommended to dispose the scope in the synchronous part of DisposeAsync. + // This slightly reduces memory usage, because it clears the AsyncLocal value in the caller's execution context. _asyncScope?.Dispose(); return DisposeCoreAsync(); } @@ -128,4 +135,4 @@ public sealed class UnitOfWork : IAsyncDisposable await _connection.DisposeAsync(); } } -``` \ No newline at end of file +``` diff --git a/docs/Basics/AsyncLocal/README.md b/docs/Basics/AsyncLocal/README.md index 5ef91f2..5a01c16 100644 --- a/docs/Basics/AsyncLocal/README.md +++ b/docs/Basics/AsyncLocal/README.md @@ -1,27 +1,26 @@ # ScopedAsyncLocal -`ScopedAsyncLocal` provides ambient context for a logical execution flow. It is useful when a value should be available to all code in the current flow without explicitly passing it through every method call. Typical scenarios include unit of work scopes, request correlation identifiers, tenant information, etc. +`ScopedAsyncLocal` provides ambient context for a logical execution flow. It is useful when a value should be available without explicitly passing it through every method call. Typical scenarios include unit-of-work scopes, request correlation identifiers, and tenant information. ## How it works -`ScopedAsyncLocal` provides ambient context by keeping a stack of scopes for the current execution flow. Each scope represents a logical boundary such as a request, unit-of-work, or tenant context. When you call `BeginScope` or `BeginScopeAsync`, a new scope is pushed onto the current execution context, and `Current` resolves to the value from the innermost initialized scope. +`ScopedAsyncLocal` is built on top of `AsyncLocal` and maintains a stack of scopes for the current [execution flow](https://learn.microsoft.com/en-us/dotnet/api/system.threading.executioncontext). Each scope represents a logical boundary, such as a request, a unit of work, or a tenant context. You can create multiple instances of `ScopedAsyncLocal`, and each instance tracks its own value independently. -Nested scopes override the parent value while they are active. When the child scope is disposed, the previous ambient value is restored automatically, so the value behaves like a flow-scoped context without having to pass it through every method call. +When you call `BeginScope` or `BeginScopeAsync`, a new scope is pushed onto the current execution context, and `Current` resolves to the value from the topmost initialized scope. If you call these methods from an async C# method, the new scope is stored in a copy of the execution context and will not be available to the caller of that async method. See [the example](#passing-scope-to-external-code) below for more details. -The implementation uses `AsyncLocal` under the hood, which means the value is captured per async flow. Because async calls create copies of the execution context, initialization is split into two steps: +It is important to dispose scopes when they are no longer needed. A leaked scope will not be collected until the end of its execution context lifetime. In long-running code, tight loops, or deeply nested async flows, leaving scopes undisposed can lead to noticeable memory leaks. -- the scope is created synchronously so it can be observed immediately by the caller; -- the scope value is initialized later, and uninitialized scopes are ignored while resolving `Current`. +The recommended approach is to use the `using` keyword with the scope returned by `BeginScope...`. If you want to store a scope as a field, there is a [detailed example](CustomScope.md) for custom scope wrappers. -This design lets the new scope be available to the caller right away, while still supporting asynchronous value factories. The library also keeps a stack of scopes so it can restore the previous value when a scope is disposed. If you forget to dispose scopes, the stack can grow and hold references longer than intended. When `validateDisposeOrder: true` is used, disposing scopes in the wrong order throws an exception. +To make leaked-scope detection easier, you can construct `ScopedAsyncLocal` with `validateDisposeOrder: true` to detect out-of-order scope disposal. -## Scenarios and example of usage +## Main scenario -Use `ScopedAsyncLocal` when a value should be available to the current logical execution path and reverted automatically when the scope ends. The value is visible to nested scopes and is restored to the previous ambient value when the scope is disposed. +Use `ScopedAsyncLocal` when a value should be available for the current logical execution path and reverted automatically when the scope ends. The value is visible to nested scopes and is restored to the previous ambient value when the scope is disposed. ```csharp -using CodeMe.Basics.Threading; +using CodeMe.Threading; var context = new ScopedAsyncLocal(); @@ -40,54 +39,73 @@ using (context.BeginScope("request-1")) Console.WriteLine(context.Current); // null ``` -`ScopedAsyncLocal` also works with asynchronous flows. The value from the current scope is available after `await`; the scope must be disposed to restore the previous value. +## Passing scope to external code + +If you want to pass a new scope back to the parent method, you should call the `Begin...` method without changing the current execution context. To do so, do not mark your methods as async. Async methods run on a copy of the parent context, so the new scope will not be accessible by the parent method. + +For asynchronous initialization, place the initialization code in the callback passed to `BeginScopeAsync`. ```csharp -var local = new ScopedAsyncLocal(); +using CodeMe.Threading; + +var context = new ScopedAsyncLocal(); +var id = Guid.Parse("7b90489c-7d81-42bc-99d6-ba6dc118375f"); -using (await local.BeginScopeAsync(() => new ValueTask("request-2"))) +// External code +using (await BeginCustomScopeAsync(id)) { - Console.WriteLine(local.Current); // request-2 + Console.WriteLine(context.Current); // 7b90489c-7d81-42bc-99d6-ba6dc118375f } -``` -The constructor can be used with `validateDisposeOrder: true` to detect out-of-order scope disposal. +Console.WriteLine(context.Current); // null -## Using BeginScopeAsync from helper methods +// Your helper +ValueTask BeginCustomScopeAsync(Guid userId) +{ + // The method MUST be synchronous + return context.BeginScopeAsync(async () => await GetUserStateAsync(userId)); +} -`BeginScopeAsync` initializes the scope asynchronously through a value factory. If you call it from a helper method, keep the helper synchronous and avoid `await` in the call. Otherwise new scope will not be propagated back to the caller. The value factory itself may use `await`. +Task GetUserStateAsync(Guid userId) => Task.FromResult(userId.ToString()); +``` + +For advanced scenarios, there is a `BeginScopeInitialization`/`Initialize` two-step pattern. Its primary purpose is to create a custom scope, and it requires some care from the caller. See the [custom scope example](CustomScope.md) for more details. ```csharp - private Task BeginUnitOfWorkAsync( - ScopedAsyncLocal local, - CancellationToken cancellation = default) => - local.BeginScopeAsync( - async () => - { - // The body is simplified for demonstration purposes. - DbConnection? connection = null; - DbTransaction? transaction = null; - try - { - connection = await _connectionFactory.CreateConnectionAsync(cancellation); - await connection.OpenAsync(cancellation); - transaction = await connection.BeginTransactionAsync(cancellation); - - return new UnitOfWork(connection, transaction); - } - catch (Exception ex) - { - if (transaction != null) - { - await transaction.DisposeAsync(); - } - - if (connection != null) - { - await connection.DisposeAsync(); - } - - throw; - } - }); -``` \ No newline at end of file +using CodeMe.Threading; + +var context = new ScopedAsyncLocal(); +var id = Guid.Parse("7b90489c-7d81-42bc-99d6-ba6dc118375f"); + +using (await BeginCustomScopeAsync(id)) +{ + Console.WriteLine(context.Current); // 7b90489c-7d81-42bc-99d6-ba6dc118375f +} + +Console.WriteLine(context.Current); // null + +ValueTask BeginCustomScopeAsync(Guid userId) +{ + // The method MUST be synchronous + var scope = context.BeginScopeInitialization(); + return CompleteBeginCustomScopeAsync(scope, userId); +} + +async ValueTask CompleteBeginCustomScopeAsync(ScopedAsyncLocal.Scope scope, Guid userId) +{ + // Asynchronous part + try + { + var state = await GetUserStateAsync(userId); + scope.Initialize(state); + return scope; + } + catch (Exception) + { + scope.Dispose(); + throw; + } +} + +Task GetUserStateAsync(Guid userId) => Task.FromResult(userId.ToString()); +``` diff --git a/docs/ServiceErrors/README.md b/docs/ServiceErrors/README.md index 65face7..690f180 100644 --- a/docs/ServiceErrors/README.md +++ b/docs/ServiceErrors/README.md @@ -1,29 +1,23 @@ # CodeMe.ServiceErrors -CodeMe.ServiceErrors is a library for describing service-level errors as first-class values and turning them into serializable payloads or exceptions. It is designed for APIs, background services, and distributed systems where you want a stable error contract across app boundaries. +CodeMe.ServiceErrors is a library for describing service-level errors as first-class values and turning them into serializable payloads or exceptions. It is designed for APIs, background services, and distributed systems where a stable error contract across application boundaries is important. -## Introduction +## How it works -The core model is built around a few simple concepts: +The library separates an error's identity, its semantics, and its transport shape: -* `ServiceError` carries well-known error descriptor together with a human-friendly message and optional inner details. -* `ErrorDescriptor` describes an error with a problem type (error URI), HTTP-like status, transience, and severity. -* `ErrorUri` and `ErrorGroupUri` represent problem type URI inspired by [RFC 9457: Problem Details for HTTP APIs](https://datatracker.ietf.org/doc/html/rfc9457). -* `ServiceErrorDto` represents serializable service error format. -* `ServiceException` and `IServiceException` allow to pass service errors as exceptions. -* `IServiceErrorFactory` converts between `ServiceError`, `ServiceErrorDto`, and `IServiceException`. +1. `ErrorGroupUri` and `ErrorUri` describe a stable problem type such as `problem://orders-api/orders/order-not-found`. The URI format is compatible with [RFC 9457: Problem Details for HTTP APIs](https://datatracker.ietf.org/doc/html/rfc9457). +2. `ErrorDescriptor` adds the runtime semantics: HTTP-like status, transience, and severity. +3. `ServiceError` is the value you pass around inside your application. It combines the descriptor with a human-readable message and optional inner exception or nested errors, so the contract stays explicit without forcing every caller to use exceptions. +4. `IServiceErrorFactory` is the bridge between the in-process model and the outside world. It can convert a `ServiceError` into a serializable `ServiceErrorDto`, restore a `ServiceError` from a DTO, or create an exception for a known error. +5. `ServiceException` and `IServiceException` let you propagate the same error as an exception while preserving the underlying descriptor. When the factory knows about a registered error, it can instantiate a typed exception for that descriptor or its containing group. +6. Matching helpers (`Matches`, `MatchesAny`) compare descriptors, error groups, error URIs, and status codes so callers can branch on well-known errors without hard-coding strings. -Typical usage scenarios include: +In practice, you usually define a small set of well-known descriptors once, register them in DI, create and inspect `ServiceError` values in your domain code, and serialize them at API boundaries. That gives you a contract that is stable for tooling, readable for humans, and cheap to process in production code. -* Enforcing usage of well-known domain errors. -* Exposing a predictable error contract from HTTP APIs or gRPC services. -* Registering error definitions in DI so services can create and rehydrate errors consistently. -* Passing errors across process boundaries using a serializable error payload. -* Use allocation-free typed errors instead of error codes or exceptions in performance-sensitive code. +## Basic examples -### Minimal example - -Handling the errors: +Handling errors: ```csharp using CodeMe.ServiceErrors; @@ -101,15 +95,15 @@ internal sealed class OrderNotFoundException : ServiceException } ``` -## Advanced usage +## More scenarios ### Checking for well-known errors -All error-related types do provide `Matches(...)` / `MatchesAny()` methods. Match logic works as follows: -* x matches to StatusCode: exact match. -* x matches to ErrorUri: exact match. -* x matches to ErrorGroupUri: match if x.Group is descendant of specified error group. -* x matches to ErrorDescriptor: match if x.Type and x.StatusCode are equal to descriptor's type and status code. +All error-related types provide `Matches(...)` and `MatchesAny()` methods. Matching works as follows: +* x matches a status code: exact match. +* x matches an `ErrorUri`: exact match. +* x matches an `ErrorGroupUri`: matches if `x.Group` is a descendant of the specified error group. +* x matches an `ErrorDescriptor`: matches if `x.Type` and `x.StatusCode` are equal to the descriptor's type and status code. Example usage: ```csharp @@ -128,7 +122,7 @@ if (error.Matches(notFoundDescriptor)) { } -// Test for error groups (checks if any group do contain descriptor's error group) +// Test for error groups (checks whether any group contains the descriptor's error group) if (notFoundDescriptor.MatchesAny(rootGroup, otherAppGroup)) { } @@ -183,7 +177,7 @@ ServiceError restored = factory.CreateError(dto); ### Exception Mapping -You can attach an exception type to a well-known error descriptor or error group with the `ServiceExceptionAttribute` family. When a matching error is turned into an exception, the factory instantiates the configured exception type. The target exception type must expose a public constructor that accepts `ServiceError` as a single argument. +You can attach an exception type to a well-known error descriptor or error group through the `ServiceExceptionAttribute` family. When a matching error is turned into an exception, the factory instantiates the configured exception type. The target exception type must expose a public constructor that accepts `ServiceError` as its single argument. ```csharp using CodeMe.ServiceErrors; @@ -204,7 +198,7 @@ ServiceError restoredError = factory.CreateError((Exception)exception); #### Unknown exceptions -If exception is not registered, the `IServiceErrorFactory.CreateError()` method will return ServiceException with error code derived from exception's type. +If an exception is not registered, the `IServiceErrorFactory.CreateError()` method returns a `ServiceException` whose error code is derived from the exception's type. ```csharp using CodeMe.ServiceErrors; @@ -224,11 +218,11 @@ var error = factory.CreateError(ex); ``` -### DI registrations of well-known errors +### DI registrations for well-known errors The DI extensions support three common registration patterns: -- Register a specific well-known errors type. +- Register a specific well-known error type. - Register all well-known error types from one assembly. - Register well-known error types from an assembly and its referenced assemblies. @@ -261,7 +255,7 @@ The `Add` overload registers the static error container type directly. `AddAssem #### Typed error factories and per-factory well-known error registration -In some cases it is useful to have a custom error factory configuration instead of the default one. As example, you may want to have a specialized error factory for client of some external service and do not want external service errors to be used across the rest of your application. Meet the typed factory concept. You have to create a marker interface derived from `IServiceErrorFactory` and use it as a type argument for the `AddServiceErrors()` call. +In some cases, it is useful to have a custom error factory configuration rather than the default one. For example, you may want a specialized error factory for a client of an external service without allowing those external service errors to be used across the rest of your application. Meet the typed factory concept. You must create a marker interface derived from `IServiceErrorFactory` and use it as the type argument for the `AddServiceErrors()` call. ```csharp using CodeMe.ServiceErrors; @@ -284,7 +278,7 @@ With this setup, the container can resolve `IOrdersErrorFactory` as a typed serv ### DI-free error factory -For scenarios where you do not want to use DI, you can create a service error factory directly using `DefaultServiceErrorFactoryBuilder`. Same configuration, no DI. +For scenarios where you do not want to use DI, you can create a service error factory directly using `DefaultServiceErrorFactoryBuilder` with the same configuration and without DI. ```csharp using CodeMe.ServiceErrors; diff --git a/docs/docs.csproj b/docs/docs.csproj index 5fba55e..4b859fc 100644 --- a/docs/docs.csproj +++ b/docs/docs.csproj @@ -4,4 +4,8 @@ netstandard2.0 + + + + diff --git a/src/CodeMe.Basics/CodeMe.Basics.csproj b/src/CodeMe.Basics/CodeMe.Basics.csproj index 9aff5c0..e7deac3 100644 --- a/src/CodeMe.Basics/CodeMe.Basics.csproj +++ b/src/CodeMe.Basics/CodeMe.Basics.csproj @@ -2,6 +2,7 @@ net10.0 + CodeMe Library or simple reusable infrastructure types (scoped async local etc). AsyncLocal;ScopedAsyncLocal diff --git a/src/CodeMe.Basics/README.md b/src/CodeMe.Basics/README.md index 46178a4..abcaf6e 100644 --- a/src/CodeMe.Basics/README.md +++ b/src/CodeMe.Basics/README.md @@ -1,87 +1,25 @@ # CodeMe.Basics -CodeMe.Basics is a library for simple reusable infrastructure types that are missing in BCL. +CodeMe.Basics provides small, reusable infrastructure types that complement the BCL. -# ScopedAsyncLocal +## ScopedAsyncLocal -`ScopedAsyncLocal` provides ambient context for a logical execution flow. It is useful when a value should be available to all code in the current flow without explicitly passing it through every method call. Typical scenarios include unit of work scopes, request correlation identifiers, tenant information, etc. - -## Scenarios and example of usage - -Use `ScopedAsyncLocal` when a value should be available to the current logical execution path and reverted automatically when the scope ends. The value is visible to nested scopes and is restored to the previous ambient value when the scope is disposed. +`ScopedAsyncLocal` lets you carry ambient context through a logical execution flow without explicitly passing it through every method call. Use `ScopedAsyncLocal` for values such as request IDs, unit-of-work state, or tenant information. The value is automatically restored when the scope is disposed. ```csharp -using CodeMe.Basics.Threading; +using CodeMe.Threading; var context = new ScopedAsyncLocal(); using (context.BeginScope("request-1")) { - Console.WriteLine(context.Current); // request-1 - - using (context.BeginScope("nested")) - { - Console.WriteLine(context.Current); // nested - } - - Console.WriteLine(context.Current); // request-1 + Console.WriteLine(context.Current); // request-1 } Console.WriteLine(context.Current); // null ``` -n t -`ScopedAsyncLocal` also works with asynchronous flows. The value from the current scope is available after `await`; the scope must be disposed to restore the previous value. - -```csharp -var local = new ScopedAsyncLocal(); - -using (await local.BeginScopeAsync(() => new ValueTask("request-2"))) -{ - Console.WriteLine(local.Current); // request-2 -} -``` - -The constructor can be used with `validateDisposeOrder: true` to detect out-of-order scope disposal. - -## Using BeginScopeAsync from helper methods - -`BeginScopeAsync` initializes the scope asynchronously through a value factory. If you call it from a helper method, keep the helper synchronous and avoid `await` in the call. Otherwise new scope will not be propagated back to the caller. The value factory itself may use `await`. - -```csharp - private Task BeginUnitOfWorkAsync( - ScopedAsyncLocal local, - CancellationToken cancellation = default) => - local.BeginScopeAsync( - async () => - { - // The body is simplified for demonstration purposes. - DbConnection? connection = null; - DbTransaction? transaction = null; - try - { - connection = await _connectionFactory.CreateConnectionAsync(cancellation); - await connection.OpenAsync(cancellation); - transaction = await connection.BeginTransactionAsync(cancellation); - - return new UnitOfWork(connection, transaction); - } - catch (Exception ex) - { - if (transaction != null) - { - await transaction.DisposeAsync(); - } - - if (connection != null) - { - await connection.DisposeAsync(); - } - - throw; - } - }); -``` -# Documentation +`ScopedAsyncLocal` also supports asynchronous flows and nested scopes. For more details and additional examples, see the full documentation: -Check [documentation](https://github.com/ig-sinicyn/CodeMe/docs/Basics/README.md) for more details and examples. +- https://github.com/ig-sinicyn/CodeMe/blob/master/docs/Basics/AsyncLocal/README.md +- https://github.com/ig-sinicyn/CodeMe/blob/master/docs/Basics/AsyncLocal/CustomScope.md diff --git a/src/CodeMe.Basics/Threading/ScopedAsyncLocal.TestAccessor.cs b/src/CodeMe.Basics/Threading/ScopedAsyncLocal.TestAccessor.cs index 15a8e99..8cdf057 100644 --- a/src/CodeMe.Basics/Threading/ScopedAsyncLocal.TestAccessor.cs +++ b/src/CodeMe.Basics/Threading/ScopedAsyncLocal.TestAccessor.cs @@ -1,6 +1,6 @@ using System.Collections.Immutable; -namespace CodeMe.Basics.Threading; +namespace CodeMe.Threading; public partial class ScopedAsyncLocal { diff --git a/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs b/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs index f912465..1c6c843 100644 --- a/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs +++ b/src/CodeMe.Basics/Threading/ScopedAsyncLocal.cs @@ -1,7 +1,7 @@ using System.Collections.Immutable; using System.ComponentModel; -namespace CodeMe.Basics.Threading; +namespace CodeMe.Threading; /// /// Provides an ambient context for a logical execution flow. @@ -161,7 +161,7 @@ public IDisposable BeginScope(T? value) /// Async factory for the new scope ambient value. Value will be replaced with previous one on scope disposal. /// /// to restore the parent scope. - public Task BeginScopeAsync(Func> valueFactory) + public ValueTask BeginScopeAsync(Func> valueFactory) { ArgumentNullException.ThrowIfNull(valueFactory); @@ -169,7 +169,7 @@ public Task BeginScopeAsync(Func> valueFactory) return CompleteScopeAsync(newScope, valueFactory); } - private static async Task CompleteScopeAsync( + private static async ValueTask CompleteScopeAsync( Scope newScope, Func> valueFactory) { diff --git a/src/CodeMe.ServiceErrors/README.md b/src/CodeMe.ServiceErrors/README.md index c87228b..ed5353d 100644 --- a/src/CodeMe.ServiceErrors/README.md +++ b/src/CodeMe.ServiceErrors/README.md @@ -1,98 +1,27 @@ # CodeMe.ServiceErrors -CodeMe.ServiceErrors is a library for describing service-level errors as first-class values and turning them into serializable payloads or exceptions. It is designed for APIs, background services, and distributed systems where you want a stable error contract across app boundaries. +CodeMe.ServiceErrors helps you describe service-level errors as first-class values and propagate them consistently across application boundaries. -## Introduction +The package is designed for APIs, background services, and distributed systems where a stable error contract matters. It lets you: -The core model is built around a few simple concepts: +- define well-known error descriptors with stable URIs and semantics; +- carry errors as `ServiceError` values in your domain code; +- serialize them to `ServiceErrorDto` payloads; +- map them to typed exceptions when needed; +- register error definitions in DI for consistent creation and hydration. -* `ServiceError` carries well-known error descriptor together with a human-friendly message and optional inner details. -* `ErrorDescriptor` describes an error with a problem type (error URI), HTTP-like status, transience, and severity. -* `ErrorUri` and `ErrorGroupUri` represent problem type URI inspired by [RFC 9457: Problem Details for HTTP APIs](https://datatracker.ietf.org/doc/html/rfc9457). -* `ServiceErrorDto` represents serializable service error format. -* `ServiceException` and `IServiceException` allow to pass service errors as exceptions. -* `IServiceErrorFactory` converts between `ServiceError`, `ServiceErrorDto`, and `IServiceException`. +A simple example: -Typical usage scenarios include: - -* Enforcing usage of well-known domain errors. -* Exposing a predictable error contract from HTTP APIs or gRPC services. -* Registering error definitions in DI so services can create and rehydrate errors consistently. -* Passing errors across process boundaries using a serializable error payload. -* Use allocation-free typed errors instead of error codes or exceptions in performance-sensitive code. - -### Minimal example - -Well-known errors, testing for errors, conversions and DI registration: ```csharp using CodeMe.ServiceErrors; -using CodeMe.ServiceErrors.DependencyInjection; -using CodeMe.ServiceErrors.Serializable; -using Microsoft.Extensions.DependencyInjection; -using static WellKnownOrderApiErrors; - -// DI registration -var services = new ServiceCollection(); -services - .AddServiceErrors(RootGroup) - .Add(typeof(WellKnownOrderApiErrors)); -using var provider = services.BuildServiceProvider(); -var errorFactory = provider.GetRequiredService(); - -// Error return and handling -var error = new ServiceError(OrderNotFound, "Order 42 was not found"); -// ... -if (error.Matches(OrderNotFound)) -{ - // handle the error -} - -// Error serialization and exception factory -ServiceError error = new ServiceError(OrderNotFound, "Order 42 was not found"); -ServiceErrorDto dto = errorFactory.CreateDto(error); -ServiceError errorFromDto = errorFactory.CreateError(dto); -// DTO content in JSON format: -// { -// "scheme": "problem", -// "application": "orders-api", -// "category": "orders", -// "code": "order-not-found", -// "statusCode": "NotFound", -// "message": "Order 42 was not found" -// } - -// returns OrderNotFoundException -IServiceException exception = errorFactory.CreateException(errorFromDto); -ServiceError errorFromException = exception.Error; - -// Well-known errors declaration -[ServiceErrors] -internal static class WellKnownOrderApiErrors -{ - public static readonly ErrorGroupUri RootGroup = ErrorGroupUri.Create("problem", "orders-api"); - - public static readonly ErrorGroupUri OrdersGroup = RootGroup.SubGroup("orders"); - - [ServiceException] - public static readonly ErrorDescriptor OrderNotFound = - ErrorDescriptor.NotFound(OrdersGroup, "order-not-found"); -} -// Typed exceptions -internal sealed class OrderNotFoundException : ServiceException -{ - public OrderNotFoundException(ServiceError error) - : base(AssertMatches(OrderNotFound, error)) - { - } +var descriptor = ErrorDescriptor.NotFound( + ErrorGroupUri.Create("problem", "orders-api", "orders"), + "order-not-found"); - public OrderNotFoundException(string message, Exception? innerException = null) - : base(OrderNotFound, message, innerException) - { - } -} +var error = new ServiceError(descriptor, "Order 42 was not found"); ``` -# Documentation +For more details, examples, and guidance on DI registration, serialization, and exception mapping, see the full documentation: -Check [documentation](https://github.com/ig-sinicyn/CodeMe/docs/ServiceErrors/README.md) for more details and examples. \ No newline at end of file +https://github.com/ig-sinicyn/CodeMe/blob/feature/unit-of-work/docs/ServiceErrors/README.md diff --git a/tests/CodeMe.Basics.UnitTests/Threading/CustomScopeTests.cs b/tests/CodeMe.Basics.UnitTests/Threading/CustomScopeTests.cs index bcd4ea9..8e9a34e 100644 --- a/tests/CodeMe.Basics.UnitTests/Threading/CustomScopeTests.cs +++ b/tests/CodeMe.Basics.UnitTests/Threading/CustomScopeTests.cs @@ -1,4 +1,4 @@ -using CodeMe.Basics.Threading; +using CodeMe.Threading; namespace CodeMe.Basics.UnitTests.Threading; diff --git a/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalInternalTests.cs b/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalInternalTests.cs index e37af02..3a75041 100644 --- a/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalInternalTests.cs +++ b/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalInternalTests.cs @@ -1,4 +1,4 @@ -using CodeMe.Basics.Threading; +using CodeMe.Threading; namespace CodeMe.Basics.UnitTests.Threading; diff --git a/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalTests.cs b/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalTests.cs index 7ced292..b740e69 100644 --- a/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalTests.cs +++ b/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalTests.cs @@ -1,4 +1,4 @@ -using CodeMe.Basics.Threading; +using CodeMe.Threading; namespace CodeMe.Basics.UnitTests.Threading; From ca95a9e1c52a246c6e0c69f725cec4acffe00fce Mon Sep 17 00:00:00 2001 From: Igor Sinicyn Date: Sun, 19 Jul 2026 15:29:00 +0300 Subject: [PATCH 2/3] Docs cleanup. --- README.md | 46 +++++++++---------- .../README.md | 25 ++++------ src/CodeMe.ServiceErrors/README.md | 2 +- 3 files changed, 32 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 6a15d9e..abf31e8 100644 --- a/README.md +++ b/README.md @@ -4,29 +4,6 @@ - CodeMe.ServiceErrors — describe service-level errors as first-class values, carry them as `ServiceError`, serialize them to DTOs, and map them to typed exceptions. See the [full documentation](docs/ServiceErrors/README.md). - CodeMe.Basics — small infrastructure helpers such as `ScopedAsyncLocal` for ambient execution context and logical scopes. See the [full documentation](docs/Basics/AsyncLocal/README.md). -# CodeMe.Basics - -CodeMe.Basics provides small, reusable infrastructure types that complement the BCL. - -## ScopedAsyncLocal - -`ScopedAsyncLocal` lets you carry ambient context through a logical execution flow without explicitly passing it through every method call. Use `ScopedAsyncLocal` for values such as request IDs, unit-of-work state, or tenant information. The value is automatically restored when the scope is disposed. - -```csharp -using CodeMe.Threading; - -var context = new ScopedAsyncLocal(); - -using (context.BeginScope("request-1")) -{ - Console.WriteLine(context.Current); // request-1 -} - -Console.WriteLine(context.Current); // null -``` - -`ScopedAsyncLocal` also supports asynchronous flows and nested scopes. For more details and additional examples, see the [full documentation](docs/Basics/AsyncLocal/README.md). - # CodeMe.ServiceErrors CodeMe.ServiceErrors helps you describe service-level errors as first-class values and propagate them consistently across application boundaries. @@ -51,4 +28,27 @@ var descriptor = ErrorDescriptor.NotFound( var error = new ServiceError(descriptor, "Order 42 was not found"); ``` +# CodeMe.Basics + +CodeMe.Basics provides small, reusable infrastructure types that complement the BCL. + +## ScopedAsyncLocal + +`ScopedAsyncLocal` lets you carry ambient context through a logical execution flow without explicitly passing it through every method call. Use `ScopedAsyncLocal` for values such as request IDs, unit-of-work state, or tenant information. The value is automatically restored when the scope is disposed. + +```csharp +using CodeMe.Threading; + +var context = new ScopedAsyncLocal(); + +using (context.BeginScope("request-1")) +{ + Console.WriteLine(context.Current); // request-1 +} + +Console.WriteLine(context.Current); // null +``` + +`ScopedAsyncLocal` also supports asynchronous flows and nested scopes. For more details and additional examples, see the [full documentation](docs/Basics/AsyncLocal/README.md). + For more details, examples, and guidance on DI registration, serialization, and exception mapping, see the [full documentation](docs/ServiceErrors/README.md). \ No newline at end of file diff --git a/src/CodeMe.ServiceErrors.Abstractions/README.md b/src/CodeMe.ServiceErrors.Abstractions/README.md index eb93c09..1183a7c 100644 --- a/src/CodeMe.ServiceErrors.Abstractions/README.md +++ b/src/CodeMe.ServiceErrors.Abstractions/README.md @@ -1,28 +1,19 @@ -# CodeMe.ServiceErrors +# CodeMe.ServiceErrors.Abstractions -CodeMe.ServiceErrors.Abstractions is a library for describing service-level errors as first-class values and turning them into serializable payloads or exceptions. It is designed for APIs, background services, and distributed systems where you want a stable error contract across app boundaries. +CodeMe.ServiceErrors.Abstractions helps you describe service-level errors as first-class values. The package contains contract types for the main `CodeMe.ServiceErrors` package. -## Minimal example +A simple example: -Handling the errors: ```csharp using CodeMe.ServiceErrors; -// Error URI: problem://orders-api/orders/order-not-found, status 404 var descriptor = ErrorDescriptor.NotFound( - group: ErrorGroupUri.Create("problem", "orders-api", "orders"), - code: "order-not-found"); + ErrorGroupUri.Create("problem", "orders-api", "orders"), + "order-not-found"); -var error = new ServiceError(descriptor, "Order 66 was not found"); - -// ... - -if (error.Matches(descriptor)) -{ - // handle the error -} +var error = new ServiceError(descriptor, "Order 42 was not found"); ``` -# Documentation +For more details, examples, and guidance on DI registration, serialization, and exception mapping, see the full documentation: -Check [documentation](https://github.com/ig-sinicyn/CodeMe/docs/ServiceErrors/README.md) for more details and examples. \ No newline at end of file +https://github.com/ig-sinicyn/CodeMe/blob/master/docs/ServiceErrors/README.md \ No newline at end of file diff --git a/src/CodeMe.ServiceErrors/README.md b/src/CodeMe.ServiceErrors/README.md index ed5353d..0b6c204 100644 --- a/src/CodeMe.ServiceErrors/README.md +++ b/src/CodeMe.ServiceErrors/README.md @@ -24,4 +24,4 @@ var error = new ServiceError(descriptor, "Order 42 was not found"); For more details, examples, and guidance on DI registration, serialization, and exception mapping, see the full documentation: -https://github.com/ig-sinicyn/CodeMe/blob/feature/unit-of-work/docs/ServiceErrors/README.md +https://github.com/ig-sinicyn/CodeMe/blob/master/docs/ServiceErrors/README.md From 34210b813ece42f0c70a20a52baa9cdb68549f79 Mon Sep 17 00:00:00 2001 From: Igor Sinicyn Date: Sun, 19 Jul 2026 15:32:15 +0300 Subject: [PATCH 3/3] Docs cleanup. --- .../Threading/ScopedAsyncLocalInternalTests.cs | 2 +- .../CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalInternalTests.cs b/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalInternalTests.cs index 3a75041..04cf7ce 100644 --- a/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalInternalTests.cs +++ b/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalInternalTests.cs @@ -306,7 +306,7 @@ public async Task BeginScopeAsyncAndFail_FullAsyncPath_ShouldBeExpected() var afterCall = internals.GetSingleSnapshot(); completion.SetResult(); - await Assert.ThrowsAsync(() => task); + await Assert.ThrowsAsync(async () => await task); var afterAwait = internals.GetSingleSnapshot(); // Assert diff --git a/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalTests.cs b/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalTests.cs index b740e69..e3516d5 100644 --- a/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalTests.cs +++ b/tests/CodeMe.Basics.UnitTests/Threading/ScopedAsyncLocalTests.cs @@ -287,7 +287,7 @@ public async Task ScopeValidation_ShouldThrowOnBackpropagate() Assert.Throws(() => scope.Dispose()); } - private static Task BeginScopeAsync(ScopedAsyncLocal local, string value) => + private static ValueTask BeginScopeAsync(ScopedAsyncLocal local, string value) => local.BeginScopeAsync( async () => {