The guard had been the same eleven lines for years. A class, an @Injectable(), a constructor that took the auth service and the router, and a canActivate that asked one question: is this person signed in, and if not, send them to the login screen. It was the least interesting file in the admin app. Nobody had needed to open it.
Then I was most of the way through pulling the last NgModules out of that app, and the guard was next on the list. Functional guards are what the routing docs lead with now, so I did the obvious thing — deleted the class, kept the logic, exported a CanActivateFn. Call it thirty seconds of typing.
Then I sat looking at a function that needed two services and had nowhere to put them.
My reflex was to add a parameter. That reflex is wrong, and it's wrong in a way worth naming: the router owns the call. It invokes your guard with the activated route and the router state, and that signature isn't a suggestion. You don't get to widen it to take an auth service, because you are not the one calling the function. For most of my Angular career, "how does this thing get its dependencies" had exactly one answer — put them in the constructor — and I had just deleted the only constructor in the file.
A function has no constructor, so the injector has to be reachable from inside
Constructor injection always looked like the framework handing you your dependencies. It's more accurate to say the framework was resolving them from an injector at the moment it built your class, and the constructor was just the delivery mechanism. inject() keeps the resolution and drops the delivery. It reads from whichever injector is active right now, at the moment the call runs.
That "right now" is the whole idea, and it's called the injection context. Angular's docs list the places that qualify: the constructor of a class the DI system instantiates, the field initializers of such a class, a useFactory function, an InjectionToken's factory — and, the one that matters here, a stackframe of a function call made while a DI context is active. The router activates a route's injector and then calls your guard. So a functional guard doesn't need a constructor. It's already standing inside the context that a constructor merely used to represent.
Which makes the conversion smaller than my hesitation suggested:
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isSignedIn()) return true;
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
Used on the route the same way the class was, minus the registration:
{
path: 'admin',
canActivate: [authGuard],
loadComponent: () => import('./admin/admin').then((m) => m.Admin),
}
And a signed-out visitor who types /admin lands on /login?returnUrl=%2Fadmin — same redirect, same query parameter, one file instead of a class plus a provider. The part I hadn't expected was what this does to composition. Because a function that calls inject() fetches its own dependencies, a helper can fetch its own dependencies too — so the useful unit of reuse stops being a class and becomes a plain function that nobody has to wire up:
// no parameters to thread; it reaches for what it needs
const hasRole = (role: string) => inject(AuthService).roles().includes(role);
export const adminGuard: CanActivateFn = () =>
hasRole('admin') || inject(Router).createUrlTree(['/forbidden']);
hasRole works because the guard calls it synchronously, so it runs inside the same stackframe and the same live context. Move that call behind a setTimeout, a .then(), or anything after an await, and the context is gone by the time it runs — Angular throws the inject() must be called from an injection context error, and it throws at runtime, not at build time. This is the one genuinely sharp edge of the pattern, and it's sharpest in async guards: resolve your dependencies at the top of the function, before the first await, and the whole class of problem disappears. Grab your services while you're standing in the room. Don't walk out and expect them to follow.
The class-based guard didn't stop working, and that matters to the decision
It would be tidier to say the old way is gone. It isn't. CanActivate is still a supported interface, not a deprecated one, and a class guard registered as a provider works today exactly as it did three years ago. Anyone telling you a migration is forced here is overstating it. So the honest comparison is about fit, not survival.
A class earns its place when the guard has real collaborators or internal state to hold — a permissions cache, a subscription it manages, several methods that share private helpers. It's also the shape most teams already know how to test, and there's a bridge if you want the new registration without rewriting the logic: a functional guard can simply call the class through the injector, inject(PermissionsGuard).canActivate(route, state), which is a genuinely good move for a guard that's large and well tested. What a class costs, for the simple case, is ceremony — a decorator, a provider, and a file whose class exists only to give one boolean somewhere to live.
Constructor injection isn't really the losing option in this comparison, because it isn't in the same category. For components, services, and directives it remains the right default, and I still write it there. It just has nothing to offer a standalone exported function; the constructor can't be the door when there's no room behind it.
The alternative I'd actually warn someone off is the clever one. Faced with a fixed signature, the instinct is to close over the dependencies instead — write a factory like authGuard(authService) that returns a CanActivateFn, and pass the service in. It reads well in isolation. But route configuration is evaluated when the module or route file loads, and at that moment there is no injector to ask for the service, so you find yourself either constructing the service by hand or threading it down from wherever one happens to be available. You end up hand-rolling a worse version of the thing the framework already does. That's the trap: it looks like dependency injection and it's actually manual wiring with a nicer signature.
For the everyday guard — one question, one or two services, no state — inject() is the recommendation without much qualification. It removes the class, keeps the dependencies honest and mockable through TestBed, and makes the reusable unit a function instead of a hierarchy.
The Constructor Was Never the Point
What I had mistaken for the mechanism turned out to be the packaging. Dependency injection was never about constructors; it was about a resolvable injector and code positioned where it can reach one. The constructor was one reliable way to be in that position, and for years it was the only one I needed, so I stopped seeing it as a choice at all. inject() didn't add a feature so much as reveal what the feature had been the whole time — which is why the smallest guards, the interceptors, and the helpers that were never worth a class of their own suddenly had somewhere to live. Know where the injection context begins and ends, and dependency injection stops being a thing you declare at the top of a class and becomes a thing you can call.


