Why Interfaces Are Common in .Net, but Not Always in Angular
A .Net developer recently asked me why Angular relies on classes instead of interfaces when wiring up dependency injection. Coming from the .Net world, that question makes a lot of sense — the standard advice there is to never instantiate a class directly, but rather to code against abstractions. I spent over eight years writing .Net code, though it's been more than a year since I last touched it on a regular basis. The example below, borrowed from Microsoft's documentation, illustrates the typical way of creating an instance — and the same pattern applies to Angular as well.
public class IndexModel : PageModel
{
MyDependency _dependency = new MyDependency();
public async Task OnGetAsync()
{
await _dependency.WriteMessage(
"IndexModel.OnGetAsync created this message.");
}
}
The .Net Approach to Dependency Injection
In .Net, the recommended flow for setting up dependency injection typically looks like this:
- Start by defining an interface.
public interface IMyDependency
{
Task WriteMessage(string message);
}
- Then, implement a service that uses that interface.
public class MyDependency : IMyDependency
{
private readonly ILogger<MyDependency> _logger;
public MyDependency(ILogger<MyDependency> logger)
{
_logger = logger;
}
public Task WriteMessage(string message)
{
_logger.LogInformation(
"MyDependency.WriteMessage called. Message: {MESSAGE}",
message);
return Task.FromResult(0);
}
}
- Next, register both the interface and its implementation in the DI container.
services.AddScoped<IMyDependency, MyDependency>();
- Finally, inject it into a consumer class, such as a controller.
public class IndexModel : PageModel
{
private readonly IMyDependency _myDependency;
public IndexModel(IMyDependency myDependency) {
_myDependency = myDependency;
}
public async Task OnGetAsync()
{
await _myDependency.WriteMessage(
"IndexModel.OnGetAsync created this message.");
}
}
The main benefit of structuring things this way is flexibility. If, down the line, you need to swap out MyDependency for an entirely different implementation, all you have to do is register the new class against the same interface. Every component that injects IMyDependency automatically gets the new instance, and no direct coupling ever forms between the service and the controller.
Angular's Dependency Injection Mechanism
Angular ships with its own built-in dependency injection container. Unlike the .Net example we examined earlier, there’s no separate step to register an interface against a concrete implementation.
In this walkthrough we focus on class-based providers. To scaffold a new service, execute the Angular CLI command below.
ng g service <service-name>
- The service itself is defined like this:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable({
providedIn: 'root',
})
export class LoginService {
constructor(public http: HttpClient) { }
login(user: any) {
console.log('login service called.');
}
}
Notice the login method is intentionally left as an empty placeholder—in a real application it would likely contain HTTP calls or other logic.
- Here’s how the service gets consumed inside a component:
import { Component } from '@angular/core';
import { LoginService } from './login.service';
@Component({
selector: 'app-login',
template: `<button (click)="login()">Login<button>`
})
export class LoginComponent {
constructor(loginService: LoginService ) {
}
login() {
this.loginService.login('test');
}
}
In that component, loginService is injected directly. But wait—we’re referencing a concrete class, not an interface as we would in .Net. If TypeScript already gives us interfaces, why aren't we using them here? And if we're tied to a class, is this really dependency injection? Let's dig into that question.
The Reason Interfaces Won't Work Here
Developers coming from a .Net background naturally wonder: since TypeScript supports interfaces, why not leverage them for DI instead of classes? A quick experiment will show why that approach is impossible.
- Start by installing TypeScript:
npm i typescript -g
Create a directory named
InterfaceDemoand populate it with a few files.Open that folder in VS Code and run the following to generate a
tsconfig.json:
tsc -init
Inside
tsconfig.json, set thetargetcompiler option toES2015.Add a new file called ILogin.ts and paste the code shown below:
interface ILogin {
login(user:any): any;
}
- From the terminal, run the next command:
tsc
- Once that finishes, open the generated ILogin.js file. You’ll find it contains… nothing.
"use strict";
What just happened? Does that mean everything written inside an interface simply disappears once the .js files are generated? Yes—exactly that. TypeScript’s interfaces exist purely for compile-time type checking; they vanish without a trace when the code is compiled to JavaScript. That’s the fundamental reason interfaces can’t serve as DI tokens in Angular.
So How Is This Still Dependency Injection?
Even though the component directly names the LoginService class, it isn’t tightly coupled to it. Let’s look at why.
- Notice that the component never instantiates
LoginServicewithnew. Instead, the instance is handed to it through the constructor—the same pattern we used with interfaces in .Net. Angular resolves the entire dependency graph automatically, including any nested dependencies like theHttpClientthatLoginServiceitself might need.
You might argue: "Sure, we’re not creating the instance ourselves, but what if I want to swap in a different service, say NewLoginService? Surely that requires editing the component?" Surprisingly, no. Here's how to do it without touching the component at all.
- Generate the new service with the CLI:
ng g service NewLogin
- Then replace the content of that new service with the following:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { LoginService } from './login.service';
@Injectable({
providedIn: 'root',
})
export class NewLoginService extends LoginService {
constructor(public http: HttpClient) { super(http); }
login(user: any) {
console.log('new login service called.');
}
}
Before making the provider change, run the app, open the console, and click the Login button—you should see the message
login service called.Next, open
app.module.tsand swap out the existingprovidersarray with this:
providers: [{ provide : LoginService , useClass : NewLoginService }]
- Re-run the application, click the Login button again, and check the console. This time it should print
new login service called.
With this simple provider swap, we’ve replaced the old implementation with a new one—without modifying a single line of component code. This is precisely how we control which service implementation the application uses.
There's another neat benefit: if the original service exposes ten methods but your new service only wants to override five, just implement those five in the new class. Any method not found in the new service will automatically fall back to the old one. Pretty slick, right?
Wrapping Up
A common pitfall for developers moving from C# or Java to Angular is getting too comfortable with TypeScript and assuming it behaves exactly like their previous language. While many C# concepts have TypeScript equivalents, I always recommend that developers also invest time in learning JavaScript itself. Spend some time experimenting with TypeScript and occasionally inspect the generated js files to see what’s really happening under the hood.
There’s a particularly good article on this topic by @layzee: https://dev.to/layzee/sorry-c-and-java-developers-this-is-not-how-typescript-works-401
