← All posts

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

  1. Console.WriteLine is a dead end. Fast to write during development, but no severity levels, no structured output, no way to route through ILogger without manually replacing every call.
  2. Object serialisation boilerplate. Printing a complex object to the console requires manual JsonSerializer.Serialize() calls, null checks, and circular-reference handling. Every time.
  3. No context grouping. Related log lines scatter with nothing tying them together. Developers resort to ad-hoc ===== separators that vary across the codebase.
  4. The logging migration tax. When it’s time to ship, every Console.WriteLine must be individually found, replaced with ILogger.Log(...), and assigned a severity level. Tedious, error-prone, often incomplete.
  5. Severity is obvious to humans, invisible to code. A Console.WriteLine inside a catch block 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.

PriorityHeuristicInferred LevelExample
1Inside a catch blockErrorcatch { ex.Message.PrintInConsole(); }
2Receiver/argument is System.ExceptionErrorexception.PrintInConsole();
3Tag contains error keywordsError.PrintInConsole(tag: "PaymentFailure")
4Tag contains warning keywordsWarning.PrintInConsole(tag: "CacheWarning")
5Tag contains debug keywordsDebug.PrintInConsole(tag: "DebugTrace")
6Literal contains error keywordsError"Connection failed".PrintInConsole()
9DefaultInformation"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) and InterceptorsNamespaces (.NET 9+) via the shipped MSBuild .props file 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 ILogger calls. If you’ve already invested in proper logging, this isn’t trying to undo that. It’s targeted at the swathe of Console.WriteLine calls 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.

More field notes from the team.

Engineering April 15, 2026

Parallel.ForEachAsync wasn't enough. So we built our own.

The four things every batched-I/O workload needs — scoped DI, retry policy, per-partition timeout, and partial-batch acknowledgement — none of which the BCL gives you in one place. Here's why we shipped Kanject.Core.Parallel.

Engineering March 18, 2026

How we ship source generators that survive refactors

Lessons from two years of building Roslyn generators that don't silently break when downstream code moves.

Engineering February 22, 2026

Cadence belongs on the method, not in startup

Why we built drift-corrected, attribute-driven recurring tasks with three call-site overloads — and why we don't think you should reach for Timer, PeriodicTimer, or BackgroundService directly anymore.