Template Design
Overview
Section titled “Overview”In the previous chapter, we learned the basic pattern of assembling code with StringBuilder. However, the code generated by ObservablePortGenerator consists of multiple components: headers, using statements, fields, constructors, wrapper methods, logging methods, and more. If all of these are placed in a single method, hundreds of lines of generation logic become entangled. By clearly separating fixed parts (headers, using statements) from dynamic parts (class names, method signatures) and extracting each component into independent methods, the maintainability of the generation code itself improves significantly.
Learning Objectives
Section titled “Learning Objectives”Core Learning Objectives
Section titled “Core Learning Objectives”- Understand the separation principle of fixed and dynamic parts
- Three categories: constants, patterns, and fully dynamic
- Grasp the hierarchical generation method structure
- The call tree: GenerateObservableClassSource -> GenerateFields -> GenerateMethod
- Learn the branching strategy for logging method templates based on parameter count
Template Design Principles
Section titled “Template Design Principles”1. Separating Fixed and Dynamic Parts
Section titled “1. Separating Fixed and Dynamic Parts”Generated Code Structure========================
[Fixed] Header (auto-generated comment)[Fixed] Using statements[Dynamic] Namespace[Dynamic] Class declaration[Fixed] Field pattern (only type is dynamic)[Fixed] Constructor pattern (only parameters are dynamic)[Dynamic] Methods (only signatures and calls are dynamic)[Fixed] Logging method pattern2. Hierarchical Generation
Section titled “2. Hierarchical Generation”GenerateObservableClassSource() ├── Header (constant) ├── Using statements (constant) ├── Namespace (dynamic) ├── Class declaration (dynamic) │ ├── GenerateFields() │ ├── GenerateConstructor() │ ├── GenerateHelperMethods() │ └── GenerateMethod() (each method) │ ├── Signature generation │ ├── Logging call generation │ └── Actual call generation └── {ClassName}ObservableLoggers class └── GenerateLoggingMethods() (each method)Header Template
Section titled “Header Template”Constants.cs
Section titled “Constants.cs”namespace Functorium.SourceGenerators.Abstractions;
public static class Constants{ /// <summary> /// Common header for generated code /// </summary> public const string Header = """ // <auto-generated/> // This code was generated by ObservablePortGenerator. // Do not modify this file directly. // Any changes will be overwritten when the code is regenerated.
#nullable enable """;}Role of the Header
Section titled “Role of the Header”Header Components=================
1. // <auto-generated/> - IDE recognizes this as generated code - Disables some analyzer warnings
2. Generator information - Indicates which tool generated the code - Easy to trace when issues occur
3. Do-not-modify warning - Guides developers not to modify directly
4. #nullable enable - Enables nullable reference types - Ensures consistency in generated codeClass Template
Section titled “Class Template”Basic Structure
Section titled “Basic Structure”private static string GenerateObservableClassSource( ObservableClassInfo classInfo, StringBuilder sb){ // 1. Header sb.Append(Header) .AppendLine();
// 2. Using statements sb.AppendLine("using System.Diagnostics;") .AppendLine("using System.Diagnostics.Metrics;") .AppendLine("using Functorium.Adapters.Observabilities;") .AppendLine("using Functorium.Adapters.Observabilities.Naming;") .AppendLine("using Functorium.Abstractions.Observabilities;") .AppendLine() .AppendLine("using LanguageExt;") .AppendLine("using Microsoft.Extensions.Logging;") .AppendLine("using Microsoft.Extensions.Options;") .AppendLine();
// 3. Namespace (dynamic) sb.AppendLine($"namespace {classInfo.Namespace};") .AppendLine();
// 4. Class declaration (dynamic) sb.AppendLine($"public class {classInfo.ClassName}Observable : {classInfo.ClassName}") .AppendLine("{");
// 5. Fields GenerateFields(sb, classInfo);
// 6. Constructor GenerateConstructor(sb, classInfo);
// 7. Helper methods GenerateHelperMethods(sb, classInfo);
// 8. Each method foreach (var method in classInfo.Methods) { GenerateMethod(sb, classInfo, method); }
// 9. Close class sb.AppendLine("}");
// 10. Logging extension class sb.AppendLine($"internal static class {classInfo.ClassName}ObservableLoggers") .AppendLine("{"); foreach (var method in classInfo.Methods) { GenerateLoggingMethods(sb, classInfo, method); } sb.AppendLine("}");
return sb.ToString();}Field Template
Section titled “Field Template”private static void GenerateFields(StringBuilder sb, ObservableClassInfo classInfo){ // Observability fields sb.AppendLine(" private readonly ActivitySource _activitySource;") .AppendLine($" private readonly ILogger<{classInfo.ClassName}Observable> _logger;") .AppendLine() .AppendLine(" // Metrics") .AppendLine(" private readonly Counter<long> _requestCounter;") .AppendLine(" private readonly Counter<long> _responseCounter;") .AppendLine(" private readonly Histogram<double> _durationHistogram;") .AppendLine();
// Constants sb.AppendLine($" private const string RequestHandler = nameof({classInfo.ClassName});") .AppendLine() .AppendLine(" private readonly string _requestCategoryLowerCase;") .AppendLine();
// Logging level cache (performance optimization) sb.AppendLine(" private readonly bool _isDebugEnabled;") .AppendLine(" private readonly bool _isInformationEnabled;") .AppendLine(" private readonly bool _isWarningEnabled;") .AppendLine(" private readonly bool _isErrorEnabled;") .AppendLine();}Constructor Template
Section titled “Constructor Template”private static void GenerateConstructor(StringBuilder sb, ObservableClassInfo classInfo){ // Constructor start sb.Append($" public {classInfo.ClassName}Observable(") .AppendLine() .AppendLine(" ActivitySource activitySource,") .AppendLine($" ILogger<{classInfo.ClassName}Observable> logger,") .AppendLine(" IMeterFactory meterFactory,") .Append(" IOptions<OpenTelemetryOptions> openTelemetryOptions");
// Parent class parameters (dynamic) string baseParams = GenerateBaseConstructorParameters( classInfo.BaseConstructorParameters); if (!string.IsNullOrEmpty(baseParams)) { sb.Append(baseParams); }
sb.Append(")");
// Parent constructor call (dynamic) string baseCall = GenerateBaseConstructorCall( classInfo.BaseConstructorParameters); if (!string.IsNullOrEmpty(baseCall)) { sb.AppendLine() .Append(baseCall); }
// Constructor body sb.AppendLine() .AppendLine(" {") .AppendLine(" global::System.ArgumentNullException.ThrowIfNull(activitySource);") .AppendLine(" global::System.ArgumentNullException.ThrowIfNull(meterFactory);") .AppendLine(" global::System.ArgumentNullException.ThrowIfNull(openTelemetryOptions);") .AppendLine() .AppendLine(" _activitySource = activitySource;") .AppendLine(" _logger = logger;") .AppendLine() .AppendLine(" // RequestCategory caching") .AppendLine(" _requestCategoryLowerCase = this.RequestCategory?.ToLowerInvariant()") .AppendLine(" ?? ObservabilityNaming.Categories.Unknown;") .AppendLine() .AppendLine(" // Meter and Metrics initialization") .AppendLine(" string serviceNamespace = openTelemetryOptions.Value.ServiceNamespace;") .AppendLine(" string meterName = $\"{serviceNamespace}.adapter.{_requestCategoryLowerCase}\";") .AppendLine(" var meter = meterFactory.Create(meterName);") .AppendLine(" _requestCounter = meter.CreateCounter<long>(...);") .AppendLine(" _responseCounter = meter.CreateCounter<long>(...);") .AppendLine(" _durationHistogram = meter.CreateHistogram<double>(...);") .AppendLine() .AppendLine(" _isDebugEnabled = logger.IsEnabled(LogLevel.Debug);") .AppendLine(" _isInformationEnabled = logger.IsEnabled(LogLevel.Information);") .AppendLine(" _isWarningEnabled = logger.IsEnabled(LogLevel.Warning);") .AppendLine(" _isErrorEnabled = logger.IsEnabled(LogLevel.Error);") .AppendLine(" }") .AppendLine();}Method Template
Section titled “Method Template”Structure
Section titled “Structure”private static void GenerateMethod( StringBuilder sb, ObservableClassInfo classInfo, MethodInfo method){ // 1. Method signature string actualReturnType = ExtractActualReturnType(method.ReturnType);
sb.AppendLine($" public override {method.ReturnType} {method.Name}("); for (int i = 0; i < method.Parameters.Count; i++) { var param = method.Parameters[i]; var comma = i < method.Parameters.Count - 1 ? "," : ""; sb.AppendLine($" {param.Type} {param.Name}{comma}"); }
// 2. FinT.lift + ExecuteWithSpan pattern string arguments = string.Join(", ", method.Parameters.Select(p => p.Name)); sb.AppendLine(" ) =>") .AppendLine($" global::LanguageExt.FinT.lift<global::LanguageExt.IO, {actualReturnType}>(") .AppendLine(" (from result in ExecuteWithSpan(") .AppendLine($" requestHandler: RequestHandler,") .AppendLine($" requestHandlerMethod: nameof({method.Name}),") .AppendLine($" operation: FinTToIO(base.{method.Name}({arguments})),") .AppendLine($" requestLog: () => AdapterRequestLog_..._{method.Name}(...),") .AppendLine($" responseLogSuccess: AdapterResponseSuccessLog_..._{method.Name},") .AppendLine($" responseLogFailure: AdapterResponseFailureLog_..._{method.Name},") .AppendLine(" startTimestamp: ElapsedTimeCalculator.GetCurrentTimestamp())") .AppendLine($" select result).Map(r => global::LanguageExt.Fin.Succ(r)));") .AppendLine();}Generated Result Example
Section titled “Generated Result Example”// Generated methodpublic override FinT<IO, User> GetUserAsync( int userId) => global::LanguageExt.FinT.lift<global::LanguageExt.IO, User>( (from result in ExecuteWithSpan( requestHandler: RequestHandler, requestHandlerMethod: nameof(GetUserAsync), operation: FinTToIO(base.GetUserAsync(userId)), requestLog: () => AdapterRequestLog_UserRepository_GetUserAsync(RequestHandler, nameof(GetUserAsync), userId), responseLogSuccess: AdapterResponseSuccessLog_UserRepository_GetUserAsync, responseLogFailure: AdapterResponseFailureLog_UserRepository_GetUserAsync, startTimestamp: ElapsedTimeCalculator.GetCurrentTimestamp()) select result).Map(r => global::LanguageExt.Fin.Succ(r)));Logging Method Template
Section titled “Logging Method Template”LoggerMessage.Define Pattern
Section titled “LoggerMessage.Define Pattern”private static void GenerateLoggingMethods( StringBuilder sb, ObservableClassInfo classInfo, MethodInfo method){ int paramCount = method.Parameters.Count;
// Base 4 parameters + method parameters // LoggerMessage.Define supports a maximum of 6 parameters
if (paramCount <= 2) { // High-performance logging (using LoggerMessage.Define) GenerateHighPerformanceLogging(sb, classInfo, method); } else { // Fallback logging (using regular logging) GenerateFallbackLogging(sb, classInfo, method); }}Logging Level Strategy
Section titled “Logging Level Strategy”The generator produces Debug/Information multi-level logging:
| Log Level | Content | Purpose |
|---|---|---|
| Debug | Includes parameter values, result values | Detailed debugging |
| Information | Handler/method name only | Operational monitoring |
| Warning | Expected errors | Business error tracking |
| Error | System errors (Exceptional) | Failure detection |
Generated Result Example
Section titled “Generated Result Example”internal static class UserRepositoryObservableLoggers{ // ===== LoggerMessage.Define delegates for GetUserAsync =====
// Request logging (Information - handler name only) private static readonly Action<ILogger, string, string, string, string, Exception?> _logAdapterRequest_UserRepository_GetUserAsync = LoggerMessage.Define<string, string, string, string>( LogLevel.Information, ObservabilityNaming.EventIds.Adapter.AdapterRequest, "{request.layer} {request.category} {request.handler}.{request.handler.method} requesting");
// Request logging (Debug - with parameters, high-performance) private static readonly Action<ILogger, string, string, string, string, int, Exception?> _logAdapterRequestDebug_UserRepository_GetUserAsync = LoggerMessage.Define<string, string, string, string, int>( LogLevel.Debug, ObservabilityNaming.EventIds.Adapter.AdapterRequest, "{request.layer} {request.category} {request.handler}.{request.handler.method} requesting with {request.params.userid}");
// Response logging (Information - status only) private static readonly Action<ILogger, string, string, string, string, string, double, Exception?> _logAdapterResponseSuccess_UserRepository_GetUserAsync = LoggerMessage.Define<string, string, string, string, string, double>( LogLevel.Information, ObservabilityNaming.EventIds.Adapter.AdapterResponseSuccess, "{request.layer} {request.category} {request.handler}.{request.handler.method} responded {response.status} in {response.elapsed:0.0000} s");
// Response logging (Debug - with result, high-performance) private static readonly Action<ILogger, string, string, string, string, string, double, User, Exception?> _logAdapterResponseSuccessDebug_UserRepository_GetUserAsync = LoggerMessage.Define<string, string, string, string, string, double, User>( LogLevel.Debug, ObservabilityNaming.EventIds.Adapter.AdapterResponseSuccess, "{request.layer} {request.category} {request.handler}.{request.handler.method} responded {response.status} in {response.elapsed:0.0000} s with {response.result}");
// Extension method: LogAdapterRequestDebug_UserRepository_GetUserAsync public static void LogAdapterRequestDebug_UserRepository_GetUserAsync( this ILogger logger, string requestLayer, string requestCategory, string requestHandler, string requestHandlerMethod, int userId) { ... }
// Extension method: LogAdapterRequest_UserRepository_GetUserAsync public static void LogAdapterRequest_UserRepository_GetUserAsync( this ILogger logger, string requestLayer, string requestCategory, string requestHandler, string requestHandlerMethod) { ... }
// Extension method: LogAdapterResponseSuccessDebug_UserRepository_GetUserAsync public static void LogAdapterResponseSuccessDebug_UserRepository_GetUserAsync( this ILogger logger, string requestLayer, string requestCategory, string requestHandler, string requestHandlerMethod, string status, User result, double elapsed) { ... }
// Extension method: LogAdapterResponseSuccess_UserRepository_GetUserAsync public static void LogAdapterResponseSuccess_UserRepository_GetUserAsync( this ILogger logger, string requestLayer, string requestCategory, string requestHandler, string requestHandlerMethod, string status, double elapsed) { ... }
// Extension method: LogAdapterResponseWarning_UserRepository_GetUserAsync // Extension method: LogAdapterResponseError_UserRepository_GetUserAsync}Summary at a Glance
Section titled “Summary at a Glance”The key to template design is clearly distinguishing “what is fixed and what is dynamic.” Fixed parts are filled with constants or Raw String Literals, pattern parts with independent generation methods, and fully dynamic parts with data extracted from ObservableClassInfo and MethodInfo. Thanks to this separation, even when new observability fields or logging patterns are added, only the relevant method needs to be modified.
| Template Part | Fixed/Dynamic | Description |
|---|---|---|
| Header | Fixed | auto-generated comment |
| Using statements | Fixed | Required namespaces |
| Namespace | Dynamic | Same as original class |
| Class declaration | Dynamic | Original + Observable suffix |
| Fields | Pattern | Only type is dynamic |
| Constructor | Pattern | Only parameters are dynamic |
| Methods | Pattern | Only signatures and calls are dynamic |
| Logging | Pattern | Branches based on parameter count |
Q1: Why should fixed and dynamic parts be separated?
Section titled “Q1: Why should fixed and dynamic parts be separated?”A: Without separation, hundreds of lines of StringBuilder chaining become entangled in a single method, making maintenance difficult. When fixed parts (headers, using statements) are constants and dynamic parts (class names, method signatures) are filled with data extracted from ObservableClassInfo, only the relevant generation method needs to be modified even when new fields or logging patterns are added.
Q2: Why does GenerateObservableClassSource receive StringBuilder as a parameter?
Section titled “Q2: Why does GenerateObservableClassSource receive StringBuilder as a parameter?”A: To reuse the StringBuilder with Clear() when generating code sequentially for multiple classes. If a new instance is created inside the method each time, GC pressure increases, so the caller creates one instance, passes it, and initializes it with Clear() for memory efficiency.
Q3: What is the criterion for branching based on parameter count in logging method templates?
Section titled “Q3: What is the criterion for branching based on parameter count in logging method templates?”A: Because LoggerMessage.Define supports a maximum of 6 generic type parameters. When the total of 4 base fields (layer, category, handler, method) plus method parameters is 6 or fewer, the high-performance LoggerMessage.Define path is used; when exceeded, the logger.LogDebug() fallback path is used.
Q4: How do you distinguish the role of each method in the hierarchical generation method structure?
Section titled “Q4: How do you distinguish the role of each method in the hierarchical generation method structure?”A: Separation is based on structural units of the code to be generated. GenerateFields() handles field declarations, GenerateConstructor() handles the constructor, GenerateMethod() handles individual method overrides, and GenerateLoggingMethods() handles logging delegates and extension methods respectively. This allows modifying or testing the generation logic of a specific part independently.
We have understood the overall structure of the template. One of the elements filled dynamically in the template is the namespace. The generated code must be in the same namespace as the original class for inheritance to work correctly, and filename collisions must be prevented when classes with the same name exist in different namespaces. The next chapter explores these handling techniques.