DEV Community

Michael Gantman
Michael Gantman

Posted on

Infrastructure for Extensible Multi-Stage Workflows Across Multiple Data Types

Introduction and Purpose of This Article

This is an in-depth technical article for Java developers. It presents a lightweight yet powerful architectural approach for implementing extensible, multi-stage processing workflows that support multiple data types, multiple processing stages, and arbitrary future extensibility — all without modifying existing code.

Before diving into the infrastructure, let’s first clearly define the problem using a simple, generic example.

What Is a Multi-Stage Processing Workflow for Multiple Data Types?

Assume you have input data files that need to be processed through several consecutive steps. For this example, let’s define the workflow stages as:

  • Pre-Processing
  • Processing
  • Post-Processing

This sequence forms a workflow, where each stage performs its own transformation.

Now assume that your system must support multiple data types, where:

  • Each data type goes through the same sequence of stages, but
  • Each data type requires its own type-specific implementation for each stage

For example, suppose your input files may arrive in JSON or XML format. However:

  • The system must be able to support an extension to an arbitrary number of formats in the future
  • The system must also be able to extend or reorder the number of workflow stages

This is precisely what the expression “multi-stage processing workflow for multiple data types refers to.

Although JSON and XML are used here as simple illustrative examples, the pattern generalizes to virtually any domain. Some representative examples include:

  • Image formats (BMP, JPEG, PNG)
  • Sensor telemetry formats
  • Message and protocol formats
  • Validation pipelines
  • ETL stages
  • Document generation
  • Many other processing domains

The key properties of this pattern are:

  • The number of stages is not fixed — the workflow may contain any number of steps.
  • The number of supported data types is not fixed — and adding a new type should NOT require any changes to existing code.

This is where the proposed solution becomes essential.

Overall Solution and Demo Description

The solution described in this article is based on the self-populating factory infrastructure provided by the open-source MgntUtils library, and demonstrated by a real-world example in the MgntUtilsUsage repository.

MgntUtils Resources

MgntUtilsUsage Demo Repository

This architectural approach emphasizes:

  • Zero-configuration extensibility (The system is completely data-type-agnostic. New types are added simply by providing type-specific implementations for each stage — with no additional configuration or registration.)
  • Automatic component discovery
  • Clean separation of responsibilities (Workflow orchestration is never aware of type-specific implementation details.)
  • Elimination of switch statements, large registries, and manual wiring
  • Support for arbitrarily complex workflows (Stages can be added, removed, or reordered.)
  • Strong error handling with discoverable valid types (This is demonstrated in the demo. Although not an inherent part of the pattern itself, it is included in the demo to keep it real-world like.)

What is self-populating factory infrastructure?

Before diving into the solution details, it is important to briefly explain what a self-populating factory infrastructure is, since this concept is the cornerstone of the proposed solution. At its core, this idea is an extension of the classic Factory pattern. In the traditional Factory pattern, you typically have:

  • An interface
  • Several concrete implementations of that interface
  • A factory that returns a concrete implementation based on some key

While this pattern is well-known and widely used, it usually requires explicit registration of implementations inside the factory — often through configuration files, static initialization blocks, or manual wiring.

The Self-Populating Factory Idea

The key idea behind a self-populating factory is that each concrete implementation is factory-aware and is responsible for registering itself with the factory when it is instantiated.

In other words:

  • The factory does not need to know about concrete implementations
  • Concrete implementations insert themselves into the factory automatically
  • Factory population happens implicitly, not through external configuration

This behavior is exactly what gives the pattern its name — self-populating factory.

How MgntUtils Implements This Infrastructure

The factory-awareness and automatic self-registration behavior are provided by the self-populating factory infrastructure in the MgntUtils library, located in the package:

com.mgnt.lyfecycle.management

Package-level Javadoc: https://michaelgantman.github.io/Mgnt/docs/com/mgnt/lifecycle/management/package-summary.html

This package contains only two core classes:

To use this infrastructure, you need to:

  • Create a factory class that extends BaseEntityFactory
  • Ensure that all concrete implementations extend BaseEntity

Since concrete implementations must contain some mandatory infrastructure-related code, it is recommended to:

  • Create an interface that will define what your implementations must do — a method signature (or a set of method signatures)
  • Create a single abstract base implementation that implements your interface
  • Place all mandatory infrastructure code into that abstract base class
  • Have all concrete implementations extend that abstract base class

Once this structure is in place, each concrete implementation instance automatically registers itself with its factory when its constructor is invoked.

As a result:

  • You never need to explicitly populate the factory
  • You do not need to worry about when or how the factory is populated
  • Factory population happens transparently and consistently

After the concrete implementations are instantiated, you can retrieve them from the factory anywhere in your code.

Factory Key Options (A Crucial Design Point)

There are two ways a concrete implementation can be registered in a factory:

  • Using the concrete class name as the factory key
  • Using a custom, user-defined key

While the second option might initially appear as an insignificant feature, it is absolutely critical for the workflow design presented later in this article.

The ability to register implementations under custom keys, rather than class names, enables multiple different factories to share the same set of keys (much like the namespace pattern). This is the foundation that enables:

  • Clean multi-stage workflows
  • Consistent type-based routing across multiple factories
  • Strong decoupling between workflow orchestration and implementation details

This point will become especially important when we transition from the simple XML/JSON example to the full Letter Formatting workflow in the MgntUtilsUsage repository.

Runnable Example in MgntUtils

The MgntUtils library contains a clear, runnable example that demonstrates the self-populating factory mechanism. It can be found in the package:

com.mgnt.lifecycle.management.example

Example package git repo link: https://github.com/michaelgantman/Mgnt/tree/master/src/main/java/com/mgnt/lifecycle/management/example

Example Walkthrough

The example defines an interface called InfoFormatter with a single method:


String formatMessage(String message);
Enter fullscreen mode Exit fullscreen mode

InfoFormatter interface: https://github.com/michaelgantman/Mgnt/blob/master/src/main/java/com/mgnt/lifecycle/management/example/InfoFormatter.java

As described earlier, there is a common abstract base class called BaseInfoFormatter, which contains the mandatory infrastructure-related code:

BaseInfoFormatter (mandatory code: lines 11–39): https://github.com/michaelgantman/Mgnt/blob/master/src/main/java/com/mgnt/lifecycle/management/example/BaseInfoFormatter.java

Note that all concrete implementations extend this BaseInfoFormatter class.

Two concrete implementations are provided:

In this example:

  • Each concrete implementation registers itself in the factory
  • Registration uses custom keys: “JSON” and “XML”

The UsageExample class is a runnable class (has main() method). So, if you downloaded the Mgnt git repository and want to run the example — just run UsageExample class as java application.

UsageExample: https://github.com/michaelgantman/Mgnt/blob/master/src/main/java/com/mgnt/lifecycle/management/example/UsageExample.java

The main method looks very simple:

public static void main(String... args) {
  init();
  printFormattedGreetings();
}
Enter fullscreen mode Exit fullscreen mode
  1. method init() sole purpose is to instantiate all concrete implementations. Once those constructors are executed, the factory is automatically populated by the infrastructure.
  2. Method printFormattedGreetings() Uses InfoFormatterFactory to retrieve implementations by their custom keys and run the concrete implementations methods. This demonstrates that no explicit factory population code exists.

InfoFormatterFactory: https://github.com/michaelgantman/Mgnt/blob/master/src/main/java/com/mgnt/lifecycle/management/example/InfoFormatterFactory.java

Main point: The factory is populated automatically by the infrastructure — without configuration files, manual registration, or wiring logic. The factory population is triggered by method init() which in our case is a bootstrapping code that ensures that factory is initialized before it is used in business logic method printFormattedGreetings().

This example provides a minimal but complete demonstration of the self-populating factory infrastructure.

To visualize the class structure described above, see the diagram below. It shows how the MgntUtils infrastructure classes (BaseEntity, BaseEntityFactory) interact with user-defined interfaces, base classes, and concrete implementations to enable automatic factory population.

An important note: For this mechanism to work, concrete implementations must actually be instantiated — that is, their constructors must be invoked. Since self-registration happens during object construction, a class that is never instantiated will never register itself with the factory. (See the description of init() method above)

This requirement makes frameworks that use the Inversion of Control (IoC) pattern — such as Spring and Spring Boot — particularly well suited for this utility. In such frameworks, concrete implementations can be instantiated automatically as part of the application startup process. In the case of Spring Boot, this typically means declaring the implementations as Spring beans with lazy initialization disabled, ensuring they are eagerly created during context initialization. You can see the example of this in declaration of one of the concrete implementations in MgntUtilsUsage library, Here is the link to CustomerLetterPreFomatter class: https://github.com/michaelgantman/MgntUtilsUsage/blob/main/src/main/java/com/example/stamboot/letterformatting/preformat/implementations/CustomerLetterPreFormatter.java

See lines 8–10 they look like


@Component 
@Lazy(false) 
public class CustomerLetterPreFormatter extends BaseLetterPreFormatter {
Enter fullscreen mode Exit fullscreen mode

As a result, factory population becomes a natural side effect of application startup, requiring no additional wiring or bootstrap code.

Important detail: Note annotation @Lazy(false). This turns off lazy mode and ensuring that this bean is instantiated at SpringBoot application startup as opposed to the first time it is requested. Without it it won’t be initialized and won’t register in the factory. And so, the search in the factory for that bean will fail.

Also note that while SpringBoot or any other IoC frameworks fit very well with self-populating factory pattern they are not required. For non-IoC environments you will need to write your own simple bootstrap code — just like method init() in the example above.

In the next section, we will build directly on this foundation and show how the same mechanism enables a clean, extensible, multi-stage workflow architecture in a real-world system using the MgntUtilsUsage repository.

Complete multi-stage workflow architecture example

Let’s first define the concrete task for which the following example demonstrates the proposed solution.

Assume that you need to develop a system responsible for formatting corporate letters.

Input Requirements

The system receives:

  • Letter content — a plain text string
  • Letter type — one of a predefined set of values, for example: LEGAL, INTERNAL, CUSTOMER

Processing Requirements

The system must format the letter using three distinct processing stages:

  • Pre-formatting Adds a type-dependent greeting that appears before the letter content.
  • Formatting Adds a type-dependent signature that appears after the letter content.
  • Post-formatting Adds a type-dependent letter header that appears above the greeting and content.

Each stage performs an independent transformation and is unaware of the internal logic of other stages.

Extensibility Requirements

The architecture must satisfy the following extensibility constraints:

  • Type extensibility The system must support adding an arbitrary number of new letter types without modifying existing code.
  • Stage extensibility The number of processing stages must not be fixed. Stages can be added, removed, or reordered.
  • Conditional workflow support The next stage may be determined based on: The result of a previous stage and External conditions or metadata

These requirements rule out hardcoded workflows and require a fully dynamic solution.

Let’s run the demo and show some examples:

How to Run the demo project

  1. Clone MgntUtilsUsage git repo. Repo link: https://github.com/michaelgantman/MgntUtilsUsage (Clone URL: https://github.com/michaelgantman/MgntUtilsUsage.git).
  2. Open the project in your IDE and run class com.example.stamboot.StamBootApplication.java as Java application.

Running example requests:

It is assumed that when you start your application the listening port is 8080.

In your browser run the following link:

http://localhost:8080/letter?content=Hello world&type=CUSTOMER

For the letter content “Hello world” and type “CUSTOMER” the result should be:


2025-12-20 Customer communication

Dear customer,
Hello world

Best regards,
Your Service provider
Enter fullscreen mode Exit fullscreen mode

In the example above

  • The line “2025–12–20 Customer communication” is added by post-formatting stage
  • The line “Dear customer,” is added by pre-formatting stage
  • The 2 lines “Best regards, Your Service provider” are added by formatting stage

The same content but with the type “INTERNAL”
(Link: http://localhost:8080/letter?content=Hello world&type=INTERNAL)
Should yield the following result:


2025-12-20 Internal (Confidential) communication

Dear employees,
Hello world

Senior Management
Enter fullscreen mode Exit fullscreen mode

This task definition captures a realistic business requirement while remaining simple enough to clearly demonstrate how a self-populating, multi-stage workflow architecture can be applied in practice.

In the next section, we will walk through how this task is implemented using the self-populating factory infrastructure provided by the open-source MgntUtils library, showing how multiple factories, shared type keys, and staged processing work together to produce the final result.

Example resources

The complete, runnable example discussed in this section can be found in the MgntUtilsUsage Git repository: https://github.com/michaelgantman/MgntUtilsUsage

The most important files and packages are listed below.

  1. Project Configuration pom.xml https://github.com/michaelgantman/MgntUtilsUsage/blob/main/pom.xml Note that the MgntUtils library is declared as a dependency on lines 30–34. The entire example is built on top of the self-populating factory infrastructure provided by this open-source library.
  2. API Entry Point LetterFormattingController class https://github.com/michaelgantman/MgntUtilsUsage/blob/main/src/main/java/com/example/stamboot/controller/LetterFormattingController.java This class serves as the entry point for the example. It exposes a GET endpoint that demonstrates the complete multi-stage formatting workflow.For simplicity and demonstration purposes only: both letter content and Letter type are passed as a URL parameter. This approach is used strictly for example convenience. In a real-world implementation, passing letter content via URL parameters would not be practical or advisable. A production-ready design would typically use:

    - A POST request
    - A request body (for example, JSON)
    - Proper validation and payload size handling 
    The choice of a GET method here keeps the example easy to invoke and focus remains on the workflow architecture, rather than on REST API design details.

  3. Letter Formatting Implementation package com.example.stamboot.letterformatting https://github.com/michaelgantman/MgntUtilsUsage/tree/main/src/main/java/com/example/stamboot/letterformatting

The Core Design Idea

Conceptually, the system behaves as a lightweight workflow engine:

Each processing stage is implemented as an independent, self-populating factory.

For each workflow stage, we create:

  • A dedicated factory
  • A set of concrete implementations, one per letter type

Specifically, for the three stages defined earlier, the solution introduces the following factories:

  • LetterPreFormatterFactory
  • LetterFormatterFactory
  • LetterPostFormatterFactory

Each factory contains three concrete implementations, one for each supported letter type:

  • LEGAL
  • INTERNAL
  • CUSTOMER

The Crucial Design Insight: Shared Custom Keys

As explained earlier in the discussion of the self-populating factory infrastructure, concrete implementations can be registered in a factory using custom keys, rather than class names.

This capability is the cornerstone of the entire workflow design.

In this example:

  • All factories use exactly the same set of custom keys
  • Each key corresponds to a letter type
  • The keys are identical across all factories: LEGAL, INTERNAL, CUSTOMER

As a result:

  • Each factory is completely independent
  • No factory is aware of any other factory
  • No stage contains logic to translate or map types

Yet all factories implicitly conform to the same convention.

How This Enables a Clean Workflow

As the workflow progresses from stage to stage, the code simply retrieves the appropriate implementation for the current letter type from the corresponding factory. For every stage and every factory, the retrieval logic is always the same:


factory.getInstance(LETTER_TYPE)
Enter fullscreen mode Exit fullscreen mode

There is:

  • No conditional logic
  • No switch statements
  • No type mapping code
  • No coupling between stages

Each stage:

  • Knows only its own responsibility
  • Retrieves the correct implementation using the shared key convention
  • Produces its output independently

This design allows the workflow to be:

  • Type-extensible (new letter types can be added)
  • Stage-extensible (new stages can be introduced)
  • Order-flexible (stages can be reordered or conditionally chained)

— all without modifying existing code.

Architectural Diagram of the Multi-Stage Letter Formatting System

The diagram below presents the architectural structure of the multi-stage letter formatting system. Each processing stage is implemented as an independent self-populating factory with its own interface, abstract base class, and concrete implementations. All factories share an identical set of custom keys corresponding to letter types, which allows the workflow to progress through stages without any coupling between them.

The workflow

Let’s now examine how the workflow is executed at runtime. There are three main components involved:

  1. LetterFormattingController https://github.com/michaelgantman/MgntUtilsUsage/blob/main/src/main/java/com/example/stamboot/controller/LetterFormattingController.java
  2. LetterFormattingService https://github.com/michaelgantman/MgntUtilsUsage/blob/main/src/main/java/com/example/stamboot/letterformatting/service/LetterFormattingService.java
  3. LetterFormattingManager https://github.com/michaelgantman/MgntUtilsUsage/blob/main/src/main/java/com/example/stamboot/letterformatting/bl/LetterFormattingManager.java

The following diagram illustrates the runtime workflow and interaction between the main components involved in letter formatting. It shows how an incoming request propagates through the controller and service layers into the business logic manager, and how the manager orchestrates the execution of individual processing stages using self-populating factories.

LetterFormattingController is the entry point of the application. It exposes a GET HTTP endpoint that receives letter content and letter type as request parameters (this is done purely for example convenience and is not intended as a real-world API design).

Upon receiving a request, the controller delegates the formatting task to LetterFormattingService by invoking

letterFormattingService.formatLetter(content, type)
Enter fullscreen mode Exit fullscreen mode

(see line 35).

The controller receives the formatted letter as a result, logs it, and builds an HTTP response containing the formatted output. If an error occurs, the exception is caught, an appropriate log message is written, and an HTTP error response is returned.

LetterFormattingService performs two responsibilities:

  • It validates the letter type against the predefined set of supported types and throws an exception if the type is invalid.
  • It delegates the actual formatting workflow execution to LetterFormattingManager.

This separation keeps validation and orchestration concerns outside of the controller while keeping business flow logic out of the service layer.

LetterFormattingManager is the core business logic engine. It is responsible for determining which stages are executed and in what order.

In this example, the workflow is unconditional and consists of three sequential stages:

  1. Pre-formatting
  2. Formatting
  3. Post-Formatting

However, this logic can be arbitrarily complex. Stages may be reordered, skipped, or conditionally executed based on the result of previous stages or other business rules. Crucially, this logic is implemented without any knowledge of concrete implementations.

For each stage, the manager retrieves a concrete implementation from the corresponding factory using the letter type and invokes the stage-specific processing method.

The most important architectural point here is that all stage factories share the same identical set of keys, representing the supported letter types. This guarantees that for any stage, retrieving a concrete implementation is always a simple and uniform operation:

factory.getInstance(dataType)
Enter fullscreen mode Exit fullscreen mode

As a result, stages remain completely decoupled from each other, while the workflow logic remains independent of both the number of stages and the number of supported letter types.

Type and Stage Extensibility and Conditional Workflow

In this section, we will finally discuss the repeatedly mentioned benefits of the proposed infrastructure.

At the end of “The Core Design Idea” section, we stated that this design allows the framework to be:

  • Type-extensible (new letter types can be added)
  • Stage-extensible (new processing stages can be introduced)
  • Order-flexible (stages can be reordered or conditionally chained)

— all without modifying existing code.

Let’s now demonstrate how this is achieved, point by point.

Conditional Workflow Logic

Here we will discuss how processing stages can be reordered or conditionally executed based on the results of previous stages or any other runtime conditions.

As stated earlier, the business logic engine is implemented in com.example.stamboot.letterformatting.bl.LetterFormattingManager (see source code here: https://github.com/michaelgantman/MgntUtilsUsage/blob/main/src/main/java/com/example/stamboot/letterformatting/bl/LetterFormattingManager.java)

The workflow is controlled by the method:

public String handleLetterFormatting(String content, DocumentType documentType) { 
  String preformattedContent = preformatLetter(content, documentType);
  String formattedContent = formatLetter(preformattedContent,  documentType);
  String postFormattedString = postFormatLetter(formattedContent, documentType);
  return postFormattedString; 
}
Enter fullscreen mode Exit fullscreen mode

(see lines 15–20 in the class source)

As shown above, the stages are executed in a simple, unconditional sequence:

  • Pre-formatting (pre-appends a greeting to the letter content)
  • Formatting (post-appends a signature)
  • Post-formatting (pre-appends a header)

Reordering Stages

Let’s start with a simple unconditional modification: changing the execution order by switching the formatting and post-formatting stages.

Originally, the flow was:

Pre-append greeting → Post-append signature → Pre-append header

After reordering, the flow becomes:

Pre-append greeting → Pre-append header → Post-append signature

To achieve this, we only need to modify the handleLetterFormatting() method as follows:

public String handleLetterFormatting(String content, DocumentType documentType) { 
  String preformattedContent = preformatLetter(content, documentType);
  String postFormattedString = postFormatLetter(preformattedContent, documentType); 
  String formattedContent = formatLetter(postFormattedString, documentType); 
  return formattedContent; 
}
Enter fullscreen mode Exit fullscreen mode

This is the only change required.

In this particular example, because pre-formatting and post-formatting both prepend content and formatting appends content, changing their order does not affect the final output. However, if you run it in debug mode and inspect the intermediate values, you will clearly see that the order of stage execution has changed.

This trivial example demonstrates an important architectural point: stage execution order can be changed without modifying any stage implementations, and without any awareness of how many implementations (letter types) exist.

Conditional Workflow

Now let’s introduce conditional logic.The core point remains the same: workflow control logic is decoupled from the number of supported types and from their implementation details.

Assume that we want to perform a document modification only if the content does not already conform to the expected standard.

For example, the pre-formatting stage prepends a greeting. Let’s modify the workflow so that this stage is executed only if the original letter content does not already contain a greeting.

Introducing Letter Metadata

We introduce a simple metadata class in the package com.example.stamboot.letterformatting.bl:

public class LetterMetadata {
 private boolean headerPresent; 
 private boolean greetingPresent;
 private boolean signaturePresent;
}
Enter fullscreen mode Exit fullscreen mode

(Getter and setter methods are omitted for brevity.)

Next, we introduce a helper class LetterInspector with a single static method:

public static LetterMetadata inspectLetter(String letterContent);
Enter fullscreen mode Exit fullscreen mode

The implementation is not shown here, as it is not relevant to the example. Conceptually, this method analyzes the letter content and returns metadata describing which structural elements are already present.

Conditional Stage Execution

We now modify the business logic method as follows:

public String handleLetterFormatting(String content, DocumentType documentType) {
 LetterMetadata metadata = LetterInspector.inspectLetter(content);
 if(!metadata.isGreetingPresent()) {
   content = preformatLetter(content, documentType);
   metadata = LetterInspector.inspectLetter(content); 
 }
 if(!metadata.isSignaturePresent()) {
   content = formatLetter(content, documentType);
   metadata = LetterInspector.inspectLetter(content);
 }
 if(!metadata.isHeaderPresent()) {
   content = postFormatLetter(content, documentType);
 }
 return content;
}
Enter fullscreen mode Exit fullscreen mode

With this our flow looks like this:

With this approach, each stage is executed only if required, based on:

  • The current content state
  • The result of previously executed stages

The workflow dynamically adapts at runtime while remaining fully deterministic and centrally controlled.

Architectural Implications

This example demonstrates that:

  • Conditional workflow logic resides entirely in LetterFormattingManager
  • Stage implementations remain completely unaware of the workflow
  • Factories and their registered implementations are untouched

Type Extensibility

This section will demonstrate how to add a new letter type (or more generically new concrete implementation) to the framework. Throughout this article I referred to 3 letter types in our example progect (MgntUtilsUsage repo: https://github.com/michaelgantman/MgntUtilsUsage). The types are: LEGAL, INTERNAL, CUSTOMER. However, if you look at the repo it comes with 4 letter types where FINANCE is the additional, not mentioned type. The truth is that as I developed my project I originally had only 3 types, and added the 4th (FINANCE) at a later stage to showcase how the new type could be added. So, I will simply list here all the changes that were made to add the additional letter type. Here they are:

  1. The enum com.example.stamboot.letterformatting.DocumentType (https://github.com/michaelgantman/MgntUtilsUsage/blob/main/src/main/java/com/example/stamboot/letterformatting/DocumentType.java) was modified. The additional enum value FINANCE was added (See Line #7 in the source code) Here is the modified enum:
public enum DocumentType {
 LEGAL, 
 INTERNAL,
 CUSTOMER,
 FINANCE; //This is the newly added line
}
Enter fullscreen mode Exit fullscreen mode

2. For each stage a new concrete implementation class was added.

IMPORTANT NOTE: Each new implementation MUST use the newly added FINANCE Letter type value as a custom key for registration in its stage factory via the superclass constructor invocation:

super(DocumentType.FINANCE.toString());
Enter fullscreen mode Exit fullscreen mode

- For pre-formatting stage the new class is: com.example.stamboot.letterformatting.preformat.implementations.FinancialDocumentPreformatter (See source code here: https://github.com/michaelgantman/MgntUtilsUsage/blob/main/src/main/java/com/example/stamboot/letterformatting/preformat/implementations/FinancialDocumentPreformatter.java See line 13 — the registration with FINANCE key)

- For formatting stage the new class is: com.example.stamboot.letterformatting.format.implementations.FinancialDocumentFormatter (Source code: https://github.com/michaelgantman/MgntUtilsUsage/blob/main/src/main/java/com/example/stamboot/letterformatting/format/implementations/FinancialDocumentFormatter.java See line 13 — the registration with FINANCE key)

- For post-formatting stage the new class is: com.example.stamboot.letterformatting.postformat.implementations.FinancialDocumentPostFormatter (Source code: https://github.com/michaelgantman/MgntUtilsUsage/blob/main/src/main/java/com/example/stamboot/letterformatting/postformat/implementations/FinancialDocumentPostFormatter.java See line 15 — the registration with FINANCE key)

This is it! No change anywhere else, just add new implementations for each stage and it is automatically picked up, registered and ready to use.

Stage Extensibility

Adding a new stage is a little bit more work than adding a new type, but it is still very structured and isolated modification that does not involve changing pre-existing structure. Back in the “Complete multi-stage workflow architecture example” section of this article in the “The Core Design Idea” paragraph we stated the following:

Each processing stage is implemented as an independent, self-populating factory.

So, adding another stage is just creating another self-populating factory set. In the same section on the next paragraph “Architectural Diagram of the Multi-Stage Letter Formatting System” there is a diagram showing 3 independent sets of self-populating factories. If you look at the code in our MgntUtilsUsage repository each such set is implemented in a separate package:

So, to add a new stage we just need to create another package with self-populating factory set that mimics the same structure as the 3 existing ones. Let’s demonstrate this with a concrete example by introducing a new ps-formatting stage that appends a ‘P.S.’ section after the signature. In order to create this stage, we will

  1. Create a new package: com.example.stamboot.letterformatting.psformat In this package we will create the following structure:

2. Create a factory class com.example.stamboot.letterformatting.psformat.LetterPsFormatterFactory. The code is omitted as it is practically identical to factories in the other 3 stages.

3. Create com.example.stamboot.letterformatting.psformat.LetterPsFormatter interface:

public interface LetterPsFormatter {
      String psFormatDocument(String doc);
}
Enter fullscreen mode Exit fullscreen mode

4. Create com.example.stamboot.letterformatting.psformat.BaseLetterPsFormatter abstract class

public abstract class BaseLetterPsFormatter extends BaseEntity<BaseLetterPsFormatter> implements LetterPsFormatter {

    private static final String FACTORY_TYPE =
      BaseLetterPsFormatter.class.getSimpleName();
    static {
        init(FACTORY_TYPE, LetterPsFormatterFactory.getFactoryInstance());
    }

    public BaseLetterPsFormatter() {
        super(FACTORY_TYPE);
    }

    public BaseLetterPsFormatter(String customName) {
        super(FACTORY_TYPE, customName);
    }
}
Enter fullscreen mode Exit fullscreen mode

5. Create a new package com.example.stamboot.letterformatting.psformat. implementations

In this package create 4 concrete implementation classes — one per each letter type. 
Important note: just like in “Type Extensibility” section Each new implementation MUST use appropriate Letter type value as a custom key for registration in its stage factory via the superclass constructor invocation. For example for FINANCE implementation use this invocation.

super(DocumentType.FINANCE.toString());
Enter fullscreen mode Exit fullscreen mode

We will show just one class and leave the rest of the implementations for readers as they are very similar as you can see in previous stages implementations. We will show the implementation for FINANCE

package com.example.stamboot.letterformatting.psformat.implementations;
//import statements are omitted
@Component
@Lazy(false)
public class FinancialDocumentPsFormatter extends BaseLetterPsFormatter {

    public FinancialDocumentPsFormatter() {
        super(DocumentType.FINANCE.toString());
    }

    @Override
    public String psFormatDocument (String doc) {
        return doc + “\n\nP.S. here comes P.S. content;
    }
} 
Enter fullscreen mode Exit fullscreen mode

6. Now we are done with adding the ps-formatting stage and we just need to use it. So, let’s assume that we want to add this stage as the last one after post-formatting stage. So we will need to modify our Business Logic. So we go to our LetterFormattingManager class (https://github.com/michaelgantman/MgntUtilsUsage/blob/main/src/main/java/com/example/stamboot/letterformatting/bl/LetterFormattingManager.java) and modify the handleLetterFormatting method as follows

public String handleLetterFormatting(String content, DocumentType documentType) {
 String preformattedContent = preformatLetter(content, documentType);
 String formattedContent = formatLetter(preformattedContent, documentType);
 String postFormattedString = postFormatLetter(formattedContent, documentType);
 String psFormattedString = psFormatLetter(postFormattedString);
 return psFormattedString;
}
Enter fullscreen mode Exit fullscreen mode

There are two simple private helper methods that need to be added but they are omitted here as they are trivial.

So, this is it. This demonstrates that stage extensibility is achieved by composition rather than modification: new stages are introduced as independent factory sets, and the workflow evolves explicitly through business logic without impacting existing stages or implementations.

Unlike type extensibility, adding a new stage necessarily requires modifying the business logic, because the workflow must explicitly decide when and where the new stage is executed.

In Conclusion

In this article, we examined a lightweight yet powerful infrastructure for building extensible multi-stage workflows across multiple data types. Using the self-populating factory mechanism provided by the MgntUtils library, we demonstrated how a system can be designed to support:

  • Adding new data types without modifying existing code
  • Adding new processing stages in an isolated and predictable manner
  • Reordering and conditionally executing stages based on runtime logic
  • Centralized workflow control that remains completely decoupled from implementation details

The key architectural insight is the combination of independent self-populating factories, a shared key convention, and a centralized workflow manager. This approach eliminates the need for switch statements, manual registries, and intrusive configuration while keeping the system easy to extend and reason about.

Although the examples in this article focus on letter formatting, the same design applies to a wide range of domains, including validation pipelines, ETL workflows, document generation systems, and other multi-step processing engines.

If you find this approach useful, consider exploring and using the MgntUtils open-source library in your own projects:

The complete runnable example discussed in this article is available here:

Feedback, suggestions, and contributions are welcome — feel free to share your experience, open issues, or contribute improvements to help evolve the library further.




Top comments (0)