Skip to content

ADR-0015: Adapter - Observable Port Source Generator

Functorium applies Tracing Span creation, structured log recording, and Metrics counter/histogram collection to every port call. This requires writing an Observable decorator for each port interface, and the problem is that this work is entirely repetitive labor.

If 10 ports each have 3-5 methods, 30-50 wrapping methods must be manually written. The more serious issue is maintenance. When a CancellationToken parameter is added to IPaymentPort.ChargeAsync, the wrapping method in ObservablePaymentPort must also be synchronized. If this synchronization is missed, observability silently breaks, and it is only discovered in production when Tracing Spans for that port go missing.

  1. [GenerateObservablePort] Source Generator
  2. Runtime reflection proxy (DispatchProxy)
  3. Manual decorator writing
  4. AOP framework (Castle.DynamicProxy, etc.)

Chosen option: “[GenerateObservablePort] Source Generator”. By simply attaching a single attribute to a port interface, Tracing/Logging/Metrics wrappers are automatically generated at compile time. When port signatures change, decorators are auto-regenerated on the next build, making synchronization misses structurally impossible, with zero runtime reflection cost.

  • Good, because adding a new port only requires a single [GenerateObservablePort] attribute line, and Tracing Span, structured log, and Metrics counter/histogram code are auto-generated, making observability omissions impossible.
  • Good, because code is generated at compile time as C# source, resulting in zero runtime reflection cost and compatibility with Native AOT environments.
  • Good, because the generated Observable{PortName}.g.cs files can be opened directly in the IDE for debugging and code review.
  • Bad, because specialized knowledge of the Incremental Generator API, Roslyn symbol analysis, and source text emitting is required, limiting the number of people who can maintain it.
  • Bad, because in some IDEs, IntelliSense or Go to Definition for generated code is not immediately reflected, requiring a build before navigation is available.
  • Verify that [GenerateObservablePort] attribute is applied to port interfaces.
  • Verify that Observable{PortName} classes are generated during build.
  • Verify through snapshot tests that generated decorators correctly record Tracing Spans, structured logs, and Metrics counters/histograms.
  • Good, because pure C# code is generated at compile time with zero runtime overhead, and no reflection intervenes in the call path.
  • Good, because when port interface method signatures change, decorators are auto-regenerated on the next build, eliminating manual synchronization.
  • Good, because generated .g.cs files are included in the project, enabling debugger step-in, code review, and snapshot testing.
  • Bad, because understanding Roslyn’s Incremental Generator API and symbol model is required, making the generator itself’s development barrier high.
  • Bad, because if the generator has bugs, build error messages point to generated code, requiring time to trace the root cause.
  • Good, because a proxy can be created with a single DispatchProxy.Create<TInterface, TProxy>() call, making initial implementation simple.
  • Bad, because every port method call incurs reflection-based MethodInfo lookup and parameter boxing, accumulating performance overhead.
  • Bad, because runtime type generation may be restricted in Native AOT environments, potentially not functioning.
  • Bad, because proxy code does not exist in source, preventing debugger step-in and making snapshot testing of correct Tracing/Logging/Metrics recording difficult.
  • Good, because written in pure C# without Source Generators or reflection, anyone can understand and modify it.
  • Bad, because 10 ports x 3-5 methods = 30-50 wrapping methods must be manually written and maintained, with code volume growing linearly as ports increase.
  • Bad, because adding parameters to a port interface requires updating the decorator too, and if the compiler does not enforce this, synchronization misses occur silently.
  • Bad, because forgetting to write a decorator when adding a new port means that port’s entire observability is missing, discovered only as Tracing omissions in production.
  • Good, because a single Interceptor can apply common Aspects across all ports, making initial setup convenient.
  • Bad, because IL generation at runtime to create proxies incurs app startup initialization cost, and the call path becomes opaque.
  • Bad, because additional external library dependency on Castle.DynamicProxy, etc. is introduced, and the project is affected by that library’s update cycle.
  • Bad, because runtime IL generation is blocked in Native AOT environments, conflicting with Functorium’s AOT compatibility goals.
  • Related commit: a5027a78 feat(observability): ObservablePortGenerator improvements + framework field naming unification
  • Related commit: 81233196 feat(source-generator): LogEnricher source generator implementation
  • Related docs: Docs.Site/src/content/docs/tutorials/sourcegen-observability/