Reading Time: 4 minutes
In the previous post, we introduced the architectural challenges behind supporting multiple chart types within a scalable dashboard solution. We established that different visualisation libraries require data in different structural formats. This mismatch makes the transformation layer a critical part of the architecture.

In this post, we focus on how the system decides which transformation logic to apply for a given chart type. Two design patterns make this possible: Strategy and Factory. Together, they allow us to select and execute the correct transformation algorithm at runtime without introducing tight coupling or complex conditionals.

 

Strategy Pattern: Chart-Specific Data Transformations

The Strategy pattern allows us to define a family of algorithms (transformations), encapsulate each one, and make them interchangeable. In our chart module, each chart type has its own transformation strategy responsible for converting raw report data into a format suitable for the rendering library.

 

Structure

The implementation revolves around a contract interface that defines two key methods:

// Interface defining the strategy contract 
export interface IChartDataTransformer 
{  
  /**  
  * Determines if this transformer supports the given chart type.  
  * Each transformer checks if it can handle the visualization type.  
  */  
  supports(chartType: string): boolean;  
 
  /**  
  * Transforms raw report data into chart-specific format.  
  * Each transformer implements its own transformation algorithm.  
  */  
  transform(reportData: any, chartType?: string, report?: any): any; 
}

Each chart transformer implements this interface:

@Injectable({ providedIn: 'root' })
export class BarChartTransformer implements IChartDataTransformer {
  supports(chartType: string): boolean {
    // Returns true for bar chart type identifiers
  }

  transform(reportData: any): any[] {
    // Analyzes dimensions and metrics
    // Normalizes data structure
    // Applies color mapping
    // Enriches with metadata
    // Returns chart library-compatible format
  }
}

 

What Each Strategy Handles

Each transformation strategy encapsulates logic specific to a chart type. This includes:

  • Dimensional Analysis: Understanding how many dimensions and metrics are present, which affects grouping and series construction.

  • Data Normalisation: Converting the consistent API response into the structure expected by the specific visualisation library (e.g., row-based data for ag-Grid vs. series-based arrays for Highcharts).

  • Metadata Enrichment: Adding display labels, tooltip formatting, and contextual information derived from report metadata.

*Quick note: At first glance, this example may appear to violate the Single Responsibility Principle. However, in practice, the transformer acts as an orchestration layer, delegating specific responsibilities to dedicated services. The example is simplified to keep the focus on the pattern rather than internal service composition.

 

Benefits

  • Each chart type can evolve independently without impacting others.

  • Adding support for a new chart only requires implementing the interface—no changes to existing code.

  • Clear separation of concerns between transformation and rendering logic.

  • High testability: each strategy can be unit-tested in isolation using mock datasets.

 

Factory Pattern: Selecting the Right Transformer

While strategies encapsulate transformation logic, we still need a mechanism to select the correct strategy at runtime. This is where the Factory pattern comes into play.

The factory acts as a lookup mechanism that returns the appropriate transformer based on the chart type, without consumers needing to know about concrete implementations.

 

Structure

We use an injection token to collect all chart transformers, and a factory that receives them via dependency injection:

// Injection token for collecting all chart transformers
export const CHART_TRANSFORMERS = new InjectionToken<IChartDataTransformer[]>(
  'CHART_TRANSFORMERS'
);

@Injectable({ providedIn: 'root' })
export class ChartDataTransformerFactory {
  constructor(@Inject(CHART_TRANSFORMERS) private transformers: IChartDataTransformer[]) {}

  getTransformer(chartType: string): IChartDataTransformer | null {
    // Searches for transformer where supports() returns true
    // This is the common method enforced by the interface defined earlier.
    return this.transformers.find(t => t.supports(chartType)) || null;
    // Returns null if no transformer found
  }
}

The factory is used by the main chart data transformer (which participates in the pipeline we’ll see in the next post):

@Injectable({ providedIn: 'root' })
export class ChartDataTransformer implements ReportTransformer {
  constructor(private chartFactory: ChartDataTransformerFactory) {}

  isApplicable(reportData: any): boolean {
    // Checks for visualization type presence
  }

  apply(reportData: any): any[] {
    // Gets transformer from factory
    const transformer = this.chartFactory.getTransformer(..);
    if (!transformer) {
      // Throws error if no transformer found
    }
    // Delegates to transformer's transform method
    // Remember, this is part of the contract defined earlier in strategy pattern.
    return transformer.transform(..);
  }
}

 

How It Fits Together

The factory leverages Angular’s dependency injection to receive all registered transformers via the custom token. It acts as a registry and lookup: it doesn’t create transformers itself—they’re registered elsewhere (we’ll cover that in a future post). The factory is completely decoupled from specific transformer implementations.

When no transformer is found for a given chart type, the system throws a descriptive error rather than returning null. This fail-fast approach ensures bugs are caught early and production errors are clearly identifiable.

 

Benefits

  • Decouples transformer selection from usage—consumers don’t need to know which transformer to use

  • Centralised logic for transformer selection—single point of control

  • Easy to extend with new chart types—just register a new transformer

  • Type-safe transformer selection through TypeScript interfaces

  • Strategies are unaware of each other; the factory is unaware of strategy implementation details

 

Conclusion

Pairing Strategy with a simple Factory keeps the chart transformation layer flexible and disciplined. Each chart type encapsulates its transformation logic behind a common interface, while the factory selects the appropriate strategy at runtime. This approach creates a fail-fast system that detects misconfigurations early and allows new visualisations to be added without modifying existing code.


Ravindra Soman

Ravindra Soman

Senior Full Stack Data Engineer

Ravindra is a Senior Full Stack Data Engineer specialising in frontend architecture and complex data visualisation systems. In his spare time, he likes to cook new dishes for his family, read fiction and travel the world.