Lesson 2 of 2
Cross-Cutting Concerns Without Tangling
Authorisation, logging, transactions and localisation genuinely apply everywhere. Placing them badly either scatters them through business logic or hides control flow nobody can find.
Some concerns refuse to sit in one module because they genuinely apply to every module. Authorisation, audit logging, transaction management, request correlation, localisation, rate limiting and error translation all cut across the whole system. They are called cross-cutting concerns, and how you place them determines whether your business logic stays readable.
There are only three real options, and each fails in a characteristic way. Scatter the concern through every method, and it is visible but repeated forty times and forgotten on the forty-first. Hide it in an invisible interception mechanism, and it is applied consistently but no reader of the code can tell what will happen at runtime. Place it at a boundary that every request must pass through, and it is both consistent and locatable — which is why the third option is the default for most of these concerns.
| Concern | Best home | Failure when misplaced |
|---|---|---|
| Authentication (who is the caller) | One boundary that every request crosses, resolving a principal once | Scattered token parsing, each with its own subtle bug |
| Authorisation (may they do this) | A central policy function called at the point of use, never inlined per endpoint | A role check copied into 200 handlers, three of which are wrong |
| Transactions | The application service that defines one business operation | Repositories opening nested transactions, or none, non-deterministically |
| Audit logging | The same application service, emitting a domain event | Logs written from UI code, missing anything done by a background job |
| Request correlation | The entry point, propagated implicitly through context | Untraceable incidents: the identifier is absent exactly where you need it |
| Localisation | The presentation edge, with the domain returning codes rather than sentences | Translated strings compared in business rules, and untranslatable domain errors |
// One authoritative definition, unit-testable with no request in sight.
export function can(
principal: Principal,
permission: Permission,
scope?: { organizationId: string },
): boolean {
if (principal.platformRole === 'ADMINISTRATOR') return true;
if (!scope) return false;
const membership = principal.memberships.find((m) => m.organizationId === scope.organizationId);
return membership ? permissionsFor(membership.role).includes(permission) : false;
}
// Called at the point of use. Note what is absent: no role name, no
// "if (user.role === 'admin')", nothing to copy incorrectly into the next
// handler someone writes.
export async function publishLesson(input: PublishLessonInput) {
const principal = await authorize('content.publish', { organizationId: input.organizationId });
return lessonService().publish(principal, input.lessonId);
}Explicit calls versus implicit interception
Explicit call at each operation
Each application service begins with a call to the shared policy, transaction or audit helper. The reader sees it.
- Control flow is visible in the file you are reading
- Resource-dependent rules are natural, since the data is in scope
- Easy to grep for every place a concern is applied
- It can be forgotten on a new operation
- A line of ceremony at the top of many functions
Choose when: The concern depends on the specific resource or business operation — authorisation, transactions, audit entries with business meaning.
Implicit interception at a chokepoint
A middleware, decorator or proxy applies the concern to everything that passes, with no code in the operation itself.
- Impossible to forget, because nothing opts in
- Zero noise in business code
- One place to change behaviour for the whole system
- Runtime behaviour is invisible to a reader of the operation
- Debugging requires knowing the mechanism exists
- Cannot easily depend on the specific resource being acted on
Choose when: The concern is uniform and resource-independent — correlation identifiers, metrics, request logging, compression, rate limiting per caller.
In practice
The audit log that could not be trusted
A healthcare scheduling product logged every access to patient data from its API controllers, satisfying an auditor for two years. A compliance review then found gaps: some record accesses had no audit entry at all.
Constraints
- Regulatory requirement to log every access to patient records
- Access happens through the API, a nightly export job and an internal admin tool
- The audit log is written by controller code
- Remediation deadline of one quarter
Decision
Move the audit write from the controllers into the application service that loads a patient record, so that every caller — API, export job and admin tool — produces an entry on the single path that actually reads the data.
Why
The controllers were not the boundary every access crossed; they were the boundary *one* kind of access crossed. The export job and the admin tool reached the repository directly and were invisible to the auditor. Choosing the narrowest layer that all three paths share moved the concern to a place where forgetting it is not possible.
What it cost
Audit entries are now written inside a database transaction with the read, which costs a write on every record view and roughly doubles the latency of the busiest endpoint. The team accepted it, added an index and a retention policy, and documented that a queue-based alternative was rejected because a lost message would be a compliance failure rather than a performance problem.
Reviewing a new endpoint
As a developer
Checks the logic, the validation and the tests. The endpoint mirrors the twelve endpoints next to it, including the two lines of role checking copied from the nearest one, so it looks correct and consistent.
As an architect
Notices that the role check is copied rather than called, and asks how the team would change that rule across all thirteen endpoints. If the answer is "search for the string and edit each", the concern is scattered and the next endpoint will get it wrong. The fix is not a review comment on this pull request — it is a central policy function plus, ideally, a check that fails the build when a role name appears outside it.
Your team wants to add a rule that any request touching customer data must record who touched it. Someone proposes an interceptor that inspects every repository call and writes an entry. What questions decide whether this is a good design?RevealHide
Three. First, can the interceptor tell what it is looking at — does a generic repository call carry enough meaning to record "who viewed patient 44's record", or will it produce a stream of low-value rows saying a query ran? Second, can it be bypassed: if any code path reaches the database without going through that layer, the audit is incomplete and therefore worthless for its purpose. Third, will a reader of the business code know this happens at all, and how will they discover it during an incident at three in the morning? Interception is excellent for uniform, resource-independent concerns and poor for concerns that need business meaning — which is what makes audit logging a borderline case that usually belongs in the application service instead.
Key takeaways
- A cross-cutting concern needs a single definition and an application point that every relevant path crosses.
- Choose the narrowest layer all callers share, not the most convenient one — controllers are rarely it.
- Use implicit interception for uniform, resource-independent concerns; use explicit calls where the rule depends on the resource.
- Authorisation belongs in one policy function called at the point of use, never inlined per endpoint.
- If a rule can be copied incorrectly into the next handler, expect that to happen; make the build catch it.