When repetitive code starts appearing across an application, the first solution that often comes to mind is inheritance. And it is a valid solution — it does remove duplication and works as expected. Yet problems surface when components become tightly coupled to the base class through the constructor, or when inheritance forces us to pass values that have nothing to do with the subclass itself.

The walkthrough below uses forms as the context, but the underlying concern applies to Angular components in general: inheritance should be used with care.

Starting scenario

Imagine we are working for 'this_is_angular' and need to build a newsletter signup form. It seems straightforward: create a NewsLetterComponent, inject the form builder, and add two methods — one to display validation errors and one to submit the data.

The complete working example can be found at https://stackblitz.com/edit/angular-ivy-a4adjr.

Our newsletter component is implemented as:

import { Component, OnInit } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';

@Component({
  selector: 'app-newsletter',
  templateUrl: './newsletter.component.html',
})
export class NewsletterComponent implements OnInit {
  errors = [];
  newsLetterForm = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
  });

  constructor(private fb: FormBuilder) {}

  save() {
    if (!this.newsLetterForm.valid) {
      this.showErrors();
    } else {
      this.errors = [];
      console.log('saving data')
    }
  }

  showErrors() {
    const emailError = this.newsLetterForm.get('email').errors;
    console.log(emailError);
    Object.keys(emailError).forEach((value) => {
      this.errors = [...value];
    });
  }
}

And its template looks like:

<form [formGroup]="newsLetterForm" (ngSubmit)="save()">
  <h1>Newsletter</h1>
  <input type="text" formControlName="email" />
  <button>Save</button>
  <span *ngFor="let error of errors">{{error}}</span>
</form>

A week passes, and a new requirement arrives: a waiting list form. It is remarkably similar to the newsletter form — validate an email address, show any errors, and send the data.

So we build another form with the same structure: one field, one validation rule, and a submit action.

import { Component, OnInit } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';

@Component({
  selector: 'app-waiting-list',
  templateUrl: './waiting-list.component.html',
})
export class WaitingListComponent  {
  errors = [];
  waitingListForm = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
  });

  constructor(private fb: FormBuilder) {}

  save() {
    if (!this.waitingListForm.valid) {
      this.showErrors();
    } else {
      this.errors = [];
      console.log('saving data!');
    }
  }

  showErrors() {
    const emailError = this.waitingListForm.get('email').errors;
    console.log(emailError);
    Object.keys(emailError).forEach((value) => {
      this.errors = [...value];
    });
  }
}

<form [formGroup]="waitingListForm" (ngSubmit)="save()">
  <h1>Waiting list</h1>
  <input type="text" formControlName="email" />
  <button>Save</button>
  <span *ngFor="let error of errors">{{ error }}</span>
</form>

Later that day, @bezael points out that a password recovery form might also be needed soon — and that all these components are quite similar, with the same duplicated logic.

My clever solution to eliminate the duplication and make the code more predictable is to introduce a BaseForm class that holds the common field declaration and shared methods, then have each form component extend this base class.

We make the base class generic by naming the form control myform and moving the reusable methods into it:

import { FormBuilder, Validators } from '@angular/forms';

export class BaseForm {
  errors = [];
  myform = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
  });

  constructor(private fb: FormBuilder) {}
  save() {
    if (!this.myform.valid) {
      this.showErrors();
    } else {
      this.errors = [];
      console.log('saving data!');
    }
  }

  showErrors() {
    const emailError = this.myform.get('email').errors;
    console.log(emailError);
    Object.keys(emailError).forEach((value) => {
      this.errors = [...value];
    });
  }
}

Next, we refactor both existing form components to extend the base class. They call the superclass constructor and pass along the form builder dependency.

All duplicated code is gone — each subclass uses the myform field and the inherited methods, and everything works without further effort.

import { Component, OnInit } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { BaseForm } from '../../core/baseForm';

@Component({
  selector: 'app-newsletter',
  templateUrl: './newsletter.component.html',
})
export class NewsletterComponent extends BaseForm {
  constructor(public fb: FormBuilder) {
    super(fb);
  }
}

<form [formGroup]="myform" (ngSubmit)="save()">
  <h1>Newsletter</h1>
  <input type="text" formControlName="email" />
  <button>Save</button>
  <span *ngFor="let error of errors">{{ error }}</span>
</form>

The waiting list component gets the same treatment, and the password recovery form can now be created quickly since all the required fields and logic are already inherited.

import { Component, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { BaseForm } from '../../core/baseForm';

@Component({
  selector: 'app-recovery-password',
  templateUrl: './recovery-password.component.html',
  styleUrls: ['./recovery-password.component.css'],
})
export class RecoveryPasswordComponent extends BaseForm {
  constructor(public fb: FormBuilder) {
    super(fb);
  }
}
<form [formGroup]="myform" (ngSubmit)="save()">
  <h1>Recovery password</h1>
  <input type="text" formControlName="email" />
  <button>Save</button>
  <span *ngFor="let error of errors">{{ error }}</span>
</form>

At this point, I feel quite confident — almost unstoppable — knowing that any new form can be built quickly with this setup.

The challenge

As is typical in a developer's routine, requirements evolve and new demands appear. The business now wants the recovery and waiting-list components to include tracking via analytics.
Since this affects two components, my plan is to add these methods and the HTTP dependency to the superclass.

Adjust the constructor and introduce the sendToAnalytics method.

constructor(public fb: FormBuilder, public http: HttpClient) {}

  sendToAnalytics() {
    return this.http
      .post<any>('google.analytics.fake', { value: 'tracking' })
      .subscribe(() => {
        console.log('tracking');
      });
  }
Enter fullscreen mode Exit fullscreen mode

Given the changes to the base class, the recovery and waiting-list components need to be updated to supply the new parameters required by the FormBase class.

 constructor(public fb: FormBuilder, public http: HttpClient) {
    super(fb, http);
    this.sendToAnalytics();
  }
Enter fullscreen mode Exit fullscreen mode

The newsletter component also has to pass the new parameter, as it inherits from baseForm.

 constructor(public fb: FormBuilder, public http: HttpClient) {
    super(fb, http);
  }
Enter fullscreen mode Exit fullscreen mode

Something feels off...

  • Why would the newsletter component need to inject a dependency that is unrelated to it?

  • Why does any modification in the base class affect my components?

  • Why do my components require so many constructor parameters when they don't need them?

  • What if, in the future, the base class needs something specific to the waiting list, such as calling another service or logging a different console message?

Learn more about the Constructor Over-injection code smell

  constructor(
    public fb: FormBuilder,
    public http: HttpClient,
    private log: string
  ) {
    console.log(this.log);
  }
Enter fullscreen mode Exit fullscreen mode
 super(fb, http, 'HELLO');
Enter fullscreen mode Exit fullscreen mode

Every component that extends the base form now has to provide all these parameters to the superclass, and we start to encounter these issues during testing, where we have to mock dependencies that aren't actually used in our component.

The tests will expose bad design quickly @Michael Karén

What went wrong, and what are my options?

The initial approach was to reuse the business code through inheritance, extending my class. It seemed that inheritance would lead to simpler maintenance.

 Understanding inheritance

Inheritance defines an is a relationship between classes, where the subclass inherits from the superclass. A common example found online is animal -> dog.

Implementing inheritance is straightforward and is a core OOP concept, which makes reuse in the subclass easy. The superclass constructor is exposed to the subclass, creating a tightly coupled relationship. As a result, any modification to the superclass has a ripple effect on the child class.

This also affects testing; when the base changes, the component changes, and the tests need to be updated accordingly.

Inheritance shouldn't be the first tool in our toolbox, it should be the last. @Lars Gyrup Brink Nielsen

What is composition?

The primary difference between inheritance and composition lies in the object has an relationship, using a reference to a field, without needing to know how it's built or what it requires to be ready.

class Helper  {
   form: BaseForm
   errors: Error
}
Enter fullscreen mode Exit fullscreen mode

An alternative is to use an interface for these fields and apply dependency inversion to decouple from the concrete implementation. This allows for runtime changes, swapping it with another object dynamically.

The creation process is hidden in composition; it's only accessible through methods or fields, and we can change the implementation without breaking our code.

How do we address the current issue?

First, we need to identify what our forms require.

  • A form.
  • A list of errors.
  • Recovery and waiting-list components need to track with analytics.

We can create a service to handle the form creation and provide two fields and methods for saving and tracking with analytics.

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { BaseForm } from './baseForm';

@Injectable()
export class FormWrapperService {
  public myform: FormGroup;

  public get errors(): string[] {
    return this._baseForm.errors;
  }
  private _baseForm: BaseForm;

  constructor(private fb: FormBuilder, private http: HttpClient) {
    this._baseForm = new BaseForm(this.fb, this.http, 'A');
    this.myform = this._baseForm.myform;
  }
  save(form: FormGroup): boolean {
    this._baseForm.myform = form;
    this._baseForm.save();
    return this._baseForm.errors.length === 0;
  }
}

Enter fullscreen mode Exit fullscreen mode

Next, inject the service into the component and connect the waiting-list component's fields with the business logic encapsulated in the service.

import { Component } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { FormWrapperService } from '../../core/form-wrapper.service';

@Component({
  selector: 'app-waiting-list',
  templateUrl: './waiting-list.component.html',
})
export class WaitingListComponent {
  myform: FormGroup;
  errors = [];
  constructor(private formWrapper: FormWrapperService) {
    this.myform = formWrapper.myform;
  }
  save() {
    if (!this.formWrapper.save(this.myform)) {
      this.errors = this.formWrapper.errors;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

What do we gain from this?

Our components are no longer directly tied to baseForm; they still reuse the business logic but also:

  • If an extra dependency is needed in _baseForm later, my components are unaffected.

  • The test for the waiting-list component only needs a form group; it doesn't care about its origin.

  • We only expose the methods pertinent to our use case, not all the business logic.

This approach can be applied consistently across all components, allowing us to clean up the constructor by relying solely on the service.

A Corner Case

When the business asks for a variation — say, sending form data to a different endpoint while logging Spanish-language errors — the initial instinct might be to add another method or parameter. That approach, however, starts down a slippery slope of conditional logic.

A cleaner alternative is to remove the direct service dependency from the component entirely and rely on an abstract class. This way, each concrete implementation can handle its own specifics, leaving the component open for future adaptations.

Start by defining an abstract class that captures the contract for the fields and methods that matter to the component.

import { FormGroup } from '@angular/forms';

export abstract class AbstractFormWrapper {
  abstract myform: FormGroup;
  abstract errors: string[];
  abstract save(form: FormGroup): boolean;
}

Since the existing FormWrapperService already conforms to this abstract contract, adjust its signature accordingly. The concrete class remains unchanged in behavior, but it now explicitly implements the abstract contract.

export class FormWrapperService implements AbstractFormWrapper

Next, introduce a new service, FormWrapperTrackingService, that implements the same abstract class. This service can carry all the custom logic requested by the business, such as the Spanish errors and the alternate endpoint.

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { AbstractFormWrapper } from './abstract-form-wrapper';
import { BaseForm } from './baseForm';

@Injectable()
export class FormWrapperTrackingService implements AbstractFormWrapper {
  private _anotherBaseForm: BaseForm;
  myform: FormGroup;
  public get errors(): string[] {
    return this.translationToSpanish();
  }
  constructor(private fb: FormBuilder, private http: HttpClient) {
    this._anotherBaseForm = new BaseForm(this.fb, this.http, 'A');
    this.myform = this._anotherBaseForm.myform;
  }

  save(form: FormGroup): boolean {
    this._anotherBaseForm.myform = form;
    this._anotherBaseForm.save();
    console.log('sending data to another service');
    return this._anotherBaseForm.errors.length === 0;
  }

  private translationToSpanish(): string[] {
    return this._anotherBaseForm.errors.map((a) => {
      return this.translate(a);
    });
  }

  private translate(string) {
    return 'Un error';
  }
}

Because this new service also aligns with the abstract contract, simply update the constructor signature in the targeted component. The component no longer needs to know which implementation it’s receiving — it just needs something that matches the contract.

Register this new provider at the component level to constrain the service instance to that component's scope. Other components continue using the original FormWrapperService; their constructor signatures remain the same, and they won't be affected by the new implementation.

@Component({
  selector: 'app-waiting-list',
  templateUrl: './waiting-list.component.html',
  providers: [
    {
      provide: AbstractFormWrapper,
      useClass: FormWrapperService,
    },
  ],
})
export class WaitingListComponent {
  myform: FormGroup;
  errors = [];
  constructor(private formWrapper: AbstractFormWrapper) {
    this.myform = formWrapper.myform;
  }

Finally, go through the remaining components and keep them pointed at the initial FormWrapperService. Because the abstract signature is shared, swapping implementations in the future won't require any changes in those components.

@Component({
  selector: 'app-newsletter',
  templateUrl: './newsletter.component.html',
  providers: [
    {
      provide: AbstractFormWrapper,
      useClass: FormWrapperService,
    },
  ],
})

Further reading on providers: https://angular.io/guide/providers

Closing Thoughts

This example ran long, not to suggest that inheritance is inherently wrong — it has its place. The point is that composition gives your components room to evolve without breaking existing consumers. That flexibility alone often makes it the better choice.

The complete source code is available in this Github Repository.

To sum up, keep these principles in mind:

  • Inheritance reuses code and is easy to follow, but it creates tight coupling — changes to a base class ripple through every subclass.
  • Prefer inheritance for services; avoid it for components where composition is far more practical.
  • Composition keeps code reusable, flexible, and loosely coupled, making future changes safer and simpler.
  • Reduce direct ties to concrete implementations by coding against abstractions, such as interfaces or abstract classes.

If you find yourself wrestling with the same kind of rigid design, refactoring is a worthwhile path. The following resources have been helpful to me and are well worth your time: