Framework-first doctrine, DI logger factory, config refactor

Introduced framework-first design rules and updated documentation to clarify framework vs. consumer boundaries. Added AcLoggerOptions and DI-based logger factory extensions to AyCode.Core, enabling per-category logger instantiation from appsettings.json. Standardized SignalR connection setup with AddAcDefaults, replacing project-specific code. Enhanced protocol configuration for DI scope isolation. Refactored appsettings.json structure and added MSBuild targets for config propagation. Removed obsolete code and updated comments to match new patterns.
This commit is contained in:
Loretta 2026-04-23 16:11:22 +02:00
parent 8b8abb7cbc
commit 06a9efd7f9
8 changed files with 247 additions and 15 deletions

View File

@ -52,6 +52,26 @@ You are operating in a multi-repo, documentation-first architecture. You MUST ST
> For detailed docs see: `README.md``docs/`
> External repos in `own-dep-repos` are fully accessible — read their source code, docs, and `.github/copilot-instructions.md` freely when you need type definitions, base classes, or context. Do not limit yourself to the current workspace.
## Framework-First Design Principle
🛑 **This repo is framework (Layer 0 — Core framework), not a consumer application.** Consumers (plural, unknown) reference it.
**Before writing code, ask:**
1. Generic (reusable across any consumer)? → belongs HERE
2. Consumer-specific (business logic, URLs, product names)? → NOT HERE
3. Same pattern in 2+ consumers? → promote to framework (HERE)
**Hard rules:**
- ❌ No consumer names or product-specific terms in code, identifiers, namespaces, or docs
- ❌ No framework → consumer references
- ✅ Abstract/virtual base classes — consumers derive/override
- ✅ `IOptions<T>` for per-consumer configuration
- ✅ DI extension methods (`services.AddAcXxx<T>()`) that bundle setup
Framework design = **"write the base first, derive the specific later"**. Plan framework placement first, then consumer-specific code.
Full doctrine: `docs/ARCHITECTURE.md#framework-vs-consumer-boundary`
## Key Abstractions
1. **IId<T>** is the foundation interface — almost every entity implements it. Always preserve ID integrity.
2. **Generic entity hierarchy**: Interfaces (`AyCode.Interfaces`) → Entities (`AyCode.Entities`) → Models (`AyCode.Models`). Entities are abstract generics; concrete types live in consuming projects.

View File

@ -17,6 +17,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.11" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>

View File

@ -0,0 +1,28 @@
using AyCode.Core.Enums;
namespace AyCode.Core.Loggers;
/// <summary>
/// Configuration for the <see cref="AcLoggerBase"/> factory pattern.
/// <para>
/// Consumer usage:
/// <code>
/// services.Configure&lt;AcLoggerOptions&gt;(configuration.GetSection("AyCode:Logger"));
/// services.AddAcLoggerFactory&lt;MyConcreteLogger&gt;();
/// </code>
/// </para>
/// <para>
/// Binds from <c>appsettings.json</c> shape:
/// <code>
/// "AyCode": { "Logger": { "AppType": "Web", "LogLevel": "Debug" } }
/// </code>
/// </para>
/// </summary>
public sealed class AcLoggerOptions
{
/// <summary>Application type stamped on every log entry. Default: <see cref="AppType.Server"/>.</summary>
public AppType AppType { get; set; } = AppType.Server;
/// <summary>Global minimum log level. Entries below this level are discarded. Default: <see cref="LogLevel.Info"/>.</summary>
public LogLevel LogLevel { get; set; } = LogLevel.Info;
}

View File

@ -0,0 +1,56 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace AyCode.Core.Loggers;
/// <summary>
/// DI extension methods for the <see cref="AcLoggerBase"/> factory pattern.
/// <para>
/// Consumers call one of the <c>AddAcLoggerFactory</c> overloads after
/// <c>services.Configure&lt;AcLoggerOptions&gt;(...)</c> to register a
/// <see cref="Func{String, AcLoggerBase}"/> singleton that produces category-scoped
/// logger instances with DI-resolved writers.
/// </para>
/// </summary>
public static class AcLoggerServiceExtensions
{
/// <summary>
/// Registers a logger factory creating <typeparamref name="TLogger"/> instances per caller category.
/// Writers are resolved from DI via <see cref="IAcLogWriterBase"/> (all registered writers are included).
/// </summary>
/// <typeparam name="TLogger">
/// Concrete logger type. Must expose a public constructor with signature
/// <c>(AppType, LogLevel, string?, params IAcLogWriterBase[])</c>.
/// </typeparam>
public static IServiceCollection AddAcLoggerFactory<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TLogger>(
this IServiceCollection services)
where TLogger : AcLoggerBase
{
return services.AddAcLoggerFactory<TLogger, IAcLogWriterBase>();
}
/// <summary>
/// Registers a logger factory with a custom writer marker interface — e.g. a client-only marker
/// to keep server-only and client-only writers separated at the DI level.
/// </summary>
/// <typeparam name="TLogger">Concrete logger type. See <see cref="AddAcLoggerFactory{TLogger}"/> for ctor requirements.</typeparam>
/// <typeparam name="TWriterBase">Writer marker interface; must extend <see cref="IAcLogWriterBase"/>.</typeparam>
public static IServiceCollection AddAcLoggerFactory<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TLogger, TWriterBase>(
this IServiceCollection services)
where TLogger : AcLoggerBase
where TWriterBase : class, IAcLogWriterBase
{
services.AddSingleton<Func<string, TLogger>>(sp =>
{
var opts = sp.GetRequiredService<IOptions<AcLoggerOptions>>().Value;
return categoryName =>
{
var writers = sp.GetServices<TWriterBase>().Cast<IAcLogWriterBase>().ToArray();
return (TLogger)Activator.CreateInstance(typeof(TLogger), opts.AppType, opts.LogLevel, categoryName, writers)!;
};
});
return services;
}
}

View File

@ -1,13 +1,39 @@
using AyCode.Core.Loggers;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Logging;
namespace AyCode.Services.SignalRs;
/// <summary>
/// Extension methods for applying <see cref="AcHubConnectionOptions"/> to
/// a client <see cref="IHubConnectionBuilder"/>.
/// a client <see cref="IHubConnectionBuilder"/>, plus the <see cref="AddAcDefaults"/>
/// bundle for consumer-friendly one-line setup (connection + logging bridge).
/// </summary>
public static class AcSignalRConnectionExtensions
{
/// <summary>
/// Framework-default builder setup — combines <see cref="AddAcConnection"/> with a
/// Microsoft.Extensions.Logging bridge to the supplied <see cref="AcLoggerBase"/>.
/// Consumers typically call this once when constructing the <see cref="IHubConnectionBuilder"/>.
/// <para>
/// Equivalent to:
/// <code>
/// builder.AddAcConnection(options)
/// .ConfigureLogging(l =&gt; { l.SetMinimumLevel(Information); l.AddAcLogger(_ =&gt; logger); });
/// </code>
/// </para>
/// </summary>
public static IHubConnectionBuilder AddAcDefaults(this IHubConnectionBuilder builder, AcLoggerBase logger, AcHubConnectionOptions connectionOptions)
{
return builder
.AddAcConnection(connectionOptions)
.ConfigureLogging(logging =>
{
logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Information);
logging.AddAcLogger(_ => logger);
});
}
/// <summary>
/// Applies <see cref="AcHubConnectionOptions"/> to the builder:
/// <c>WithUrl</c> (with HttpConnectionOptions), keep-alive, server timeout,
@ -18,13 +44,11 @@ public static class AcSignalRConnectionExtensions
/// follows the fluent chain (last write wins for a given setting).
/// </para>
/// </summary>
public static IHubConnectionBuilder AddAcConnection(
this IHubConnectionBuilder builder,
AcHubConnectionOptions options)
public static IHubConnectionBuilder AddAcConnection(this IHubConnectionBuilder builder, AcHubConnectionOptions options)
{
if (options is null) throw new ArgumentNullException(nameof(options));
if (string.IsNullOrWhiteSpace(options.Url))
throw new ArgumentException("AcHubConnectionOptions.Url must be set.", nameof(options));
if (string.IsNullOrWhiteSpace(options.Url)) throw new ArgumentException("AcHubConnectionOptions.Url must be set.", nameof(options));
builder.WithUrl(options.Url, http =>
{
@ -35,14 +59,10 @@ public static class AcSignalRConnectionExtensions
if (options.SkipNegotiation.HasValue) http.SkipNegotiation = options.SkipNegotiation.Value;
});
if (options.KeepAliveInterval.HasValue)
builder.WithKeepAliveInterval(options.KeepAliveInterval.Value);
if (options.ServerTimeout.HasValue)
builder.WithServerTimeout(options.ServerTimeout.Value);
if (options.UseAutomaticReconnect)
builder.WithAutomaticReconnect();
if (options.UseStatefulReconnect)
builder.WithStatefulReconnect();
if (options.KeepAliveInterval.HasValue) builder.WithKeepAliveInterval(options.KeepAliveInterval.Value);
if (options.ServerTimeout.HasValue) builder.WithServerTimeout(options.ServerTimeout.Value);
if (options.UseAutomaticReconnect) builder.WithAutomaticReconnect();
if (options.UseStatefulReconnect) builder.WithStatefulReconnect();
return builder;
}

View File

@ -20,7 +20,16 @@ public static class AcSignalRProtocolExtensions
{
/// <summary>
/// Registers <see cref="AyCodeBinaryHubProtocol"/> as the protocol for a client <see cref="HubConnection"/>.
/// Call on the <see cref="IHubConnectionBuilder"/> during client setup.
/// Resolves <see cref="AcBinaryHubProtocolOptions"/> from <see cref="IOptions{TOptions}"/> in the
/// <see cref="HubConnectionBuilder"/>'s inner DI scope.
/// <para>
/// ⚠️ <b>Limitation:</b> the outer ASP.NET Core / MAUI / WASM <c>services.Configure&lt;AcBinaryHubProtocolOptions&gt;(...)</c>
/// registrations do <i>not</i> flow into the <c>HubConnectionBuilder.Services</c> inner collection —
/// it is an isolated container. This overload therefore always falls back to default options
/// when used from a client host that uses <c>appsettings.json</c>-driven configuration.
/// Prefer <see cref="AddAcBinaryProtocol(IHubConnectionBuilder, AcBinaryHubProtocolOptions, Action{AcBinaryHubProtocolOptions})"/>
/// in that case, passing a pre-resolved <see cref="AcBinaryHubProtocolOptions"/> from the outer service provider.
/// </para>
/// </summary>
public static IHubConnectionBuilder AddAcBinaryProtocol(this IHubConnectionBuilder builder, Action<AcBinaryHubProtocolOptions>? configure = null)
{
@ -28,6 +37,37 @@ public static class AcSignalRProtocolExtensions
return builder;
}
/// <summary>
/// Registers <see cref="AyCodeBinaryHubProtocol"/> for a client <see cref="HubConnection"/> using
/// an <see cref="AcBinaryHubProtocolOptions"/> instance supplied by the caller — typically resolved
/// from the outer host's <see cref="IOptions{TOptions}"/> and cloned. An optional
/// <paramref name="configure"/> action may apply last-minute overrides (e.g. attaching a logger
/// instance) on the cloned options.
/// <para>
/// Use this overload when <c>AcBinaryHubProtocolOptions</c> is bound from <c>appsettings.json</c>
/// via <c>services.Configure&lt;AcBinaryHubProtocolOptions&gt;(...)</c> in the outer host's
/// service collection — the <see cref="HubConnectionBuilder"/>'s isolated inner DI cannot see
/// those registrations, so the options must be passed explicitly.
/// </para>
/// </summary>
public static IHubConnectionBuilder AddAcBinaryProtocol(this IHubConnectionBuilder builder, AcBinaryHubProtocolOptions options, Action<AcBinaryHubProtocolOptions>? configure = null)
{
if (options is null) throw new ArgumentNullException(nameof(options));
builder.Services.AddSingleton<IHubProtocol>(sp =>
{
// Clone so callers can safely reuse their options instance across multiple connections
// without the factory's Logger fallback or configure overrides leaking back.
var opts = options.Clone();
opts.Logger ??= sp.GetService<ILogger<AcBinaryHubProtocol>>();
configure?.Invoke(opts);
opts.Validate();
return new AyCodeBinaryHubProtocol(opts);
});
return builder;
}
/// <summary>
/// Shared factory used by both client (this file) and server
/// (<c>AcSignalRServerProtocolExtensions</c> in AyCode.Services.Server).

View File

@ -1,5 +1,54 @@
# Architecture
## Framework vs. Consumer Boundary
This solution is **Layer 0 — Core framework**. Consumers (plural, unknown) reference it.
### Layer hierarchy
```
Layer 0 — Core framework this solution (AyCode.Core)
Layer 1 — UI framework e.g. AyCode.Blazor — Blazor/MAUI bases
Layer 2 — Domain framework e.g. Mango.Nop.Core — NopCommerce-plugin bases
Layer 3 — Consumer application the actual business app
```
**Dependencies flow downward only.** A consumer CAN reference this framework; this framework CAN NEVER reference a consumer.
### What belongs here vs. in a consumer
**Yes, framework:**
- Abstract base classes with hooks for consumer override
- Interfaces and contracts
- Options classes for consumer configuration
- Generic logic parameterized by consumer types
**No, consumer only:**
- Business logic
- Consumer-named types or namespaces
- Hardcoded URLs, tenants, or product IDs
### Minimum-boilerplate ideal
Well-designed framework → minimal consumer setup. Aim for:
```csharp
// Consumer Program.cs — ideal pattern
services.Configure<AcXxxOptions>(config.GetSection("AyCode:Xxx"));
services.AddAcXxxFactory<MyConcreteType>();
```
Verbose consumer code = framework incomplete. Promote recurring patterns via extension methods, default-providing base classes, or options classes.
### Promotion pattern
When a pattern appears in 2+ consumer projects:
1. Identify generic vs. consumer-specific parts
2. Move generic part → appropriate framework layer (abstract base, options, or extension)
3. Leave specific part in consumer (override or configure)
Framework design follows **"write the base first, derive the specific later"** — when planning a new feature, first consider whether the generic part fits the framework, only then implement consumer-specific derived code.
## Dependency Graph
```

View File

@ -46,3 +46,21 @@ This is **planned for replacement** with direct Binary parameter serialization (
- **Abstract test bases** with `AcBase_` prefixed methods for reusable test logic.
- **TestDataFactory** for centralized test data creation with ID sequencing.
- **Testable infrastructure** for SignalR: `TestableSignalRClient2`, `TestableSignalRHub2` bypass real connections.
## Framework-First Placement
Every new type/feature requires asking: *generic or consumer-specific?*
| Trait | Verdict |
|-------|---------|
| Contains a consumer's product name, tenant, or URL | **REJECT** from framework |
| Same pattern appears in 2+ consumers | **PROMOTE** to framework |
| Abstract/virtual hooks for consumer customization | **ACCEPT** in framework |
| Requires a specific concrete type from a consumer | **REDESIGN** — use generic/options/extension pattern |
Before committing any type to this framework:
- No consumer-name string in any identifier, namespace, docstring, or doc
- No hardcoded consumer-specific values
- Extension methods / base classes / options classes cover the N-consumer use case
Full doctrine: `ARCHITECTURE.md#framework-vs-consumer-boundary`