Skip to content

Template Design

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.

  1. Understand the separation principle of fixed and dynamic parts
    • Three categories: constants, patterns, and fully dynamic
  2. Grasp the hierarchical generation method structure
    • The call tree: GenerateObservableClassSource -> GenerateFields -> GenerateMethod
  3. Learn the branching strategy for logging method templates based on parameter count

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 pattern
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)

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
""";
}
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 code

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();
}

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();
}

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();
}

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 method
public 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)));

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);
}
}

The generator produces Debug/Information multi-level logging:

Log LevelContentPurpose
DebugIncludes parameter values, result valuesDetailed debugging
InformationHandler/method name onlyOperational monitoring
WarningExpected errorsBusiness error tracking
ErrorSystem errors (Exceptional)Failure detection
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
}

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 PartFixed/DynamicDescription
HeaderFixedauto-generated comment
Using statementsFixedRequired namespaces
NamespaceDynamicSame as original class
Class declarationDynamicOriginal + Observable suffix
FieldsPatternOnly type is dynamic
ConstructorPatternOnly parameters are dynamic
MethodsPatternOnly signatures and calls are dynamic
LoggingPatternBranches 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.

-> 11. Namespace Handling