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
- GitHub: https://github.com/michaelgantman/Mgnt
- Maven Central: https://central.sonatype.com/artifact/com.github.michaelgantman/MgntUtils
- Javadoc: https://michaelgantman.github.io/Mgnt/docs/
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:
- BaseEntityFactory https://github.com/michaelgantman/Mgnt/blob/master/src/main/java/com/mgnt/lifecycle/management/BaseEntityFactory.java
- BaseEntity https://github.com/michaelgantman/Mgnt/blob/master/src/main/java/com/mgnt/lifecycle/management/BaseEntity.java
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);
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:
- JsonInfoFormatter https://github.com/michaelgantman/Mgnt/blob/master/src/main/java/com/mgnt/lifecycle/management/example/implementations/JsonInfoFormatter.java
- XmlInfoFormatter https://github.com/michaelgantman/Mgnt/blob/master/src/main/java/com/mgnt/lifecycle/management/example/implementations/XmlInfoFormatter.java
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();
}
- method init() sole purpose is to instantiate all concrete implementations. Once those constructors are executed, the factory is automatically populated by the infrastructure.
- 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 {
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
- Clone MgntUtilsUsage git repo. Repo link: https://github.com/michaelgantman/MgntUtilsUsage (Clone URL: https://github.com/michaelgantman/MgntUtilsUsage.git).
- 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
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
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.
- 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.
- 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. - 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)
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:
- LetterFormattingController https://github.com/michaelgantman/MgntUtilsUsage/blob/main/src/main/java/com/example/stamboot/controller/LetterFormattingController.java
- LetterFormattingService https://github.com/michaelgantman/MgntUtilsUsage/blob/main/src/main/java/com/example/stamboot/letterformatting/service/LetterFormattingService.java
- 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)
(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:
- Pre-formatting
- Formatting
- 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)
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;
}
(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;
}
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;
}
(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);
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;
}
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:
- 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
}
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());
- 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:
- com.example.stamboot.letterformatting.preformat (see https://github.com/michaelgantman/MgntUtilsUsage/tree/main/src/main/java/com/example/stamboot/letterformatting/preformat)
- com.example.stamboot.letterformatting.format (see https://github.com/michaelgantman/MgntUtilsUsage/tree/main/src/main/java/com/example/stamboot/letterformatting/format)
- com.example.stamboot.letterformatting.postformat (see https://github.com/michaelgantman/MgntUtilsUsage/tree/main/src/main/java/com/example/stamboot/letterformatting/postformat)
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
- 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);
}
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);
}
}
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());
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”;
}
}
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;
}
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:
- GitHub repository: https://github.com/michaelgantman/Mgnt
- Maven Central: https://central.sonatype.com/artifact/com.github.michaelgantman/MgntUtils
- Javadoc: https://michaelgantman.github.io/Mgnt/docs/
The complete runnable example discussed in this article is available here:
- MgntUtilsUsage example project: https://github.com/michaelgantman/MgntUtilsUsage
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)