Skip to content

V2: Major architecture upgrade to .NET 10#18

Open
ahmet-cetinkaya wants to merge 200 commits into
mainfrom
v2
Open

V2: Major architecture upgrade to .NET 10#18
ahmet-cetinkaya wants to merge 200 commits into
mainfrom
v2

Conversation

@ahmet-cetinkaya
Copy link
Copy Markdown
Owner

Summary

  • Upgrade to .NET 10
  • Restructure solution with modular architecture
  • Add comprehensive pipeline behaviors (auth, caching, logging, performance, validation)
  • Implement CQRS patterns with MediatR-style approach
  • Enhanced security with JWT authentication and role-based authorization
  • Improved persistence layer with Entity Framework integration

Test plan

  • Build and test all projects
  • Verify pipeline behaviors work correctly
  • Test authentication and authorization flows
  • Validate Entity Framework integration
  • Run comprehensive test suite

… instructions; add custom commands to global.json
…s struct and update ISecuredRequest interface

```
BenchmarkDotNet v0.14.0, Arch Linux
AMD Ryzen 7 7800X3D, 1 CPU, 16 logical and 8 physical cores
.NET SDK 9.0.102
  [Host]     : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
  Job-BZVZVG : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI

IterationCount=5  WarmupCount=3

Before:

| Method          | Mean       | Error     | StdDev    | Ratio | RatioSD | Gen0   | Allocated | Alloc Ratio |
|---------------- |-----------:|----------:|----------:|------:|--------:|-------:|----------:|------------:|
| AdminRole       |   307.0 ns |  16.80 ns |   4.36 ns |  1.00 |    0.02 | 0.0143 |     736 B |        1.00 |
| MatchingRole    |   327.1 ns |   8.12 ns |   2.11 ns |  1.07 |    0.02 | 0.0143 |     736 B |        1.00 |
| NonMatchingRole | 4,976.5 ns | 710.57 ns | 109.96 ns | 16.21 |    0.38 | 0.0305 |    1592 B |        2.16 |
| MultipleRoles   |   318.0 ns |  26.94 ns |   7.00 ns |  1.04 |    0.02 | 0.0143 |     736 B |        1.00 |

After:

| Method          | Mean        | Error     | StdDev   | Ratio  | RatioSD | Gen0   | Allocated | Alloc Ratio |
|---------------- |------------:|----------:|---------:|-------:|--------:|-------:|----------:|------------:|
| AdminRole       |    15.92 ns |  0.440 ns | 0.114 ns |   1.00 |    0.01 | 0.0014 |      72 B |        1.00 |
| MatchingRole    |    21.82 ns |  1.253 ns | 0.194 ns |   1.37 |    0.01 | 0.0014 |      72 B |        1.00 |
| NonMatchingRole | 2,609.38 ns | 40.857 ns | 6.323 ns | 163.95 |    1.14 | 0.0153 |     904 B |       12.56 |
| MultipleRoles   |    32.44 ns |  1.570 ns | 0.408 ns |   2.04 |    0.03 | 0.0014 |      72 B |        1.00 |
```
…ents, restructure request interfaces, and update expiration settings
…ling and improved cancellation handling

```
BenchmarkDotNet v0.14.0, Arch Linux
AMD Ryzen 7 7800X3D, 1 CPU, 16 logical and 8 physical cores
.NET SDK 9.0.102
  [Host]     : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
  Job-NVPNBL : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI

IterationCount=5  WarmupCount=3

Before:
| Method                           | Mean      | Error     | StdDev    | Ratio | Gen0   | Gen1   | Allocated | Alloc Ratio |
|--------------------------------- |----------:|----------:|----------:|------:|-------:|-------:|----------:|------------:|
| 'Baseline - No Cache Operations' | 26.938 us | 0.4809 us | 0.1249 us |  1.00 | 2.5330 |      - |    125 KB |        1.00 |
| 'Remove Single Key'              |  2.062 us | 0.4421 us | 0.1148 us |  0.08 | 0.0273 | 0.0234 |   1.41 KB |        0.01 |
| 'Remove Group Keys'              | 10.181 us | 0.3096 us | 0.0804 us |  0.38 | 0.1250 | 0.1094 |   7.16 KB |        0.06 |
| 'Remove Both Single and Group'   | 12.763 us | 1.1912 us | 0.3093 us |  0.47 | 0.1406 | 0.1250 |   7.28 KB |        0.06 |

After:
| Method                           | Mean      | Error     | StdDev    | Ratio | RatioSD | Gen0   | Gen1   | Allocated | Alloc Ratio |
|--------------------------------- |----------:|----------:|----------:|------:|--------:|-------:|-------:|----------:|------------:|
| 'Baseline - No Cache Operations' | 24.979 us | 1.9945 us | 0.5180 us |  1.00 |    0.03 | 2.5330 |      - |  128000 B |       1.000 |
| 'Remove Single Key'              |  1.053 us | 0.0874 us | 0.0227 us |  0.04 |    0.00 | 0.0137 | 0.0117 |     712 B |       0.006 |
| 'Remove Group Keys'              |  7.879 us | 0.9904 us | 0.2572 us |  0.32 |    0.01 | 0.0781 | 0.0625 |    4416 B |       0.035 |
| 'Remove Both Single and Group'   |  8.876 us | 0.3903 us | 0.1014 us |  0.36 |    0.01 | 0.0938 | 0.0781 |    4864 B |       0.038 |
```
…nd improved cancellation handling

```
AMD Ryzen 7 7800X3D, 1 CPU, 16 logical and 8 physical cores
.NET SDK 9.0.102
  [Host]     : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
  Job-SPBUNF : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI

IterationCount=5  WarmupCount=3

Before:
| Method                       | Mean          | Error         | StdDev        | Gen0   | Gen1   | Allocated |
|----------------------------- |--------------:|--------------:|--------------:|-------:|-------:|----------:|
| 'Cache Miss - New Entry'     | 182,094.96 ns | 50,984.493 ns | 13,240.511 ns | 0.3333 |      - |   23324 B |
| 'Cache Hit - Existing Entry' |   1,901.39 ns |    121.888 ns |     31.654 ns | 0.0267 | 0.0248 |    1432 B |
| 'Cache Bypass'               |      24.57 ns |      0.908 ns |      0.236 ns | 0.0029 |      - |     144 B |

After:
| Method                       | Mean          | Error         | StdDev       | Gen0   | Gen1   | Allocated |
|----------------------------- |--------------:|--------------:|-------------:|-------:|-------:|----------:|
| 'Cache Miss - New Entry'     | 156,176.51 ns | 36,653.266 ns | 9,518.737 ns | 0.5000 |      - |   25864 B |
| 'Cache Hit - Existing Entry' |   1,799.25 ns |    217.875 ns |    56.581 ns | 0.0248 | 0.0229 |    1320 B |
| 'Cache Bypass'               |      24.85 ns |      1.294 ns |     0.200 ns | 0.0029 |      - |     144 B |
```
…gBehavior for better responsiveness and remove pools

```
Before:
| Method                                 | Mean            | Error            | StdDev         | Gen0   | Gen1   | Allocated |
|--------------------------------------- |----------------:|-----------------:|---------------:|-------:|-------:|----------:|
| 'Cache Miss - New Entry'               |   155,628.72 ns |    44,816.636 ns |  11,638.738 ns | 0.5000 |      - |   25936 B |
| 'Cache Hit - Existing Entry'           |     1,886.46 ns |       217.700 ns |      56.536 ns | 0.0248 | 0.0229 |    1320 B |
| 'Cache Bypass'                         |        25.30 ns |         2.055 ns |       0.534 ns | 0.0029 |      - |     144 B |
| 'Parallel Cache Miss - 100 Requests'   | 1,110,506.80 ns | 1,158,851.560 ns | 300,950.071 ns | 9.7656 | 3.9063 |  608378 B |
| 'Sequential Cache Miss - 100 Requests' | 1,048,207.76 ns | 1,058,025.939 ns | 274,765.977 ns | 9.7656 | 3.9063 |  606225 B |
| 'Parallel Cache Hit - 100 Requests'    |   220,323.14 ns |    30,529.056 ns |   7,928.299 ns | 2.6855 | 2.4414 |  177435 B |
| 'Sequential Cache Hit - 100 Requests'  |   201,467.41 ns |    21,038.839 ns |   5,463.720 ns | 2.6855 | 2.4414 |  175171 B |

After:
| Method                                 |          Mean |            Error |         StdDev |   Gen0 |   Gen1 | Allocated |
| -------------------------------------- | ------------: | ---------------: | -------------: | -----: | -----: | --------: |
| 'Cache Miss - New Entry'               | 171,978.84 ns |    56,949.025 ns |  14,789.481 ns | 0.3333 |      - |   22280 B |
| 'Cache Hit - Existing Entry'           |   1,692.19 ns |       170.363 ns |      44.243 ns | 0.0248 | 0.0229 |    1320 B |
| 'Cache Bypass'                         |      23.61 ns |         0.438 ns |       0.114 ns | 0.0029 |      - |     144 B |
| 'Parallel Cache Miss - 100 Requests'   | 887,043.03 ns | 1,083,843.609 ns | 281,470.744 ns | 1.9531 |      - |  189141 B |
| 'Sequential Cache Miss - 100 Requests' | 904,417.10 ns | 1,091,516.239 ns | 283,463.302 ns | 1.9531 |      - |  186878 B |
| 'Parallel Cache Hit - 100 Requests'    | 198,198.37 ns |    24,243.779 ns |   6,296.032 ns | 2.6855 | 2.4414 |  177435 B |
| 'Sequential Cache Hit - 100 Requests'  | 189,024.62 ns |    23,531.312 ns |   6,111.007 ns | 2.6855 | 2.4414 |  175172 B |
```
…or enhanced logging and remove http context dependency

```
BenchmarkDotNet v0.14.0, Arch Linux
AMD Ryzen 7 7800X3D, 1 CPU, 16 logical and 8 physical cores
.NET SDK 9.0.102
  [Host]     : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
  Job-AXFFZO : .NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI

IterationCount=5  WarmupCount=3

Before:
| Method                          |         Mean |      Error |    StdDev |  Ratio | RatioSD |    Gen0 |    Gen1 |  Allocated | Alloc Ratio |
| ------------------------------- | -----------: | ---------: | --------: | -----: | ------: | ------: | ------: | ---------: | ----------: |
| Handle_BasicRequest             |     10.20 us |   0.953 us |  0.247 us |   1.00 |    0.03 |  0.1984 |  0.1831 |   10.98 KB |        1.00 |
| Handle_WithLargePayload         |     58.25 us |   5.124 us |  0.793 us |   5.71 |    0.14 |  4.1504 |  4.0283 |  206.76 KB |       18.84 |
| Handle_WithAsyncResponse        | 10,116.91 us | 144.551 us | 22.369 us | 992.52 |   22.01 |       - |       - |   13.67 KB |        1.25 |
| Handle_MultipleSequential       |  1,007.93 us |  65.982 us | 17.135 us |  98.88 |    2.66 | 19.5313 | 17.5781 | 1079.88 KB |       98.39 |
| Handle_MultipleParallel         |  1,017.80 us |  41.982 us | 10.903 us |  99.85 |    2.40 | 19.5313 | 17.5781 | 1083.83 KB |       98.75 |
| Handle_MultipleBatched          |  1,040.46 us |  33.805 us |  8.779 us | 102.07 |    2.38 | 21.4844 | 19.5313 | 1097.03 KB |       99.95 |
| Handle_ConcurrentWithThrottling |    571.82 us |  26.524 us |  6.888 us |  56.10 |    1.38 | 24.4141 | 23.4375 | 1107.36 KB |      100.89 |
| Handle_ProducerConsumerPattern  |    646.96 us |  38.022 us |  9.874 us |  63.47 |    1.65 | 23.4375 | 21.4844 | 1082.65 KB |       98.64 |

After:
| Method                          |          Mean |      Error |     StdDev |    Ratio | RatioSD |    Gen0 |    Gen1 | Allocated | Alloc Ratio |
| ------------------------------- | ------------: | ---------: | ---------: | -------: | ------: | ------: | ------: | --------: | ----------: |
| Handle_BasicRequest             |      8.089 us |  1.6028 us |  0.4162 us |     1.00 |    0.07 |  0.1831 |  0.1678 |   9.15 KB |        1.00 |
| Handle_WithLargePayload         |     14.865 us |  0.5664 us |  0.1471 us |     1.84 |    0.09 |  0.9766 |  0.4578 |   48.3 KB |        5.28 |
| Handle_WithAsyncResponse        | 10,107.998 us | 34.2875 us |  5.3060 us | 1,252.30 |   60.04 |       - |       - |   9.73 KB |        1.06 |
| Handle_MultipleSequential       |    797.664 us | 55.4247 us | 14.3936 us |    98.82 |    4.99 | 18.5547 | 17.5781 | 946.85 KB |      103.50 |
| Handle_MultipleParallel         |    798.220 us | 66.8508 us | 17.3609 us |    98.89 |    5.11 | 18.5547 | 17.5781 |  950.8 KB |      103.93 |
| Handle_MultipleBatched          |    821.460 us | 38.9047 us | 10.1034 us |   101.77 |    4.99 | 18.5547 | 17.5781 | 963.68 KB |      105.34 |
| Handle_ConcurrentWithThrottling |    439.364 us | 46.7406 us | 12.1384 us |    54.43 |    2.94 | 20.5078 | 19.5313 | 978.97 KB |      107.01 |
| Handle_ProducerConsumerPattern  |    479.659 us |  4.5981 us |  0.7116 us |    59.43 |    2.85 | 19.5313 | 18.5547 | 950.31 KB |      103.88 |
```
…ptions structs for improved clarity and performance
…itoring and update PerformanceBehavior to utilize new interval structure
@gemini-code-assist
Copy link
Copy Markdown

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

- Replace Authors: Kodlama.io, Contributors → NArchitecture, Contributors
- Remove Company and Copyright fields
- Update RepositoryUrl to github.com/NArchitecture/narchitecture-core
- Apply consistent branding across all 39 core packages
- Update .NET version from 9.0.x to 10.0.x in CI workflow
- Upgrade actions/checkout from v3 to v4 in dotnet.yml
- Upgrade actions/setup-dotnet from v3 to v4 in dotnet.yml
- Add explicit .NET 10.0.x setup to nuget-publish workflow
- Upgrade actions/checkout from v2 to v4 in nuget-publish.yml
@ahmet-cetinkaya
Copy link
Copy Markdown
Owner Author

/gemini review

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a significant and impressive architectural upgrade to .NET 10, modularizing the solution and improving many aspects like developer workflow, performance, and security abstractions. The new structure is much cleaner and more maintainable.

My review has identified a few issues, including a critical security vulnerability in the authorization logic that needs immediate attention. I've also found a potential security concern with the use of sudo in scripts and a few minor issues in configuration and documentation. Overall, this is a fantastic step forward for the project. Once the identified issues are addressed, this will be a solid foundation to build upon.

Comment on lines +46 to +74
public bool HasAnyRequiredRole()
{
if (!IsAuthenticated)
return false;
if (_hasAdminRole)
return true;
if (_requiredRoles == null || _requiredRoles.Length == 0)
return true;

bool hasAnyValidRequiredRole = false;
foreach (string required in _requiredRoles)
{
if (string.IsNullOrWhiteSpace(required))
return true;

hasAnyValidRequiredRole = true;
ReadOnlySpan<char> requiredSpan = required.AsSpan().Trim();

foreach (string identity in _identityRoles!)
{
if (string.IsNullOrWhiteSpace(identity))
continue;
if (identity.AsSpan().Trim().Equals(requiredSpan, StringComparison.OrdinalIgnoreCase))
return true;
}
}

return !hasAnyValidRequiredRole;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

There's a security vulnerability in the HasAnyRequiredRole method. An empty or whitespace string in the requiredRoles array will incorrectly grant access.
Specifically, this line is problematic:
if (string.IsNullOrWhiteSpace(required)) return true;
This should probably be continue; to ignore invalid role entries.
The logic with return !hasAnyValidRequiredRole; is also a bit confusing. A clearer and safer implementation is recommended.

    public bool HasAnyRequiredRole()
    {
        if (!IsAuthenticated)
            return false;

        if (_hasAdminRole)
            return true;

        if (_requiredRoles is null || _requiredRoles.Length == 0)
            return true;

        // If there are no valid roles to check against, access is granted.
        if (!_requiredRoles.Any(r => !string.IsNullOrWhiteSpace(r)))
            return true;

        foreach (string required in _requiredRoles)
        {
            if (string.IsNullOrWhiteSpace(required))
                continue;

            ReadOnlySpan<char> requiredSpan = required.AsSpan().Trim();
            foreach (string identity in _identityRoles!)
            {
                if (string.IsNullOrWhiteSpace(identity))
                    continue;

                if (identity.AsSpan().Trim().Equals(requiredSpan, StringComparison.OrdinalIgnoreCase))
                    return true;
            }
        }

        return false;
    }

Comment thread global.json Outdated
Comment on lines +11 to +12
"benchmark": "sudo dotnet run --project tests/benchmarks/Benchmarks/NArchitecture.Core.Benchmarks.csproj && dotnet restore",
"benchmark:last": "sudo dotnet run --project tests/benchmarks/Benchmarks/NArchitecture.Core.Benchmarks.csproj -- --last-run && dotnet restore",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The benchmark scripts use sudo. This can pose a security risk and may cause issues in CI/CD environments or on developer machines where the user doesn't have sudo privileges. Is sudo strictly necessary? If it's for accessing performance counters, consider if there are alternative ways to run the benchmarks without requiring elevated privileges, or document why it's needed.

Comment thread .config/dotnet-tools.json Outdated
},
"roslynator.dotnet.cli": {
"version": "0.10.0",
"version": "0.9.3",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The version of roslynator.dotnet.cli has been downgraded from 0.10.0 to 0.9.3. Was this intentional? Downgrading a tool might re-introduce old bugs or remove features. If this is to fix a compatibility issue, it would be good to add a comment here for future reference.

Comment thread README.md Outdated
<a href="https://github.com/kodlamaio-projects/nArchitecture.Core/issues">Request Feature</a>
</p>
</p>
# ![NArchitecture Logo](https://github.com/user-attachments/assets/8a61adb0-f0b8-48e4-82b3-db38e10bb052) NArchitecture Core Packages
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The NArchitecture logo image link appears to be broken. The URL points to a GitHub user attachment asset which might not be accessible to everyone or could be temporary. It's recommended to commit the image to the repository (e.g., in a docs/images folder) and use a relative path to ensure it always loads correctly.

Comment thread src/Application/README.md Outdated
public class DeleteUserCommand : IRequest<bool>, ISecuredRequest
{
public string UserId { get; set; }
public RoleClaims RoleClaims => new(CurrentUser.Roles, ["Admin"]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There seems to be a typo in the example for ISecuredRequest. The type RoleClaims is used, but based on the new AuthorizationBehavior, it should probably be AuthOptions.

Suggested change
public RoleClaims RoleClaims => new(CurrentUser.Roles, ["Admin"]);
public AuthOptions AuthOptions => new(CurrentUser.Roles, ["Admin"]);

…ation logic

- Fix HasAnyRequiredRole method that incorrectly granted access for empty/whitespace required roles
- Empty or whitespace required roles now skip validation instead of granting access
- Improve logic clarity and safety by checking for valid required roles first
- Maintain existing behavior for admin roles and null/empty role arrays
- Remove sudo from benchmark and benchmark:last scripts for security and compatibility
- Benchmarks can run without elevated privileges in most environments
- Improves CI/CD compatibility and reduces security risks
- Upgrade roslynator.dotnet.cli from 0.9.3 back to 0.10.0
- Ensures latest features and bug fixes are available
- Maintains consistency with modern tooling versions
- Replace temporary GitHub user attachment URL with local logo file
- Ensures logo always loads correctly for all users
- Improves documentation reliability and accessibility
- Fix RoleClaims type to AuthOptions in ISecuredRequest example
- Ensures documentation matches actual implementation
- Improves developer experience with accurate examples
- csharpier: 0.30.6 → 1.1.1
- roslynator.dotnet.cli: 0.10.0 → 0.10.2
- dotnet-outdated-tool: 4.6.7 → 4.6.8
- run-script and security-scan remain up to date
@ahmet-cetinkaya
Copy link
Copy Markdown
Owner Author

/gemini review

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This is an extensive and impressive architectural upgrade. The move to a more modular structure, the introduction of a custom high-performance Mediator, and the abstraction of core services like mapping are excellent improvements. The code demonstrates a strong focus on performance and modern C# practices. I've found a few issues, including a critical one in the new Mediator's service registration that would prevent some pipeline behaviors from working, and a couple of other suggestions for improvement.

Comment thread src/Mediator/ServiceRegistration.cs Outdated
Comment on lines +78 to +86
// Skip types that have constructor parameters which would prevent auto-registration
if (
implementation
.GetConstructors()
.All(ctor => ctor.GetParameters().Length > 0 && !ctor.GetParameters().All(p => p.HasDefaultValue))
)
{
continue;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The logic to skip behaviors with constructor parameters seems incorrect for dependency injection scenarios. The DI container is responsible for resolving constructor dependencies. This check will prevent any behavior that has dependencies (like ILogger or IHttpContextAccessor) from being registered, which is a common use case. This check should be removed to allow the DI container to correctly instantiate behaviors with their dependencies.

Comment on lines +27 to +43
public static IServiceCollection AddYamlResourceLocalization(this IServiceCollection services)
{
_ = services.AddScoped<ILocalizationService, ResourceLocalizationManager>(_ =>
{
Dictionary<string, Dictionary<string, string>> resources = GetLocalizationResources();
return new ResourceLocalizationManager(resources);
});

return services;
}

private static Dictionary<string, Dictionary<string, string>> GetLocalizationResources()
{
var resources = new Dictionary<string, Dictionary<string, string>>();
string? assemblyLocation = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
if (assemblyLocation is null)
return resources;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The AddYamlResourceLocalization extension method currently uses Assembly.GetExecutingAssembly() to find localization resources. This forces the resources to be in the output directory of the NArchitecture.Core.Localization.Resource.Yaml.DependencyInjection library, which is not ideal for consumers of this library who would expect to keep resources in their own application projects. A more flexible approach would be to allow the consumer to specify which assemblies to scan, similar to how AddMediator and AddNArchitectureAutoMapper are implemented. I recommend changing the method signature to accept assemblies to scan.

public static IServiceCollection AddYamlResourceLocalization(this IServiceCollection services, params Assembly[] assembliesToScan)
    {
        _ = services.AddScoped<ILocalizationService, ResourceLocalizationManager>(_ =>
        {
            Dictionary<string, Dictionary<string, string>> resources = GetLocalizationResources(assembliesToScan);
            return new ResourceLocalizationManager(resources);
        });

        return services;
    }

    private static Dictionary<string, Dictionary<string, string>> GetLocalizationResources(Assembly[] assembliesToScan)
    {

catch (Exception)
{
// Ensure transaction is disposed on exception
transactionScope.Dispose();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The explicit call to transactionScope.Dispose() within the catch block is redundant. The using statement on line 21 already ensures that Dispose() will be called on the transactionScope when the try block is exited, whether normally or due to an exception. If an exception occurs, transactionScope.Complete() is not called, and the transaction is automatically rolled back upon disposal by the using block. Removing this redundant call makes the code cleaner and relies on the standard using pattern for resource management.

…or registration

- Remove logic that prevented behaviors with constructor dependencies from being registered
- DI container is responsible for resolving constructor dependencies
- Allows behaviors with dependencies like ILogger or IHttpContextAccessor to be properly registered
- Fixes critical issue that would prevent pipeline behaviors from working correctly
…xecuting assembly

- Change AddYamlResourceLocalization to accept assemblies to scan parameter
- Remove dependency on Assembly.GetExecutingAssembly() which limited resources to library location
- Allow consumers to specify which assemblies contain localization resources
- Consistent with other extension methods like AddMediator and AddNArchitectureAutoMapper
- Improves flexibility for applications using the localization service
…ScopeBehavior

- Remove explicit transactionScope.Dispose() call in catch block
- Using statement already ensures proper disposal when leaving scope
- Transaction is automatically rolled back when Complete() is not called
- Cleaner code that relies on standard using pattern for resource management
@ahmet-cetinkaya
Copy link
Copy Markdown
Owner Author

/gemini review

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This is an impressive and substantial upgrade to the project's architecture. The move to a more modular structure with .NET 10, the introduction of a custom Mediator, and the focus on performance through modern C# features are all excellent changes. The new pipeline behaviors for caching, logging, and authorization are well-designed and robust. My review includes a few suggestions to improve cross-platform compatibility and enhance the flexibility of the new logging features. Overall, this is a very strong pull request that significantly modernizes the codebase.

Comment thread global.json Outdated
"benchmark:last": "dotnet run --project tests/benchmarks/Benchmarks/NArchitecture.Core.Benchmarks.csproj -- --last-run && dotnet restore",
"build": "dotnet build --configuration Release",
"test": "dotnet test --configuration Release"
"clean": "dotnet clean && find . -type d -name bin -exec rm -rf {} + && find . -type d -name obj -exec rm -rf {} + && dotnet restore && dotnet build --configuration Release",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The clean script uses Unix-specific commands (find, rm -rf), which will cause it to fail on Windows development environments. For better cross-platform compatibility, consider using a tool that works on all supported platforms. A cross-platform PowerShell equivalent would be:

"Get-ChildItem -Path . -Include bin,obj -Recurse -Directory | ForEach-Object { Remove-Item -Path $_.FullName -Recurse -Force }; dotnet restore; dotnet build --configuration Release"

This would require developers to have PowerShell Core installed, which is a reasonable expectation for .NET development.

Comment on lines +109 to +110
int startChars = param.Name == "SensitiveData" ? 4 : param.KeepStartChars;
int endChars = param.Name == "SensitiveData" ? 5 : param.KeepEndChars;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The MaskEmail method contains hardcoded logic that checks for a specific property name, "SensitiveData". This creates a tight coupling between the logging behavior and specific property names in your requests, making it less reusable and harder to maintain. It's better to rely on the configuration provided in the LogExcludeParameter (KeepStartChars, KeepEndChars) for all properties. If a default masking strategy for emails is desired, it should be applied when KeepStartChars and KeepEndChars are not specified, rather than being tied to a property name.

        int startChars = param.KeepStartChars;
        int endChars = param.KeepEndChars;

Comment on lines +125 to +126
const int keepStart = 2,
keepEnd = 2;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The MaskNumeric method uses hardcoded values for keepStart and keepEnd, ignoring any values that might be set in the LogExcludeParameter. This limits flexibility. The method should respect the KeepStartChars and KeepEndChars from the param argument, perhaps with a default fallback if they are not set to allow for configurable masking of numeric values.

        int keepStart = param.KeepStartChars;
        int keepEnd = param.KeepEndChars;
        if (keepStart == 0 && keepEnd == 0)
        {
            keepStart = 2;
            keepEnd = 2;
        }

Comment on lines +9 to +75
public abstract class SerilogLoggerServiceBase(PackageSerilog.ILogger logger) : ILogger
{
/// <summary>
/// The Serilog logger instance used for logging operations.
/// </summary>
protected readonly PackageSerilog.ILogger Logger = logger;

/// <summary>
/// Logs a critical message that represents a critical error that requires immediate attention.
/// </summary>
/// <param name="message">The message to be logged</param>
public Task CriticalAsync(string message)
{
Logger.Fatal(message);
return Task.CompletedTask;
}

/// <summary>
/// Logs a debug message that is used for development and troubleshooting.
/// </summary>
/// <param name="message">The message to be logged</param>
public Task DebugAsync(string message)
{
Logger.Debug(message);
return Task.CompletedTask;
}

/// <summary>
/// Logs an error message that represents an error that has occurred.
/// </summary>
/// <param name="message">The message to be logged</param>
public Task ErrorAsync(string message)
{
Logger.Error(message);
return Task.CompletedTask;
}

/// <summary>
/// Logs an informational message that provides general information.
/// </summary>
/// <param name="message">The message to be logged</param>
public Task InformationAsync(string message)
{
Logger.Information(message);
return Task.CompletedTask;
}

/// <summary>
/// Logs a trace message that is used for detailed tracing of the application's execution.
/// </summary>
/// <param name="message">The message to be logged</param>
public Task TraceAsync(string message)
{
Logger.Verbose(message);
return Task.CompletedTask;
}

/// <summary>
/// Logs a warning message that represents a potential issue or important situation.
/// </summary>
/// <param name="message">The message to be logged</param>
public Task WarningAsync(string message)
{
Logger.Warning(message);
return Task.CompletedTask;
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The methods in this base class are async but wrap synchronous Serilog calls (e.g., Logger.Fatal(message)), returning Task.CompletedTask. This doesn't provide true asynchrony and can block the calling thread if the underlying sink performs I/O. To achieve non-blocking logging, you should configure Serilog with Serilog.Sinks.Async. This would involve wrapping your sinks in the LoggerConfiguration, for example: .WriteTo.Async(a => a.File(...)). This ensures that log messages are passed to a background thread for processing, preventing your application threads from being blocked by logging operations.

… clean script

- Replace find/rm commands with PowerShell Get-ChildItem/Remove-Item for Windows compatibility
- Uses pwsh (PowerShell Core) which is available on all platforms
- Maintains same functionality while ensuring cross-platform compatibility
- Removes dependency on Unix-specific command line tools
…ility in logging behavior

- Remove hardcoded SensitiveData property name check in MaskEmail method
- Use KeepStartChars and KeepEndChars from LogExcludeParameter configuration
- Add fallback defaults for MaskNumeric when parameters are not specified
- Improves reusability and reduces tight coupling between behavior and property names
- Makes masking configuration more flexible and configurable
- Add remarks section explaining how to achieve true asynchronous logging
- Provide examples for configuring async sinks with .WriteTo.Async()
- Clarify that current implementation wraps synchronous calls with Task.CompletedTask
- Guides developers to use async sinks to prevent thread blocking in I/O operations
@ahmet-cetinkaya
Copy link
Copy Markdown
Owner Author

/gemini review

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This is an impressive and extensive architectural upgrade. The move to a modular structure, the introduction of abstractions for core services like mailing and mapping, and the implementation of a custom Mediator pattern are all excellent changes that will improve maintainability and flexibility. The code shows a strong focus on performance and modern C# features. My feedback focuses on a few areas to further enhance robustness and performance.

Comment on lines +41 to +71
private static Dictionary<string, Dictionary<string, string>> GetLocalizationResources(Assembly[] assembliesToScan)
{
var resources = new Dictionary<string, Dictionary<string, string>>();

foreach (var assembly in assembliesToScan)
{
string? assemblyLocation = Path.GetDirectoryName(assembly.Location);
if (assemblyLocation is null)
continue;

string featuresPath = Path.Combine(assemblyLocation, FeaturesFolder);
if (!Directory.Exists(featuresPath))
continue;

string[] featureDirectories = Directory.GetDirectories(featuresPath);

foreach (string featureDir in featureDirectories)
{
IEnumerable<(string culture, string filePath)> localeFiles = GetLocaleFiles(featureDir);
foreach ((string culture, string filePath) in localeFiles)
{
if (!resources.ContainsKey(culture))
resources[culture] = [];

resources[culture][Path.GetFileName(featureDir)] = filePath;
}
}
}

return resources;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation for discovering localization resources relies on scanning the file system based on assembly.Location. This approach can be fragile and may fail in certain deployment scenarios, such as single-file executables, containerized environments where file paths might differ, or when assemblies are loaded from memory. A more robust solution would be to use embedded resources. By embedding the YAML files into the assemblies and loading them via assembly.GetManifestResourceStream(), you would decouple the localization system from the physical file structure on disk, making it more reliable across different environments.

Comment on lines +46 to +48
await _logger.InformationAsync(
$"Performance -> {requestName} took {_stopwatch.Elapsed.TotalSeconds}s, exceeding the threshold of {request.IntervalOptions.Interval}s"
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The await on the logging call inside the finally block can introduce latency, which is undesirable in a performance monitoring behavior. The request pipeline will be blocked until the logging operation completes. To avoid this, the logging call should be fire-and-forget.

                _ = _logger.InformationAsync(
                    $"Performance -> {requestName} took {_stopwatch.Elapsed.TotalSeconds}s, exceeding the threshold of {request.IntervalOptions.Interval}s"
                );

Comment on lines +31 to +94
private async Task<TResponse> CreateRequestPipelineWithResponse<TResponse>(
IRequest<TResponse> request,
Type requestType,
Type responseType,
CancellationToken cancellationToken
)
{
// Create a scope to resolve handlers with scoped dependencies
using var scope = _serviceScopeFactory.CreateScope();
var serviceProvider = scope.ServiceProvider;

Type handlerType = typeof(IRequestHandler<,>).MakeGenericType(requestType, responseType);
object? handler = serviceProvider.GetService(handlerType);

if (handler == null)
{
throw new InvalidOperationException($"Handler not found for {requestType.Name}");
}

// Get the handle method
var handleMethod = handlerType.GetMethod("Handle")!;

// Create the core request handler delegate
RequestHandlerDelegate<TResponse> coreHandlerDelegate = async () =>
{
var result = await (Task<TResponse>)handleMethod.Invoke(handler, new object[] { request, cancellationToken })!;
return result;
};

// Get the closed behavior type for this specific request/response
var closedBehaviorType = typeof(IPipelineBehavior<,>).MakeGenericType(requestType, responseType);

// Get behaviors directly registered for this specific request/response type
var behaviors = serviceProvider.GetServices(closedBehaviorType).ToArray();

// If no behaviors, just execute the handler directly
if (behaviors.Length == 0)
return await coreHandlerDelegate();

// Build the pipeline by wrapping behaviors around the core handler
var behaviorHandleMethod = closedBehaviorType.GetMethod("Handle")!;

// Start with the core handler
RequestHandlerDelegate<TResponse> pipeline = coreHandlerDelegate;

// Wrap each behavior around the pipeline in reverse order of registration
// This ensures first registered behavior executes first
for (int i = behaviors.Length - 1; i >= 0; i--)
{
var behavior = behaviors[i];
var currentPipeline = pipeline; // Capture the current pipeline

// Create a new pipeline that wraps the current one with this behavior
pipeline = async () =>
{
var result = await (Task<TResponse>)
behaviorHandleMethod.Invoke(behavior, new object[] { request, currentPipeline, cancellationToken })!;
return result;
};
}

// Execute the pipeline and return the result
return await pipeline();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current mediator implementation uses reflection on every SendAsync call to find and invoke handlers and behaviors. This can introduce performance overhead, especially for frequently used requests. Caching the constructed handlers and pipeline delegates would significantly improve performance. A ConcurrentDictionary could be used to store delegates created for each request type, so that the reflection work is only done once per request type.

- Add OS detection to use pwsh on systems where available
- Fallback to Unix find/rm commands on systems without PowerShell
- Ensures clean script works on both Windows (with pwsh) and Unix systems
- Adds error handling with 2>/dev/null and || true for robust execution
…ipeline blocking

- Remove await from logger call in performance behavior
- Use discard operator (_) to make logging fire-and-forget
- Prevents performance monitoring from introducing additional latency
- Avoids blocking the request pipeline for logging operations
- Add ConcurrentDictionary caching for handler and behavior types and methods
- Cache handler types, behavior types, and their Handle methods
- Reduce reflection overhead by caching lookups for request, stream, and behavior types
- Improves performance for frequently used requests by avoiding repeated reflection calls
- Maintain thread safety with ConcurrentDictionary for concurrent access
- Add embedded resource discovery with fallback to file system resources
- Embedded resources provide deployment resilience for containers and single-file apps
- Maintain backward compatibility with existing file system approach
- Improve resource loading performance and deployment flexibility
- Update documentation to show embedded resource patterns and usage
…logging behavior

- Update authorization tests to reflect secure empty role handling
- Fix email masking to use configurable parameters instead of hardcoded length
- Update test expectations to match actual masking calculations
- Ensure all Application tests pass with new secure behavior
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant