- Published on
Logging and Architecture Tests Defend the Same Thing: Stay Debuggable When It Breaks, Keep Boundaries from Being Silently Punched Through
- Authors

- Name
- Jack Qin
Logging and architectural boundaries look like two unrelated things — one is about "how do I debug a production incident," the other about "how do modules avoid being punched through." But put them side by side and you find they defend the same thing: the system's governability under continuous change. Logging ensures you can debug when it breaks; architecture tests ensure the modular monolith doesn't quietly dissolve under continuous change. Neither is "writing code" itself — both are guardrails for "keeping code controllable over the long run."
What I want to take apart here are two judgments: where to log and what not to log, and why module boundaries must be enforced by architecture tests rather than review alone. The latter is especially counterintuitive — many people feel boundaries can be held just by watching them in code review, but review is done by people with memory, and memory fails steadily as the project grows.
Logging: where to log, what to log, what to never log
Both extremes of logging are common, and both cost the system its governability. On one side is "too little logging" — when a production incident actually hits, the log holds nothing but a single context-free error and there's no way in. On the other side is "too dirty logging" — someone takes the easy route and dumps the entire request body, even auth cookies and tokens. That's both noise, drowning the genuinely useful signal, and a security hazard, writing sensitive data into a store that's usually less protected.
The backend uses Serilog for structured logging in both the API and Worker hosts:
- Both hosts bootstrap Serilog as early as possible.
- The API host enables request logging (
UseSerilogRequestLogging()). - Exceptions are logged centrally.
- Module/job code uses
ILogger<T>. - Log output should help operators debug production issues without leaking sensitive data.
Host-level logging: the API host creates a bootstrap logger before building the app, configures Serilog from config and services, and enables request logging. The Worker host creates a bootstrap logger before building the host, configures Serilog from config and services, and logs fatal-level startup failures before exiting.
Why "log at the boundary" is the key criterion: the value of a log isn't in "logging a lot," it's in "logging where the operator will look." Logging every step of pure internal logic only manufactures noise; what genuinely needs logging is the boundaries — the places where, once something goes wrong, it crosses out of your field of view. So use ILogger<T> in module code, jobs, consumers, and services, and prioritize logging these points:
- Log at the boundary: job execution, integration calls, unexpected failures, migration failures, startup lifecycle.
- Prefer structured templates over string concatenation.
- Include identifiers that help correlate work, like schedule ID, user ID, asset ID, or module name.
- Let the global exception handler or host-level error handling own truly unhandled exceptions (rather than catch-and-log repeated at every layer).
// Structured template + correlation identifiers
_logger.LogInformation(
"Processing email schedule {ScheduleId} for site {SiteId}",
schedule.Id, schedule.SiteId);
Structured templates aren't just a formatting preference — a placeholder like {ScheduleId} lets the log backend index and query by field, whereas a string-concatenated log can only be full-text searched. Correlation identifiers are what let you thread logs scattered across many places into one complete chain of work.
Sensitive-data rule — don't log:
- Secrets or credentials
- Raw auth cookies or tokens
- Personally sensitive data, unless the business need is explicit and approved
- The entire request body by default (just because adding a log is easy)
That last one exposes a common psychology: adding a log is so easy that people will "just" dump the whole object in. But when logging a failure, the right move is to prefer stable identifiers and high-signal context over dumping everything — dumping everything both leaks sensitive fields and dilutes the real signal with noise.
Error logging: unhandled API exceptions are normalized by the global exception handler — the server logs the exception and returns a sanitized ProblemDetails, including extra exception detail only in the development environment. This holds both ends at once: the operator sees the full exception on the server, while the client gets nothing of the internal state.
Module boundaries: why review-by-memory is bound to fail
The entire value of a modular monolith rides on that one boundary. The moment someone adds a direct Api -> Worker dependency, or stuffs a concrete infrastructure dependency into BuildingBlocks, the boundary starts to dissolve, and changing one thing ripples across many.
There's a key judgment here: this kind of boundary violation can't be held by review. The reason is structural — a violation might be just one extra line of ProjectReference in some .csproj, buried in a few hundred lines of diff, easily skated past by a reviewer. And even if it's caught this time, what about next time, and the time after? Review relies on human memory and attention, and both fail steadily as the project grows and people change. A boundary constraint that lives only in the reviewer's head will fail; it's only a matter of time. What actually holds the line is making architecture tests turn these constraints into red lights in CI — machines have no memory problem and don't skate past a diff.
Backend quality is ensured by build rules, architectural boundaries, automated tests, and host/module separation. Current signals: .NET 10 + shared build config; warnings treated as errors via shared build properties; architecture tests protecting project boundaries; host and module tests covering endpoints and integration behavior; Testcontainers as part of ordinary backend testing.
Patterns that must be followed:
- Respect module boundaries: use
ModuleRegistration.csas the consistent entry point for a module's service registration and endpoint mapping; keep API-host concerns inPlatform.Apiand Worker-host concerns inPlatform.Worker; route cross-module communication through Contracts projects and message abstractions. - Keep endpoints thin: focused on HTTP concerns, permissions, and route-local persistence orchestration.
- Use the right test layer: architecture tests / host integration tests / module tests / API contract tests — pick the smallest layer that proves the behavior.
Strictly forbidden patterns (several enforced by architecture tests):
- Introducing a direct
Api -> WorkerorWorker -> Apidependency. - Adding a concrete infrastructure dependency to
BuildingBlocks. - Bypassing module contracts with direct implementation references.
- Creating a generic repository layer spanning modules.
- Putting long-lived business rules only in Program/bootstrap code.
This last one is easy to underrate: stuffing business rules into Program.cs isn't just misplaced, it's that the rules become both untestable and not part of bootstrap. Bootstrap code's job is wiring; putting long-lived business rules in there leaves them uncovered by module tests and scatters the rules into a place nobody will think to look.
Review checklist (review is still useful, it just no longer carries boundary-holding alone): does the change stay inside the boundary of the module that owns it? Does cross-module access go through Contracts rather than implementation references? Are writes EF Core, with Dapper left to query/read paths? When schema changed, were the migration and affected SQL/tests updated together? Is the chosen test layer appropriate? Is the change correct in both production startup and the Testcontainers tests?
Counter-examples
// Counter-example 1: logging the whole request body + token
_logger.LogInformation("Request: {Body}", JsonSerializer.Serialize(request)); // ❌ contains sensitive fields
_logger.LogDebug("Auth cookie: {Cookie}", httpContext.Request.Cookies["app-auth"]); // ❌
// Correct: log only stable identifiers and high-signal context
_logger.LogInformation("Login attempt for user {UserId}", userId); // ✅
// Counter-example 2: ad-hoc console logging when Serilog + ILogger<T> is already in place
Console.WriteLine($"Job failed: {ex.Message}"); // ❌
// Correct: use the injected ILogger<T>
_logger.LogError(ex, "Email schedule job {ScheduleId} failed", scheduleId); // ✅
// Counter-example 3: Api depending directly on Worker — architecture tests go red
// Platform.Api project references Platform.Worker ❌
// Correct: route cross-host/module communication through message abstractions (IEventPublisher / ICommandDispatcher)
// Counter-example 4: putting business rules only in Program.cs
// a big block of scheduling business rules written in Program.cs ❌ untestable, and not part of bootstrap
// Correct: put the rules in the module that owns them, bootstrap only does wiring
Other things to avoid: don't add ad-hoc console logging when Serilog/ILogger<T> is already in place; don't log sensitive request data by default; don't repeat the same failure log across multiple layers (unless each layer adds its own context); don't return raw exception detail to the client outside development; don't add cross-module shortcuts that break the modular monolith's boundaries; don't forget that the API host runs migrations automatically on startup while tests may skip them; don't stuff infrastructure concerns into shared abstractions.
Putting it into practice
- Log at the boundary: job execution, integration calls, unexpected failures, migration failures, startup lifecycle — these are the handles operators use to debug.
- Structured template + correlation ID: use
{ScheduleId}rather than string concatenation; carry the identifiers that thread a unit of work together. - Zero tolerance for sensitive data: secrets, tokens, raw cookies, the whole request body — none of them logged by default; on failure, give a stable identifier, don't dump everything.
- Let architecture tests be the gatekeeper: violations like
Api -> WorkerorBuildingBlocksdepending on concrete infrastructure go red in CI via tests, not via reviewer memory. - Warnings are errors: shared build properties treat warnings as errors; don't develop a "warnings don't matter" habit.
- Business rules belong to modules: long-lived rules go in the module that owns them; bootstrap is only responsible for wiring.
The transferable layer
Strip away the specific tooling of Serilog and architecture tests, and logging and boundaries share one transferable insight: a constraint that lives only in human discipline or memory will rot steadily as the system grows. "Remember not to log the token," "remember not to add a cross-module dependency" — these memory-maintained disciplines break the first time someone forgets, and someone always forgets.
The way to make a constraint hold over the long run is to demote it from "people must remember" to "a mechanism will check": sensitive data avoided via the field selection of structured logging, boundary violations caught by architecture tests going red in CI. Every time you write down a "everyone, please remember to..." convention, it's worth asking: does this constraint have an enforcement point that doesn't depend on memory? A convention with no enforcement point, however clearly written, only delays rot rather than preventing it.