Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 37 additions & 66 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 — 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<T>` for ambient execution context and logical scopes. See the [full documentation](docs/Basics/AsyncLocal/README.md).

# 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. Check [documentation](https://github.com/ig-sinicyn/CodeMe/docs/ServiceErrors/README.md) for more details and examples.
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.

## Minimal example
A simple 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<IServiceErrorFactory>();

// 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");
var descriptor = ErrorDescriptor.NotFound(
ErrorGroupUri.Create("problem", "orders-api", "orders"),
"order-not-found");

public static readonly ErrorGroupUri OrdersGroup = RootGroup.SubGroup("orders");
var error = new ServiceError(descriptor, "Order 42 was not found");
```

[ServiceException<OrderNotFoundException>]
public static readonly ErrorDescriptor OrderNotFound =
ErrorDescriptor.NotFound(OrdersGroup, "order-not-found");
}
# CodeMe.Basics

// Typed exceptions
internal sealed class OrderNotFoundException : ServiceException
CodeMe.Basics provides small, reusable infrastructure types that complement the BCL.

## ScopedAsyncLocal<T>

`ScopedAsyncLocal<T>` lets you carry ambient context through a logical execution flow without explicitly passing it through every method call. Use `ScopedAsyncLocal<T>` 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<string>();

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<T>` also supports asynchronous flows and nested scopes. For more details and additional examples, see the [full documentation](docs/Basics/AsyncLocal/README.md).

CodeMe.Basics contains BCL-style extensions such as `ScopedAsyncLocal<T>` and others.
For more details, examples, and guidance on DI registration, serialization, and exception mapping, see the [full documentation](docs/ServiceErrors/README.md).
35 changes: 21 additions & 14 deletions docs/Basics/AsyncLocal/CustomScope.md
Original file line number Diff line number Diff line change
@@ -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<T>`.

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())
{
Expand All @@ -22,8 +28,11 @@ async ValueTask<User> 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
{
Expand All @@ -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);
}
Expand All @@ -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
{
Expand All @@ -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);

Expand All @@ -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()
{
Expand All @@ -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();
}
Expand All @@ -128,4 +135,4 @@ public sealed class UnitOfWork : IAsyncDisposable
await _connection.DisposeAsync();
}
}
```
```
120 changes: 69 additions & 51 deletions docs/Basics/AsyncLocal/README.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,26 @@

# ScopedAsyncLocal<T>

`ScopedAsyncLocal<T>` 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<T>` 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<T>` 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<T>` is built on top of `AsyncLocal<T>` 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<T>`, 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<T>` 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<T>` with `validateDisposeOrder: true` to detect out-of-order scope disposal.

## Scenarios and example of usage
## Main scenario

Use `ScopedAsyncLocal<T>` 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<T>` 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<string>();

Expand All @@ -40,54 +39,73 @@ using (context.BeginScope("request-1"))
Console.WriteLine(context.Current); // null
```

`ScopedAsyncLocal<T>` 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<string>();
using CodeMe.Threading;

var context = new ScopedAsyncLocal<string>();
var id = Guid.Parse("7b90489c-7d81-42bc-99d6-ba6dc118375f");

using (await local.BeginScopeAsync(() => new ValueTask<string>("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<IDisposable> 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<string> 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<IDisposable> BeginUnitOfWorkAsync(
ScopedAsyncLocal<IUnitOfWork> 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;
}
});
```
using CodeMe.Threading;

var context = new ScopedAsyncLocal<string>();
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<IDisposable> BeginCustomScopeAsync(Guid userId)
{
// The method MUST be synchronous
var scope = context.BeginScopeInitialization();
return CompleteBeginCustomScopeAsync(scope, userId);
}

async ValueTask<IDisposable> CompleteBeginCustomScopeAsync(ScopedAsyncLocal<string>.Scope scope, Guid userId)
{
// Asynchronous part
try
{
var state = await GetUserStateAsync(userId);
scope.Initialize(state);
return scope;
}
catch (Exception)
{
scope.Dispose();
throw;
}
}

Task<string> GetUserStateAsync(Guid userId) => Task.FromResult(userId.ToString());
```
Loading