Skip to content

Project Overview

The previous two chapters covered the concept and rationale for source generators. Now it’s time to connect theory to a real project. This chapter introduces the design goals, the problem being solved, and the project structure of the ObservablePortGenerator that we will implement throughout this tutorial. The big picture drawn here becomes the learning context for each subsequent chapter.

  1. Understand the purpose and expected benefits of ObservablePortGenerator
    • Identify the cross-cutting concern problem in the adapter layer and the need for automation
  2. Understand the reasons for implementing with a source generator
    • Specific advantages of compile-time generation over runtime AOP
  3. Understand the overall project structure
    • The relationship between the source generator, core library, and test projects

ObservablePortGenerator is a source generator that automatically adds Observability capabilities to adapter classes.

The adapter layer (database access, external API calls, etc.) must handle the following cross-cutting concerns:

Cross-Cutting Concerns
======================
1. Logging
- Request/response recording
- Parameter value tracking
- Error information recording
2. Tracing
- Distributed tracing context propagation
- Activity creation and management
- Request correlation tracking
3. Metrics
- Response time measurement
- Success/failure counters
- Resource usage measurement

Manually implementing these features causes the following problems:

// Manual implementation - boilerplate code repeated in every method
public class UserRepository : IObservablePort
{
private readonly ILogger<UserRepository> _logger;
private readonly ActivitySource _activitySource;
private readonly Counter<long> _requestCounter;
private readonly Histogram<double> _durationHistogram;
public FinT<IO, User> GetUserAsync(int userId)
{
// Record start time
var startTimestamp = Stopwatch.GetTimestamp();
// Request logging
_logger.LogInformation("GetUserAsync request: userId={UserId}", userId);
// Create tracing Activity
using var activity = _activitySource.StartActivity("GetUserAsync");
try
{
// Actual business logic
var result = await _dbContext.Users.FindAsync(userId);
// Success logging
var elapsed = CalculateElapsed(startTimestamp);
_logger.LogInformation("GetUserAsync success: {Elapsed}ms", elapsed);
// Record metrics
_requestCounter.Add(1);
return result;
}
catch (Exception ex)
{
// Failure logging
var elapsed = CalculateElapsed(startTimestamp);
_logger.LogError(ex, "GetUserAsync failure: {Elapsed}ms", elapsed);
// Record metrics
_durationHistogram.Record(elapsed);
throw;
}
}
// Same pattern repeated for other methods...
public FinT<IO, IEnumerable<User>> GetUsersAsync() { /* same boilerplate */ }
public FinT<IO, Unit> UpdateUserAsync(User user) { /* same boilerplate */ }
public FinT<IO, Unit> DeleteUserAsync(int userId) { /* same boilerplate */ }
}

Problems:

  • 30-50 lines of boilerplate code added per method
  • Possibility of accidentally omitting logging
  • Difficult to maintain consistency of logging formats
  • Difficult to identify core logic during code review

Existing techniques such as runtime AOP or Interceptors could be used to solve this problem. However, ObservablePortGenerator chose source generators. Let’s examine the specific reasons why.


Source generators apply the same pattern to every method. It is impossible for developers to accidentally omit anything.

// Code the developer writes - focus only on core logic
[GenerateObservablePort]
public class UserRepository(ILogger<UserRepository> logger) : IObservablePort
{
public FinT<IO, User> GetUserAsync(int userId) =>
// Write only pure business logic
from user in _dbContext.Users.FindAsync(userId)
select user;
public FinT<IO, IEnumerable<User>> GetUsersAsync() =>
from users in _dbContext.Users.ToListAsync()
select users;
}
// Code automatically generated by the source generator
public class UserRepositoryObservable : UserRepository
{
// Logging, tracing, and metrics automatically applied to all methods
}

Unlike runtime AOP (Aspect-Oriented Programming) or Interceptors, code is generated at compile time, so:

ApproachRuntime OverheadAOT Support
Castle DynamicProxyHighLimited
DispatchProxyMediumLimited
Source GeneratorNoneFull Support

The generated code is regular C# code, so you can step into it with the debugger to examine the logging logic.

Automatically generates high-performance logging code using LoggerMessage.Define:

// High-performance logging code generated by the source generator
internal static class UserRepositoryObservableLoggers
{
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}");
public static void LogAdapterRequestDebug_UserRepository_GetUserAsync(
this ILogger logger, string requestLayer, string requestCategory, string requestHandler, string requestHandlerMethod, int userId)
{
if (!logger.IsEnabled(LogLevel.Debug))
return;
_logAdapterRequestDebug_UserRepository_GetUserAsync(logger, requestLayer, requestCategory, requestHandler, requestHandlerMethod, userId, null);
}
}

When parameter types change, a compile error occurs immediately, alerting you right away.

Now that we have confirmed the reasons for choosing a source generator, let’s examine what design patterns ObservablePortGenerator is built upon.


IncrementalGeneratorBase applies the Template Method Pattern to define the common flow of source generators:

// Template Method Pattern - common flow definition
public abstract class IncrementalGeneratorBase<TValue>(
Func<IncrementalGeneratorInitializationContext,
IncrementalValuesProvider<TValue>> registerSourceProvider, // Step 1: Register source provider
Action<SourceProductionContext, ImmutableArray<TValue>> generate, // Step 2: Code generation
//Action<IncrementalGeneratorPostInitializationContext>? registerPostInitializationSourceOutput = null,
bool AttachDebugger = false) : IIncrementalGenerator
{
protected const string ClassEntityName = "class";
private readonly bool _attachDebugger = AttachDebugger;
private readonly Func<IncrementalGeneratorInitializationContext, IncrementalValuesProvider<TValue>> _registerSourceProvider = registerSourceProvider;
private readonly Action<SourceProductionContext, ImmutableArray<TValue>> _generate = generate;
// Template method - fixed algorithm flow
public void Initialize(IncrementalGeneratorInitializationContext context)
{
#if DEBUG
if (_attachDebugger && Debugger.IsAttached is false)
{
Debugger.Launch();
}
#endif
// Step 1: Extract targets of interest from source code
IncrementalValuesProvider<TValue> provider = _registerSourceProvider(context)
.Where(static m => m is not null);
// Step 2: Generate code from extracted information
context.RegisterSourceOutput(provider.Collect(), Execute);
}
private void Execute(SourceProductionContext context, ImmutableArray<TValue> displayValues)
{
_generate(context, displayValues);
}
}
Template Method Pattern Structure
=================================
IncrementalGeneratorBase (abstract class)
├── Initialize() # Template method (fixed)
│ ├── registerSourceProvider() # Abstract step 1
│ ├── .Where(not null) # Null filtering
│ └── Execute() → generate() # Abstract step 2
└── ObservablePortGenerator (concrete class)
├── RegisterSourceProvider() # Implementation: Filter [GenerateObservablePort] classes
└── Generate() # Implementation: Generate Observable code

Benefits:

  • Reuse common structure of source generators
  • Only implement core logic when adding new generators
  • Centrally manage common features such as debugging flags

The Strategy Pattern is implemented through the IObservablePort interface. Each adapter encapsulates a communication strategy with a specific external system:

// IObservablePort interface - common contract for strategies
public interface IObservablePort
{
string RequestCategory { get; }
}
// Concrete strategy definition - user repository
public interface IUserRepository : IObservablePort
{
FinT<IO, User> GetUserAsync(int id);
FinT<IO, IEnumerable<User>> GetUsersAsync();
}
// Concrete strategy definition - order repository
public interface IOrderRepository : IObservablePort
{
FinT<IO, Order> GetOrderAsync(int id);
FinT<IO, Unit> CreateOrderAsync(Order order);
}
Strategy Pattern Structure
==========================
IObservablePort (strategy interface)
├── IUserRepository # User-related strategy
│ └── UserRepository # Concrete implementation
│ └── UserRepositoryObservable <- Generated by source generator
├── IOrderRepository # Order-related strategy
│ └── OrderRepository # Concrete implementation
│ └── OrderRepositoryObservable <- Generated by source generator
└── IProductRepository # Product-related strategy
└── ProductRepository # Concrete implementation
└── ProductRepositoryObservable <- Generated by source generator

Role of the Source Generator:

// Written by the developer - strategy implementation
[GenerateObservablePort]
public class UserRepository(ILogger<UserRepository> logger) : IUserRepository
{
public FinT<IO, User> GetUserAsync(int id) =>
// Pure data access logic
from user in _dbContext.Users.FindAsync(id)
select user;
}
// Auto-generated by source generator - strategy decorator
public class UserRepositoryObservable : UserRepository
{
// Inherits original strategy and adds observability features
// Logging, tracing, and metrics are automatically applied
}

Benefits:

  • Business logic isolation for each adapter (strategy)
  • Consistent automatic application of observability code
  • Easy to swap in Observable classes in the DI container
// Use Observable classes when registering with DI
services.AddScoped<IUserRepository, UserRepositoryObservable>();
services.AddScoped<IOrderRepository, OrderRepositoryObservable>();

By combining the Template Method Pattern and the Strategy Pattern, the common flow of source generators is reused while consistently generating Observability code for each adapter. Let’s verify the actual impact of this design with numbers.


Lines of Code
==============
UserRepository.cs : 200 lines (including logging code)
OrderRepository.cs : 180 lines (including logging code)
ProductRepository.cs : 220 lines (including logging code)
-----------------------------------------
Total : 600 lines
Problems
========
- Business logic mixed with cross-cutting concerns
- Difficult to maintain consistency
- Increased code review complexity
Lines of Code
==============
UserRepository.cs : 50 lines (pure business logic)
OrderRepository.cs : 40 lines (pure business logic)
ProductRepository.cs : 60 lines (pure business logic)
-----------------------------------------
Total : 150 lines (75% reduction)
+ Auto-generated Observable classes
UserRepositoryObservable.g.cs : auto-generated
OrderRepositoryObservable.g.cs : auto-generated
ProductRepositoryObservable.g.cs : auto-generated
Benefits
========
- Focus only on business logic
- 100% consistent Observability
- Improved code review efficiency

Let’s examine the actual directory structure of the project that achieves these benefits.


Functorium/
├── Src/
│ ├── Functorium.SourceGenerators/ # Source generator
│ │ ├── Abstractions/
│ │ │ ├── Constants.cs # Common constants (headers, etc.)
│ │ │ └── Selectors.cs # Common selectors
│ │ │
│ │ └── Generators/
│ │ ├── IncrementalGeneratorBase.cs # Template Method Pattern base class
│ │ │
│ │ ├── ObservablePortGenerator/ # Observability code generator
│ │ │ ├── ObservablePortGenerator.cs # Main source generator
│ │ │ ├── ObservableGeneratorConstants.cs # Generator-specific constants
│ │ │ ├── ObservableClassInfo.cs # Class info record
│ │ │ ├── MethodInfo.cs # Method info
│ │ │ ├── ParameterInfo.cs # Parameter info
│ │ │ ├── TypeExtractor.cs # Type extraction utility
│ │ │ ├── CollectionTypeHelper.cs # Collection type detection
│ │ │ ├── SymbolDisplayFormats.cs # Type string format
│ │ │ ├── ConstructorParameterExtractor.cs # Constructor analysis
│ │ │ └── ParameterNameResolver.cs # Name conflict resolution
│ │ │
│ │ ├── EntityIdGenerator/ # Entity ID auto-generator
│ │ │ ├── EntityIdGenerator.cs # Ulid-based ID struct generation
│ │ │ └── EntityIdInfo.cs # Entity info record
│ │ │
│ │ └── UnionTypeGenerator/ # Union Type generator
│ │ ├── UnionTypeGenerator.cs # Match/Switch method generation
│ │ └── UnionTypeInfo.cs # Union info record
│ │
│ ├── Functorium/ # Core domain library
│ │ └── Domains/
│ │ └── Observabilities/
│ │ └── IObservablePort.cs # Observability marker interface
│ │
│ ├── Functorium.Adapters/ # Adapter library
│ │ ├── SourceGenerators/
│ │ │ └── GenerateObservablePortAttribute.cs # [GenerateObservablePort] attribute
│ │ └── Observabilities/
│ │ └── Naming/
│ │ ├── ObservabilityNaming.cs # Observability naming rules
│ │ ├── ObservabilityNaming.Events.cs # Event ID definitions
│ │ └── ObservabilityNaming.Attributes.cs # Attribute key definitions
│ │
│ └── Functorium.Testing/ # Test utilities
│ └── Actions/
│ └── SourceGenerators/
│ └── SourceGeneratorTestRunner.cs # Test runner
└── Tests/
└── Functorium.Tests.Unit/
└── AdaptersTests/
└── SourceGenerators/
├── ObservablePortGeneratorTests.cs # 31 snapshot tests
├── ObservablePortObservabilityTests.cs # Tag structure verification
├── ObservablePortLoggingStructureTests.cs # Logging field structure verification
├── ObservablePortMetricsStructureTests.cs # Metrics tag structure verification
├── ObservablePortTracingStructureTests.cs # Tracing tag structure verification
└── Snapshots/ # Snapshot files
├── ObservablePortGenerator/
├── ObservablePortLoggingStructure/
├── ObservablePortMetricsStructure/
└── ObservablePortTracingStructure/

The main source generator class. It operates as a 2-stage pipeline:

Stage 1: Target Class Filtering
================================
Selects only classes that have the
[GenerateObservablePort] attribute (predefined
in the Functorium library) and implement IObservablePort
Stage 2: Observable Class Generation
=====================================
Generates wrapper methods that include logging,
tracing, and metrics code for each method

Provides the Template Pattern for incremental source generators. See the Core Design Patterns > Template Method Pattern section for implementation code.

ClassRole
TypeExtractorFinT<IO, User> -> User type extraction
CollectionTypeHelperList<T>, IEnumerable<T> etc. collection detection
SymbolDisplayFormatsDeterministic type string generation
ConstructorParameterExtractorConstructor parameter analysis
ParameterNameResolverlogger -> baseLogger name conflict resolution

Part 0: Introduction
====================
- Source Generator concept, Hello World, project overview
Part 1: Fundamentals
====================
- Development environment setup
- Roslyn Architecture (Syntax API, Semantic API, Symbol)
Part 2: Core Concepts
=====================
- IIncrementalGenerator, Provider Pattern
- ForAttributeWithMetadataName, symbol analysis
- StringBuilder code generation, deterministic output
Part 3: Advanced
================
- Constructor, Generic, Collection handling
- LoggerMessage.Define 6-parameter limit
- Snapshot testing, 31 test scenarios
Part 4: Cookbook
===============
- Entity ID, EF Core Value Converter, Validation generators
- Custom Generator template

ObservablePortGenerator automatically generates and eliminates repetitive logging, tracing, and metrics code in the adapter layer. The reason for choosing a source generator is that it simultaneously meets four requirements: consistency, performance, type safety, and AOT support. With a design combining the Template Method Pattern and the Strategy Pattern, it reduces the amount of code developers write by approximately 75% while guaranteeing 100% consistent observability.


Q1: What are the benefits of using IncrementalGeneratorBase<TValue>?

Section titled “Q1: What are the benefits of using IncrementalGeneratorBase<TValue>?”

A: It manages common logic such as debugger attachment, null filtering, and batch processing via Collect() in a single place. When adding a new source generator, you only need to implement the registerSourceProvider and generate functions, eliminating duplication of pipeline configuration code.

Q2: What role does the IObservablePort interface play in the Strategy Pattern?

Section titled “Q2: What role does the IObservablePort interface play in the Strategy Pattern?”

A: IObservablePort serves as a marker to identify targets for which the source generator should generate code. Only classes implementing this interface become targets for the [GenerateObservablePort] attribute, and each adapter defines its observability category through the RequestCategory property.

Q3: What is the basis for the claim that code volume decreases by 75% after introducing the source generator?

Section titled “Q3: What is the basis for the claim that code volume decreases by 75% after introducing the source generator?”

A: With manual implementation, 30-50 lines of boilerplate for logging, tracing, and metrics are added per method. After introducing the source generator, only pure business logic remains and all cross-cutting concern code is auto-generated, resulting in approximately 75% code reduction based on actual project measurements.

Q4: Why are helper classes like TypeExtractor and CollectionTypeHelper separated into distinct classes?

Section titled “Q4: Why are helper classes like TypeExtractor and CollectionTypeHelper separated into distinct classes?”

A: Each helper handles an independent responsibility (type extraction, collection detection, name conflict resolution, etc.). Separating them according to the Single Responsibility Principle allows individual logic to be tested independently and reused across other source generators.


Now that we have grasped the overall picture of the project, it is time to set up the environment for actually developing source generators.

-> Part 1, Chapter 1. Development Environment