From Console.WriteLine to ILogger without changing a single call site
Developers love Console.WriteLine. Production hates it. We built a compile-time severity inference + interceptor system so the same call sites route through ILogger when you ship — zero refactoring.
There are two kinds of .NET teams. The ones who use Console.WriteLine everywhere during development and pay the migration tax later. And the ones who shame each other into using ILogger from day one and lose a week of velocity.
Both are wrong. We picked a third path.
The five problems with the status quo
Console.WriteLineis a dead end. Fast to write during development, but no severity levels, no structured output, no way to route throughILoggerwithout manually replacing every call.- Object serialisation boilerplate. Printing a complex object to the console requires manual
JsonSerializer.Serialize()calls, null checks, and circular-reference handling. Every time. - No context grouping. Related log lines scatter with nothing tying them together. Developers resort to ad-hoc
=====separators that vary across the codebase. - The logging migration tax. When it’s time to ship, every
Console.WriteLinemust be individually found, replaced withILogger.Log(...), and assigned a severity level. Tedious, error-prone, often incomplete. - Severity is obvious to humans, invisible to code. A
Console.WriteLineinside acatchblock is clearly an error. One containing the word “warning” is clearly a warning. The compiler doesn’t know that. Neither does your logging pipeline.
What we built
PrintInConsole is an extension method system that works at two levels.
Level 1 — immediate value, zero setup:
using Kanject.Core.SystemConsole.Extensions;
"Hello from Kanject".PrintInConsole();
var order = new { Id = 42, Status = "Pending", Total = 99.95m };
order.PrintInConsole();
// Auto-serialised to indented JSON:
// {
// "Id": 42,
// "Status": "Pending",
// "Total": 99.95
// }
"Processing payment".PrintInConsole(tag: "Billing");
// ======== Billing START =========
// Processing payment
// ======== Billing END =========
No DI, no setup. Just call it. Add tags for grouping, attach extra data objects, run side-effect actions inline.
Level 2 — production readiness, one line:
<PropertyGroup>
<EnablePrintInConsoleInterceptor>true</EnablePrintInConsoleInterceptor>
</PropertyGroup>
services.AddPrintInConsoleLogging();
Every PrintInConsole call in your application now flows through ILogger with the correct severity. Zero call-site changes.
How the interceptor works
C# 12 introduced interceptors — a Roslyn source generator can emit a method that intercepts a specific call site at compile time and reroutes it through different code. We use this to redirect every PrintInConsole invocation to a configurable handler.
Your code (unchanged)
"error".PrintInConsole();
│ [InterceptsLocation]
▼
Generated interceptor
if (Handler != null)
→ Handler(text, tag, data, Error) ◄── severity inferred at compile time
else
→ ConsolePrintHelper() ◄── original console output
The handler check is a single null comparison. If you don’t enable the interceptor, you get the default console output. If you do enable it but never set a handler, same thing. If you wire AddPrintInConsoleLogging(), every call routes through ILogger. The system is non-breaking at every level.
Compile-time severity inference
The killer feature: the source generator analyses every PrintInConsole call site at compile time and infers a ConsolePrintLogLevel from context.
| Priority | Heuristic | Inferred Level | Example |
|---|---|---|---|
| 1 | Inside a catch block | Error | catch { ex.Message.PrintInConsole(); } |
| 2 | Receiver/argument is System.Exception | Error | exception.PrintInConsole(); |
| 3 | Tag contains error keywords | Error | .PrintInConsole(tag: "PaymentFailure") |
| 4 | Tag contains warning keywords | Warning | .PrintInConsole(tag: "CacheWarning") |
| 5 | Tag contains debug keywords | Debug | .PrintInConsole(tag: "DebugTrace") |
| 6 | Literal contains error keywords | Error | "Connection failed".PrintInConsole() |
| 9 | Default | Information | "Hello".PrintInConsole() |
Keyword patterns are word-boundary, case-insensitive:
- Error:
error,exception,fail,failed,failure - Warning:
warn,warning,caution - Debug:
debug,trace,verbose
The diagnostic KANPIC001 fires during build whenever severity is inferred:
KANPIC001: Inferred severity 'Error' for PrintInConsole call at
Services/PaymentService.cs:42 (Inside catch block).
Use an explicit ConsolePrintLogLevel overload to override.
You can audit every inference at build time. Disagree with one? Use the explicit-level overload:
"Error code lookup table loaded".PrintInConsole(ConsolePrintLogLevel.Information);
// Without override, the word "Error" would have inferred Error severity
Why this beats both extremes
The “use ILogger from day one” camp has a real point: structured logging, severity routing, and observability are non-negotiable in production. The cost is that _logger.LogInformation("...") is six syllables longer than "...".PrintInConsole(), and your developers feel that drag every single time they want to print something during a debug session.
The “use Console.WriteLine everywhere” camp has the opposite real point: when you’re debugging, you want to print, see, iterate. Friction at this layer kills exploratory work.
The interceptor approach gets both. The dev-loop syntax stays as light as Console.WriteLine. The production behaviour stays as structured as ILogger. The same source code does both jobs without a single character of difference.
Trade-offs we accept
- Interceptors are still preview in .NET 8 — they’re stable in .NET 9+. We auto-configure
InterceptorsPreviewNamespaces(.NET 8) andInterceptorsNamespaces(.NET 9+) via the shipped MSBuild.propsfile so consumers don’t have to think about it. But we mention it because it matters. - The inference heuristics are word-based, not semantic. A literal like “fail-safe” gets matched as Error. The KANPIC001 diagnostic surfaces every inference so you can audit and override. We considered fancier inference (NLP, AST patterns) and decided the keyword approach was simpler, faster, and good enough.
- We don’t replace your
ILoggercalls. If you’ve already invested in proper logging, this isn’t trying to undo that. It’s targeted at the swathe ofConsole.WriteLinecalls that exist in every codebase and never make it to production-quality logging.
Try it
Install Kanject.Core, add the MSBuild flag, wire AddPrintInConsoleLogging() in startup, and the next "...".PrintInConsole() you write — including the ones already in your codebase — flows through ILogger with the right severity.
Write PrintInConsole during development. Get ILogger.Log in production. Same call sites, zero refactoring.