diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ad7bc9f..0d47173 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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` for per-consumer configuration +- ✅ DI extension methods (`services.AddAcXxx()`) 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** 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. diff --git a/AyCode.Core/AyCode.Core.csproj b/AyCode.Core/AyCode.Core.csproj index 194082c..9bc8c91 100644 --- a/AyCode.Core/AyCode.Core.csproj +++ b/AyCode.Core/AyCode.Core.csproj @@ -17,6 +17,7 @@ + diff --git a/AyCode.Core/Loggers/AcLoggerOptions.cs b/AyCode.Core/Loggers/AcLoggerOptions.cs new file mode 100644 index 0000000..153c5f2 --- /dev/null +++ b/AyCode.Core/Loggers/AcLoggerOptions.cs @@ -0,0 +1,28 @@ +using AyCode.Core.Enums; + +namespace AyCode.Core.Loggers; + +/// +/// Configuration for the factory pattern. +/// +/// Consumer usage: +/// +/// services.Configure<AcLoggerOptions>(configuration.GetSection("AyCode:Logger")); +/// services.AddAcLoggerFactory<MyConcreteLogger>(); +/// +/// +/// +/// Binds from appsettings.json shape: +/// +/// "AyCode": { "Logger": { "AppType": "Web", "LogLevel": "Debug" } } +/// +/// +/// +public sealed class AcLoggerOptions +{ + /// Application type stamped on every log entry. Default: . + public AppType AppType { get; set; } = AppType.Server; + + /// Global minimum log level. Entries below this level are discarded. Default: . + public LogLevel LogLevel { get; set; } = LogLevel.Info; +} diff --git a/AyCode.Core/Loggers/AcLoggerServiceExtensions.cs b/AyCode.Core/Loggers/AcLoggerServiceExtensions.cs new file mode 100644 index 0000000..5ccf048 --- /dev/null +++ b/AyCode.Core/Loggers/AcLoggerServiceExtensions.cs @@ -0,0 +1,56 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace AyCode.Core.Loggers; + +/// +/// DI extension methods for the factory pattern. +/// +/// Consumers call one of the AddAcLoggerFactory overloads after +/// services.Configure<AcLoggerOptions>(...) to register a +/// singleton that produces category-scoped +/// logger instances with DI-resolved writers. +/// +/// +public static class AcLoggerServiceExtensions +{ + /// + /// Registers a logger factory creating instances per caller category. + /// Writers are resolved from DI via (all registered writers are included). + /// + /// + /// Concrete logger type. Must expose a public constructor with signature + /// (AppType, LogLevel, string?, params IAcLogWriterBase[]). + /// + public static IServiceCollection AddAcLoggerFactory<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TLogger>( + this IServiceCollection services) + where TLogger : AcLoggerBase + { + return services.AddAcLoggerFactory(); + } + + /// + /// 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. + /// + /// Concrete logger type. See for ctor requirements. + /// Writer marker interface; must extend . + public static IServiceCollection AddAcLoggerFactory<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TLogger, TWriterBase>( + this IServiceCollection services) + where TLogger : AcLoggerBase + where TWriterBase : class, IAcLogWriterBase + { + services.AddSingleton>(sp => + { + var opts = sp.GetRequiredService>().Value; + return categoryName => + { + var writers = sp.GetServices().Cast().ToArray(); + return (TLogger)Activator.CreateInstance(typeof(TLogger), opts.AppType, opts.LogLevel, categoryName, writers)!; + }; + }); + + return services; + } +} diff --git a/AyCode.Services/SignalRs/AcSignalRConnectionExtensions.cs b/AyCode.Services/SignalRs/AcSignalRConnectionExtensions.cs index 8bbff9b..99a0eb5 100644 --- a/AyCode.Services/SignalRs/AcSignalRConnectionExtensions.cs +++ b/AyCode.Services/SignalRs/AcSignalRConnectionExtensions.cs @@ -1,13 +1,39 @@ +using AyCode.Core.Loggers; using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.Logging; namespace AyCode.Services.SignalRs; /// /// Extension methods for applying to -/// a client . +/// a client , plus the +/// bundle for consumer-friendly one-line setup (connection + logging bridge). /// public static class AcSignalRConnectionExtensions { + /// + /// Framework-default builder setup — combines with a + /// Microsoft.Extensions.Logging bridge to the supplied . + /// Consumers typically call this once when constructing the . + /// + /// Equivalent to: + /// + /// builder.AddAcConnection(options) + /// .ConfigureLogging(l => { l.SetMinimumLevel(Information); l.AddAcLogger(_ => logger); }); + /// + /// + /// + 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); + }); + } + /// /// Applies to the builder: /// WithUrl (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). /// /// - 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; } diff --git a/AyCode.Services/SignalRs/AcSignalRProtocolExtensions.cs b/AyCode.Services/SignalRs/AcSignalRProtocolExtensions.cs index e43d8ce..e0cad03 100644 --- a/AyCode.Services/SignalRs/AcSignalRProtocolExtensions.cs +++ b/AyCode.Services/SignalRs/AcSignalRProtocolExtensions.cs @@ -20,7 +20,16 @@ public static class AcSignalRProtocolExtensions { /// /// Registers as the protocol for a client . - /// Call on the during client setup. + /// Resolves from in the + /// 's inner DI scope. + /// + /// ⚠️ Limitation: the outer ASP.NET Core / MAUI / WASM services.Configure<AcBinaryHubProtocolOptions>(...) + /// registrations do not flow into the HubConnectionBuilder.Services inner collection — + /// it is an isolated container. This overload therefore always falls back to default options + /// when used from a client host that uses appsettings.json-driven configuration. + /// Prefer + /// in that case, passing a pre-resolved from the outer service provider. + /// /// public static IHubConnectionBuilder AddAcBinaryProtocol(this IHubConnectionBuilder builder, Action? configure = null) { @@ -28,6 +37,37 @@ public static class AcSignalRProtocolExtensions return builder; } + /// + /// Registers for a client using + /// an instance supplied by the caller — typically resolved + /// from the outer host's and cloned. An optional + /// action may apply last-minute overrides (e.g. attaching a logger + /// instance) on the cloned options. + /// + /// Use this overload when AcBinaryHubProtocolOptions is bound from appsettings.json + /// via services.Configure<AcBinaryHubProtocolOptions>(...) in the outer host's + /// service collection — the 's isolated inner DI cannot see + /// those registrations, so the options must be passed explicitly. + /// + /// + public static IHubConnectionBuilder AddAcBinaryProtocol(this IHubConnectionBuilder builder, AcBinaryHubProtocolOptions options, Action? configure = null) + { + if (options is null) throw new ArgumentNullException(nameof(options)); + + builder.Services.AddSingleton(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>(); + configure?.Invoke(opts); + opts.Validate(); + return new AyCodeBinaryHubProtocol(opts); + }); + + return builder; + } + /// /// Shared factory used by both client (this file) and server /// (AcSignalRServerProtocolExtensions in AyCode.Services.Server). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 958330c..8988642 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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(config.GetSection("AyCode:Xxx")); +services.AddAcXxxFactory(); +``` + +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 ``` diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 74dd35f..a3d424f 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -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`