Understanding the Factory Pattern in Angular

Modern Angular applications often require forms that adapt to different user roles, data schemas, or configuration environments. Manually creating FormGroup instances for every permutation leads to duplicated code, fragile switch cases, and tangled validation logic. The Factory Pattern addresses this by providing a centralized mechanism to produce form objects based on a defined type or configuration. In essence, the factory acts as a form-building orchestrator: it accepts a specification (e.g., a string, an enum, or a configuration object) and returns a fully initialized FormGroup with the appropriate controls, validators, and default values.

The factory encapsulates the instantiation logic, so components no longer need to know which form class to instantiate or how to assemble the controls. This separation of concerns makes the codebase more modular, testable, and scalable — especially when the number of form types grows beyond a handful. The pattern fits naturally into Angular’s dependency injection system, allowing you to register factory functions as providers and inject them where needed.

Why Use the Factory Pattern for Dynamic Forms?

Angular already provides the ReactiveFormsModule to handle complex form scenarios, but the factory pattern eliminates the procedural boilerplate that often accompanies form creation. Here are the primary advantages:

  • Reduces code duplication. Instead of repeating similar FormGroup constructions across components or services, you centralize the logic in one factory method. Each form type is built by a dedicated builder class, so you write its logic only once.
  • Enhances maintainability. Adding a new form type means creating a new builder class and registering it in the factory. No existing components need to change, as long as they request forms by type.
  • Allows flexible form configurations. The factory can accept additional parameters (e.g., user permissions, locale, or feature flags) that influence which controls are included or how they are validated.
  • Supports dynamic user interfaces. When the form type is determined at runtime (for example, from a database record or a route parameter), the factory makes it trivial to instantiate the correct FormGroup on the fly.
  • Improves testability. Each form builder can be unit-tested independently. The factory itself becomes a simple mapping that can be mocked or stubbed in component tests.

These benefits are especially valuable in enterprise applications that must support many distinct workflows, each with its own set of fields and validation rules.

Core Implementation Steps

Step 1: Define Form Type Enumeration and Interfaces

Start by defining a set of form types. Using an enum prevents typos and provides a clear contract:

export enum FormType {
  Login = 'login',
  Registration = 'registration',
  Feedback = 'feedback',
  Contact = 'contact'
}

Next, define an interface that all form builders will implement. This ensures the factory can treat them polymorphically:

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

export interface FormBuilderInterface {
  build(config?: any): FormGroup;
}

The optional config parameter allows builders to customize the form based on runtime data, such as pre‑filling values or adjusting validation rules per user role.

Step 2: Create Concrete Form Builders

Each concrete builder implements FormBuilderInterface and returns a properly configured FormGroup. For example, a login form builder might look like this:

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

export class LoginFormBuilder implements FormBuilderInterface {
  private fb: FormBuilder;

  constructor() {
    this.fb = new FormBuilder();
  }

  build(): FormGroup {
    return this.fb.group({
      email: ['', [Validators.required, Validators.email]],
      password: ['', [Validators.required, Validators.minLength(6)]],
      rememberMe: [false]
    });
  }
}

A registration form builder might include additional controls and cross‑field validation:

export class RegistrationFormBuilder implements FormBuilderInterface {
  private fb: FormBuilder;

  constructor() {
    this.fb = new FormBuilder();
  }

  build(): FormGroup {
    const form = this.fb.group({
      firstName: ['', Validators.required],
      lastName: ['', Validators.required],
      email: ['', [Validators.required, Validators.email]],
      password: ['', [Validators.required, Validators.minLength(8)]],
      confirmPassword: ['', Validators.required]
    }, { validators: this.matchPasswords });

    return form;
  }

  private matchPasswords(group: FormGroup): { [key: string]: any } | null {
    const password = group.get('password')?.value;
    const confirm = group.get('confirmPassword')?.value;
    return password === confirm ? null : { mismatch: true };
  }
}

This structure scales cleanly: each builder focuses solely on its own form’s details, and you can reuse logic by composing builders or using helper functions.

Step 3: Build the Factory Class

The factory class is responsible for mapping a form type to its corresponding builder. A simple implementation uses a switch statement or a map object:

import { Injectable } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { FormType } from './form-type.enum';
import { LoginFormBuilder } from './login-form.builder';
import { RegistrationFormBuilder } from './registration-form.builder';
import { FeedbackFormBuilder } from './feedback-form.builder';

@Injectable({ providedIn: 'root' })
export class FormFactory {
  private builders = new Map<FormType, FormBuilderInterface>();

  constructor() {
    // Register builders
    this.builders.set(FormType.Login, new LoginFormBuilder());
    this.builders.set(FormType.Registration, new RegistrationFormBuilder());
    this.builders.set(FormType.Feedback, new FeedbackFormBuilder());
  }

  createForm(type: FormType, config?: any): FormGroup {
    const builder = this.builders.get(type);
    if (!builder) {
      throw new Error(`No builder registered for form type: ${type}`);
    }
    return builder.build(config);
  }
}

Registering builders inside the constructor is straightforward, but for larger applications you might prefer using Angular’s dependency injection to provide builders. That approach is covered later.

Step 4: Use the Factory in Components

With the factory in place, components can request a form instance without knowing the details of its construction. For example, a component that handles multiple form types based on a route parameter:

import { Component, OnInit } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { FormFactory } from './form.factory';
import { FormType } from './form-type.enum';

@Component({
  selector: 'app-dynamic-form',
  template: `
    
` }) export class DynamicFormComponent implements OnInit { form!: FormGroup; formType!: FormType; constructor(private formFactory: FormFactory) {} ngOnInit(): void { // Let's say formType is set from route data or user selection this.form = this.formFactory.createForm(this.formType); } }

This component is now effectively decoupled from the form creation logic. Changing the validation rules for the login form requires only editing the LoginFormBuilder class.

Handling Complex Scenarios

Conditional Fields and Validation

Real‑world forms often need to show or hide fields based on other fields’ values. For example, a feedback form might display an upload field only when the user selects “attachment” as a category. The factory pattern accommodates this by allowing builders to attach dynamic validators or use Angular’s valueChanges observable within the builder itself:

export class FeedbackFormBuilder implements FormBuilderInterface {
  private fb: FormBuilder;

  constructor() {
    this.fb = new FormBuilder();
  }

  build(): FormGroup {
    const form = this.fb.group({
      category: ['', Validators.required],
      subject: ['', Validators.required],
      message: ['', [Validators.required, Validators.maxLength(500)]],
      attachment: [null]
    });

    // Conditionally require attachment
    form.get('category')?.valueChanges.subscribe(category => {
      const attachmentControl = form.get('attachment');
      if (category === 'attachment') {
        attachmentControl?.setValidators([Validators.required]);
      } else {
        attachmentControl?.clearValidators();
      }
      attachmentControl?.updateValueAndValidity();
    });

    return form;
  }
}

This keeps the conditional logic within the builder, so the consuming component remains simple.

Dynamic Field Sets from Configuration

Sometimes the form structure itself must be determined at runtime, based on a configuration object (e.g., a JSON schema). In that case, the factory can accept a configuration object and delegate to a generic builder that iterates over the configuration:

export class ConfigDrivenFormBuilder implements FormBuilderInterface {
  private fb: FormBuilder;

  constructor() {
    this.fb = new FormBuilder();
  }

  build(config: FieldConfig[]): FormGroup {
    const group: { [key: string]: any } = {};
    config.forEach(field => {
      group[field.name] = [field.defaultValue || '', field.validators || []];
    });
    return this.fb.group(group);
  }
}

This generic builder can be registered under a special form type (e.g., FormType.ConfigDriven) or used directly from the factory when a configuration object is provided.

Using Angular’s FormArray

When a form must handle a variable number of repeated sub‑forms (e.g., a list of participants in an event registration), the factory can return a FormGroup that includes a FormArray. The builder for such a form might expose a method to add rows dynamically:

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

export class EventRegistrationFormBuilder implements FormBuilderInterface {
  build(): FormGroup {
    const fb = new FormBuilder();
    return fb.group({
      eventName: ['', Validators.required],
      participants: fb.array([])
    });
  }

  createParticipant(): FormGroup {
    const fb = new FormBuilder();
    return fb.group({
      name: ['', Validators.required],
      email: ['', [Validators.required, Validators.email]],
      age: ['', [Validators.min(0)]]
    });
  }
}

The factory returns only the root FormGroup. The component can then use the builder’s createParticipant() method when the user clicks “Add participant”.

Integrating with Angular Components

Creating a Dynamic Form Component

A dynamic form component can render fields automatically based on the FormGroup structure. This component receives the form instance from the factory and generates the template using formControlName directives. A minimal implementation might look like:

import { Component, Input } from '@angular/core';
import { FormGroup } from '@angular/forms';

@Component({
  selector: 'app-dynamic-form',
  template: `
    
Invalid field
` }) export class DynamicFormComponent { @Input() form!: FormGroup; onSubmit(): void { if (this.form.valid) { console.log('Form submitted', this.form.value); } } }

For production use, you would want to map control types (text, email, select, etc.) from a metadata object. This metadata can be returned by the builder alongside the FormGroup, or derived from the control configuration.

Passing Data and Resolving Types

The factory’s power shines when the form type is determined by external data — for example, a user’s profile type or a workflow step. In the parent component, you can retrieve the desired form type from a service or a data store and pass it to the factory:

this.userService.getUserProfile().subscribe(profile => {
  const formType = profile.type === 'admin' ? FormType.AdminConfig : FormType.UserConfig;
  this.form = this.formFactory.createForm(formType, profile.preferences);
});

This pattern keeps the component logic agnostic to the form structure, making it easy to introduce new profiles without touching existing code.

Advanced: Dependency Injection and Factory Providers

Instead of manually instantiating builders in the factory constructor, you can leverage Angular’s dependency injection to provide builders via a multi‑provider token. This makes the factory extensible without modifying its code. First, create an injection token for form builders:

import { InjectionToken } from '@angular/core';
import { FormBuilderInterface } from './form-builder.interface';

export const FORM_BUILDER = new InjectionToken<FormBuilderInterface>('FORM_BUILDER');

Each builder becomes an Angular service, and you provide them with a specific form type key. For example:

import { Injectable } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { FormBuilderInterface } from './form-builder.interface';
import { FormType } from './form-type.enum';

@Injectable()
export class LoginFormBuilderService implements FormBuilderInterface {
  readonly type = FormType.Login;
  private fb: FormBuilder;

  constructor() {
    this.fb = new FormBuilder();
  }

  build(): FormGroup {
    return this.fb.group({
      email: ['', [Validators.required, Validators.email]],
      password: ['', [Validators.required, Validators.minLength(6)]]
    });
  }
}

Now you can use a factory provider to inject all builders and register them in a Map:

import { Injectable, Inject, Optional } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class FormFactory {
  private builders = new Map<FormType, FormBuilderInterface>();

  constructor(@Optional() @Inject(FORM_BUILDER) builders: FormBuilderInterface[] = []) {
    builders.forEach(builder => {
      if (builder.type) {
        this.builders.set(builder.type, builder);
      }
    });
  }

  createForm(type: FormType, config?: any): FormGroup {
    const builder = this.builders.get(type);
    if (!builder) {
      throw new Error(`No builder found for form type: ${type}`);
    }
    return builder.build(config);
  }
}

In your module or component, you then provide the builders:

import { NgModule } from '@angular/core';
import { FORM_BUILDER } from './form-builder.token';
import { LoginFormBuilderService } from './login-form-builder.service';
import { RegistrationFormBuilderService } from './registration-form-builder.service';

@NgModule({
  providers: [
    { provide: FORM_BUILDER, useClass: LoginFormBuilderService, multi: true },
    { provide: FORM_BUILDER, useClass: RegistrationFormBuilderService, multi: true }
  ]
})
export class DynamicFormModule { }

With this DI‑based approach, adding a new form type is as simple as creating a new service and registering it in the providers array — no changes to the factory class.

Testing the Factory Pattern

One of the pattern’s biggest strengths is testability. Each builder can be tested in isolation. For example, a test for the RegistrationFormBuilder might verify that the form has the expected controls and that the password mismatch validation works:

import { TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { RegistrationFormBuilder } from './registration-form.builder';

describe('RegistrationFormBuilder', () => {
  let builder: RegistrationFormBuilder;

  beforeEach(() => {
    TestBed.configureTestingModule({ imports: [ReactiveFormsModule] });
    builder = TestBed.inject(RegistrationFormBuilder);
  });

  it('should create a form with required controls', () => {
    const form = builder.build();
    expect(form.contains('firstName')).toBeTrue();
    expect(form.contains('email')).toBeTrue();
    expect(form.contains('password')).toBeTrue();
  });

  it('should mark form as invalid when passwords do not match', () => {
    const form = builder.build();
    form.patchValue({
      password: '12345678',
      confirmPassword: 'different'
    });
    expect(form.invalid).toBeTrue();
    expect(form.errors?.['mismatch']).toBeTrue();
  });
});

The factory itself can be tested by mocking the builders and verifying that it delegates to the correct builder for each form type. This isolation keeps tests fast and focused.

Alternative Approaches

The Factory Pattern is not the only way to handle dynamic forms in Angular. Other viable strategies include:

  • Template‑driven forms with dynamic directives. Use Angular’s *ngSwitch or dynamic component loading to swap out different form templates. This can be simpler for small numbers of form types but quickly becomes unwieldy.
  • Schema‑based form generation. Libraries like @angular‑components or custom implementations that parse a JSON schema and generate a form recursively. This approach is highly data‑driven but can be difficult to customize for complex validation or UI.
  • Service‑oriented form providers. Rather than a factory, you inject a service that knows how to return forms based on a key. This is essentially a variation of the factory but without the explicit pattern structure.

The Factory Pattern sits comfortably between these extremes. It provides enough structure to keep the code organized without imposing the overhead of a full schema engine.

Conclusion

Implementing the Factory Pattern for dynamic form generation in Angular gives you a scalable, maintainable, and testable approach to managing form creation. By isolating each form’s construction logic into a dedicated builder, you eliminate duplication and make your application resilient to change. Whether you are building a multi‑workflow dashboard or a form‑heavy data entry system, the factory pattern offers a clean separation of concerns that pays dividends as the application grows.

For further reading, consult the Angular documentation on reactive forms and the Factory Method pattern description on Refactoring Guru. For more advanced patterns in Angular, consider exploring Angular University’s module guides.