Part 5. Authorisation as a Cross-Cutting Concern Without Leaking Modules

If you’ve followed this series so far, you’ve done the hard structural work.
You’ve enforced real module boundaries.
You’ve organised behaviour into vertical slices.
You’ve isolated data with separate DbContexts.
You’ve stopped modules from chatting like overfriendly neighbours.
And then you hit authorisation.
Suddenly, every nice boundary you worked so hard to protect feels under threat.
Permissions need to be checked everywhere.
Identity is shared.
Policies feel global.
This is where most modular monoliths start leaking.
Why Authorisation Is So Dangerous Architecturally
Authorisation is one of the most deceptive parts of a system from an architectural point of view. It looks like a technical concern, something you can solve once and move on from, but in reality it is deeply business-driven. Every permission encodes a rule about who is allowed to do what, when, and why, and those rules evolve as the business evolves.
Because of that, authorisation cuts across modules. It touches almost every request. It changes over time. And it has a habit of attracting shortcuts, especially under pressure. Those shortcuts often sound reasonable in the moment. Let’s just inject a permission service everywhere. We will centralise all checks in one place. It is fine if modules know about roles.
That is how you end up with a system where every module depends on a shared authorisation core. Permission logic gets smeared across handlers and services. Changing a single rule means touching half the codebase. The modules may still compile separately, but architecturally they are glued together, and the boundaries you thought you had are no longer doing any real work.
The First Principle: Authorisation Is Not Ownership
The biggest mental shift that makes this work is understanding that a module does not own authorisation. A module owns its rules. That distinction is subtle, but it is foundational.
Identity, tokens, and enforcement are cross-cutting concerns. They are about how a decision is applied and verified. Meaning is not cross-cutting. Meaning lives with the business capability that understands it. A Billing module knows what it means to issue an invoice. A Users module knows what it means to deactivate a user. A Claims module knows what it means to approve a claim.
If you centralise the meaning of permissions, you lose modularity instantly. The moment a single place decides what actions really mean across the system, every module starts to depend on that shared interpretation. Boundaries blur, ownership weakens, and what looked like a clean separation turns into another hidden point of coupling.
The Wrong Way (And Why It’s So Tempting)
Here’s the classic mistake.
You create something like:
public static class Permissions
{
public const string CreateUser = "users:create";
public const string DeleteUser = "users:delete";
public const string IssueInvoice = "billing:issue";
}
This pattern shows up quickly in otherwise well-structured systems. Someone introduces a shared permissions class with a set of constants, and every module references it. At first glance, it feels clean, centralised, and consistent. Everyone uses the same strings. There is one obvious place to look. It looks like order. In reality, every module now depends on a shared permissions model. The meaning of those permissions is no longer owned by the modules that enforce them, but by a central construct that everyone must agree on. When the semantics of a permission change, that change ripples outward across the entire system, whether the affected modules care about it or not.
What you have really created is a hidden coupling hub. The dependency may be subtle, but it is powerful. Modules that should be independent are now tied together through shared meaning and shared evolution. Architecturally, this is no better than a shared DbContext. It is just harder to see, and therefore easier to justify until the damage is already done.
Cross-Cutting
Cross-cutting does not mean globally defined. That is where a lot of designs quietly go wrong. Treating something as cross-cutting does not give it permission to own meaning for the entire system. In this context, cross-cutting means that enforcement is consistent. The mechanics of checking permissions, validating tokens, and rejecting unauthorised requests can and should be shared. Infrastructure can be reused. The plumbing does not need to be reinvented in every module.
Meaning, however, must stay local. Each module decides what a permission actually represents in terms of business behaviour. That distinction matters. When enforcement is shared but meaning is local, you get consistency without coupling, and that is the balance modular systems depend on.
The Shape of a Boundary-Respecting Authorization Model
Here’s the structure that holds up long-term:

In a modular system, each module declares its own permissions. It knows what those permissions mean, how they map to business rules, and when they should apply. Those permissions are used internally, close to the behaviour they protect, where their meaning is clear and unlikely to be misinterpreted.
The platform plays a different role. It authenticates users, resolves claims, and enforces checks in a consistent way. It provides the mechanism, not the meaning. No single module owns the whole picture, and thats by design. Meaning stays local to the module that understands it, while enforcement remains shared and predictable. That separation is what keeps authorisation powerful without turning it into another hidden source of coupling.
Permissions Are Part of the Use Case
A permission is not a generic rule. It’s part of a specific behaviour.
That means it belongs with the slice.
For example, in a Users module:
internal static class UserPermissions
{
public const string Create = "users:create";
public const string Deactivate = "users:deactivate";
}
Used directly in the slice that cares about it.
[Authorize(Policy = UserPermissions.Create)]
internal sealed class CreateUserEndpoint : IEndpoint
{
// endpoint mapping
}
This permission:
Is declared by Users
Is used by Users
Is meaningful only in Users
Billing doesn’t care. It shouldn’t even know it exists.
Policies Are Infrastructure, Not Business Logic
This is another common source of leakage.
Policies often become a dumping ground for logic:
services.AddAuthorization(options =>
{
options.AddPolicy("CanCreateUser", policy =>
policy.RequireClaim("role", "Admin"));
});
This approach looks innocent at first. Then roles change. Rules evolve. Context starts to matter. Before long, business logic is no longer living in the modules where it belongs, it is living in startup configuration and policy wiring, far away from the actual use cases that give it meaning. The better approach is a clean separation of responsibility. Policies should be mechanical. They exist to enforce checks, not to encode business rules. Meaning should live inside the modules, close to the behaviour it governs. Claims should represent capabilities, not roles or organisational structures.
A policy should answer a simple question:”Does this principal have permission X?” It should not be trying to answer something like “Is this user an admin in department Y during business hours?” That kind of logic depends on context, intent, and business rules, and it belongs near the use case where that context actually exists, not buried inside infrastructure configuration.
Claims as Capabilities, Not Roles
Roles are blunt instruments. They don’t scale well once systems grow.
Capabilities do.
Instead of:
Role: Admin
Think:
Permission: users:create
Permission: billing:issue
Permission: claims:approve
Your identity system emits capabilities.
Your modules decide what those capabilities mean.
This keeps the identity layer dumb and the domain layer expressive.
Avoiding the “Authorisation Service” Anti-Pattern
Another trap is the so-called “Authorisation Service”.
A shared service with methods like:
bool CanCreateUser(User user);
bool CanIssueInvoice(User user);
This kind of design often looks reusable at first glance. In reality, it is catastrophic for modularity. The problem is not the intent, it is the effect.
It centralises business rules that should belong to individual modules. It forces every module to depend on that central logic. And over time, it becomes a change bottleneck, because even small rule adjustments ripple through the entire system.
If a rule is about Users, it belongs in the Users module. If a rule is about Billing, it belongs in the Billing module. That alignment keeps ownership clear and change local.
Cross-cutting enforcement does not mean cross-cutting logic. Confusing the two is how systems quietly lose their modularity while still appearing well-structured on the surface.
Where Authorisation Checks Actually Belong
In a vertical-slice system, authorisation checks belong at the boundary of the slice. That is the point where intent is clearest, where you can say, “this operation is about to happen”, and make a deliberate decision about whether it should be allowed.
They should not be buried in repositories, where they become implicit and easy to bypass. They should not be hidden inside services, where they turn into invisible rules that only exist if you remember to call the right method. And they should not be scattered across helper utilities, where consistency depends on developer discipline. Put the check where the request enters the slice, at the point where the behaviour is named and explicit. That is where the system can tell the truth about what it is doing, and where authorisation can be enforced predictably without leaking into the wrong places.
public async Task<Result> Handle(
CreateUserCommand command,
ClaimsPrincipal principal,
CancellationToken stopToken)
{
if (!principal.HasPermission(UserPermissions.Create))
return Result.Forbidden();
// behaviour
}
That’s explicit. Honest. Local.
If the rule changes, you know exactly where to look.
Handling Shared Identity Without Shared Coupling
Identity is shared, and that part is not controversial. There is a single logged-in user and a single authentication story. But shared identity does not require shared interpretation. Each module maps that identity to meaning on its own terms, based on the business rules it owns. This is the difference between asking “who is this user?” and asking “what are they allowed to do here?” The first question is global and infrastructural. The second is local and contextual. Confusing the two is how modules start to leak meaning into places where it does not belong.
Authorisation mistakes rarely show up as obvious bugs. Instead, they surface as fear of change, overly defensive coding, centralised gatekeepers, and endless regressions. Teams slow down, not because the system is broken, but because nobody is confident that a change will not have unintended side effects. Systems that age well avoid that trap. They keep permission logic obvious, local, and boring. When authorisation is easy to understand and easy to change in one place, the system stays flexible, and the team keeps its momentum.
Up Next in the Series
The next inevitable question is:
How do you test all of this without writing brittle, end-to-end monsters or meaningless unit tests?
Testing modular monoliths requires a different mental model.





