A service is a class that holds logic or state which does not belong to any one component. Dependency injection is how components get hold of one. It is the part of Angular that most repays understanding properly, because it is used everywhere and it is barely visible.
@Injectable({ providedIn: 'root' })
export class ApiService {
private readonly http = inject(HttpClient);
get<T>(path: string): Observable<T> {
return this.http.get<T>(path);
}providedIn: 'root'
Two things at once.
It registers the service with the root injector, so anything can inject it without a providers array anywhere.
It makes the service a singleton, and that is what makes shared state work.
CartService holds the cart in signals; every component that injects it gets the
same instance, so there is no provider component and no position in a tree to get right.
React's four nested providers in main.tsx have an order that matters whenever one
consumes another. Angular's four services do not.
It is also tree-shakeable: if nothing injects the service, it is not in the
bundle. That is the advantage over the older providers: [ApiService] on a module, where
registering a service guaranteed shipping it.
inject()
private readonly api = inject(ApiService);
private readonly menu = inject(MenuService);
private readonly destroyRef = inject(DestroyRef);inject() is the modern form. The older constructor-parameter style still
works:
constructor(private api: ApiService) {} // the old way; still validPrefer inject(), for a reason beyond taste: it works in places that have no
constructor to put a parameter in — functional guards, functional interceptors, and plain functions
like observedWidth. It also composes, which is what makes a helper function able to
inject DestroyRef on its caller's behalf.
The injection context
The one rule with teeth: inject() only works inside an injection
context — a field initialiser, a constructor, a factory, or a functional guard or
interceptor. Call it from a click handler or a setTimeout and it throws
NG0203.
In practice this is not a constraint, because injecting into a field is what you want anyway.
When you genuinely need it later, capture inject(Injector) early and use
runInInjectionContext.
What a service is for
The pizza app's services divide cleanly, and the division is worth copying.
ApiService is stateless — a typed wrapper over
HttpClient. AuthService, CartService,
MenuService and ToastService are stateful: they hold
signals and are the reason those signals are shared.
readonly isAuthenticated = computed(() => this._user() !== null);
readonly isAdmin = computed(() => this._user()?.role === 'ADMIN');Note what that gives you. A component asks auth.isAdmin() and gets an answer
derived from one source of truth, rather than each component keeping its own copy of "am I an
admin".
The injector hierarchy
Providers form a tree. When a component asks for a dependency, Angular looks in that component's injector, then its parent's, and up to the root — the first match wins.
providedIn: 'root' puts one instance at the top, which is what you want for
almost everything. Providing a service on a route gives every component under that
route its own instance, destroyed when you navigate away. The pizza app uses exactly that for NgRx:
the store's feature slices are provided on the /admin route, so they ship in the lazy
admin chunk instead of in everyone's bundle.
Providing a service on a component gives each instance of that component its own copy — occasionally right, usually a surprise for anyone expecting shared state.
Injecting something that is not a class
DI keys on the type. A string or a config object has no type to key on, so it needs an
InjectionToken:
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');
// provide it
{ provide: API_BASE_URL, useValue: 'http://localhost:8085' }
// inject it
private readonly baseUrl = inject(API_BASE_URL);The pizza app has none, and the reason is instructive: its one piece of configuration is a build-time constant, so it is a plain import instead.
export const environment = {
production: true,
apiBaseUrl: 'http://localhost:8085',The CLI swaps this file for environment.development.ts via
fileReplacements in angular.json. A token would buy the ability to swap the
value at runtime or in a test — worth it when you need it, ceremony when you do not.
⚠️ Everything in that file ships to the browser. Only the publishable Stripe key belongs there.
Why this matters for testing
Because dependencies arrive through the injector rather than being constructed, a test can replace one:
TestBed.configureTestingModule({
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
{ provide: MenuService, useValue: menuServiceStub([PEPPERONI, PEPSI]) },
],
});That is the menu page's own spec swapping the real MenuService for a stub. No
mocking library, no module interception — just a different provider for the same token.
What is next
The service every other service is built on: HttpClient, and the
httpResource that replaces most of the code you would write around it.