100%

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.

12 min read

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.

ConcernBest homeFailure when misplaced
Authentication (who is the caller)One boundary that every request crosses, resolving a principal onceScattered 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 endpointA role check copied into 200 handlers, three of which are wrong
TransactionsThe application service that defines one business operationRepositories opening nested transactions, or none, non-deterministically
Audit loggingThe same application service, emitting a domain eventLogs written from UI code, missing anything done by a background job
Request correlationThe entry point, propagated implicitly through contextUntraceable incidents: the identifier is absent exactly where you need it
LocalisationThe presentation edge, with the domain returning codes rather than sentencesTranslated strings compared in business rules, and untranslatable domain errors
Where each common cross-cutting concern belongs, and what it costs when it is placed elsewhere.
The rule is central; the invocation is at the boundary. Business logic never contains a role name.typescript
// 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?Reveal

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.