Factory Method Pattern (ENG)
In short, Factory Method helps us separate the responsibility of creating objects from the general flow more effectively.
- Introduction: Problem scenario
- 1. Solution without the pattern
- 1.1 What is the problem with the solution without the pattern?
- 1.2 How can we solve the problem in the solution without the pattern?
- 2. Applying the pattern without Spring dependencies
- 2.1 Defining the Product abstraction
- 2.2 Creating the Concrete Products
- 2.3 Defining the Creator abstraction
- 2.4 Creating the Concrete Creators
- 2.5 Selecting the appropriate implementation with the Simple Factory mechanism
- 2.6 Result
- 3. Applying the pattern with Spring dependencies
- 3.1 Defining the Creator abstraction
- 3.2 Creating the Concrete Creators
- 3.3 Selecting the appropriate implementation with a Spring Bean Registry
- 3.4 Result
- 4. When not to use it?
- 5. Conclusion
- Advantages
- Disadvantages
- 6. Additional notes on TransactionCreatorRegistrySuppose that on a payment platform the user is given cashback for every payment. Depending on the type of the PaymentEvent (COMPLETED, REVERSING, REFUND), the system creates the appropriate CashbackTransaction and then applies this transaction in two places: to the user’s balance (increasing or decreasing the amount) and to the audit log (writing a human-readable message).
The transaction created for each event type may carry different data and behaviour:
- COMPLETED — the payment has been completed successfully. A new cashback is calculated with CashbackRuleService and added to the balance.
- REVERSING — the payment has been cancelled. The previous cashback is looked up with CashbackLookupService and subtracted from the balance.
- REFUND — the payment has been partially refunded. The previous cashback is looked up and subtracted from the balance, but the refund reason is also noted in the audit message.
The simplest approach is to handle these cases with if/else inside the orchestrator class, as shown below. In each branch the required cashback amount is calculated or looked up, the balance change is applied, and the audit message is composed.
1. Solution without the pattern
@Getter
@Builder
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class CashbackTransaction {
String customerId;
BigDecimal amount;
String description;
}
@Component
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class PaymentEventConsumer {
CashbackRuleService cashbackRuleService;
CashbackLookupService cashbackLookupService;
CashbackBalanceService cashbackBalanceService;
CashbackAuditLogger cashbackAuditLogger;
@KafkaListener(topics = "payment-events")
public void consumePayment(PaymentEvent event) {
CashbackTransaction transaction;
if (event.getType() == PaymentEventType.COMPLETED) {
BigDecimal cashback = cashbackRuleService.calculate(event);
String description = String.format("Cashback of %s awarded for payment %s",cashback,event.getPaymentId());
transaction = CashbackTransaction.builder()
.customerId(event.getCustomerId())
.amount(cashback)
.description(description)
.build();
} else if (event.getType() == PaymentEventType.REVERSING) {
BigDecimal originalCashback = cashbackLookupService.findAwardedAmount(event.getAwardedTxnId());
String description = String.format("Cashback of %s reversed for transaction %s",originalCashback,event.getAwardedTxnId());
transaction = CashbackTransaction.builder()
.customerId(event.getCustomerId())
.amount(originalCashback.negate())
.description(description)
.build();
} else if (event.getType() == PaymentEventType.REFUND) {
BigDecimal originalCashback =cashbackLookupService.findAwardedAmount(event.getPaymentId());
String description = String.format("Cashback of %s refunded, reason: %s",originalCashback,event.getRefundReason());
transaction = CashbackTransaction.builder()
.customerId(event.getCustomerId())
.amount(originalCashback.negate())
.description(description)
.build();
} else {
throw new UnsupportedPaymentTypeException(event.getType());
}
cashbackBalanceService.applyDelta(transaction.getCustomerId(),transaction.getAmount());
cashbackAuditLogger.log(transaction.describe());
}
}1.1 What is the problem with the solution without the pattern?
As you can see, here PaymentEventConsumer takes on several different responsibilities.
- Deciding, based on the type, which CashbackTransaction object is to be created.
- Obtaining the data required for each type from the appropriate services.
- Determining the balance change.
- Composing the audit message.
- Applying the created object to the balance and audit services.
And if a new cashback type — PARTIAL_REFUND — is added to the business requirements, then in order to create an object of that type we will have to make changes inside PaymentEventConsumer: add a new branch to the if/else structure, add new dependencies, and so on. As a result, our class will both become bloated and have to be re-tested.
Ultimately, the problem is that PaymentEventConsumer itself holds far too many responsibilities, and that number is likely to keep growing.
1.2 How can we eliminate the problem of the solution without the pattern?
By applying Factory Method here, we can take the object creation responsibility out of PaymentEventConsumer and delegate it to subclasses. This way, PaymentEventConsumer is freed from the responsibility of creating the CashbackTransactionobject, and will merely drive the flow of the process through an abstraction.
Note: Naturally, it is also possible to reduce these responsibilities with completely different designs. However, the goal of this article is to demonstrate the application of Factory Method.
2. Applying the pattern without Spring dependencies.
2.1 Defining the Product abstraction:
public interface CashbackTransaction {
String getCustomerId();
BigDecimal getAmount();
String describe();
}2.2 Creating the Concrete Products:
@Getter
@Builder
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class AwardingTransaction implements CashbackTransaction {
String customerId;
BigDecimal amount;
String paymentId;
@Override
public BigDecimal getAmount() {
return amount;
}
@Override
public String describe() {
return String.format("Cashback of %s awarded for payment %s",amount,paymentId);
}
}
@Getter
@Builder
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class ReversingTransaction implements CashbackTransaction {
String customerId;
BigDecimal amount;
String awardedTxnId;
@Override
public BigDecimal getAmount() {
return amount.negate();
}
@Override
public String describe() {
return String.format("Cashback of %s reversed for transaction %s",amount,awardedTxnId);
}
}
@Getter
@Builder
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class RefundTransaction implements CashbackTransaction {
String customerId;
BigDecimal amount;
String refundReason;
@Override
public BigDecimal getAmount() {
return amount.negate();
}
@Override
public String describe() {
return String.format("Cashback of %s refunded, reason: %s",amount,refundReason);
}
}Here CashbackTransaction is the Product, while AwardingTransaction, ReversingTransaction and RefundTransaction are its Concrete Products.
Note: Fields such as customerId and amount could in fact have been kept in a parent class. Honestly, I just didn’t feel like it, so they stayed as they are :)
2.3 Defining the Creator abstraction:
public abstract class TransactionCreator {
public abstract CashbackTransaction createTransaction(PaymentEvent event);
}createTransaction() is the Factory Method here. TransactionCreator only defines which method is going to be used, and leaves how the concrete CashbackTransaction is created to the subclasses.
2.4 Creating the Concrete Creators:
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class AwardingTransactionCreator extends TransactionCreator {
CashbackRuleService cashbackRuleService;
@Override
public CashbackTransaction createTransaction(PaymentEvent event) {
BigDecimal originalCashback = cashbackRuleService.calculate(event);
return AwardingTransaction.builder()
.customerId(event.getCustomerId())
.amount(originalCashback)
.paymentId(event.getPaymentId())
.build();
}
}
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class ReversingTransactionCreator extends TransactionCreator {
CashbackLookupService cashbackLookupService;
@Override
public CashbackTransaction createTransaction(PaymentEvent event) {
BigDecimal originalCashback = cashbackLookupService.findAwardedAmount(event.getAwardedTxnId());
return ReversingTransaction.builder()
.customerId(event.getCustomerId())
.amount(originalCashback)
.awardedTxnId(event.getAwardedTxnId())
.build();
}
}
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class RefundTransactionCreator extends TransactionCreator {
CashbackLookupService cashbackLookupService;
@Override
public CashbackTransaction createTransaction(PaymentEvent event) {
BigDecimal originalCashback = cashbackLookupService.findAwardedAmount(event.getPaymentId());
return RefundTransaction.builder()
.customerId(event.getCustomerId())
.amount(originalCashback)
.refundReason(event.getRefundReason())
.build();
}
}2.5 Selecting the appropriate implementation with a Simple Factory mechanism
Now, if we look at TransactionCreatorFactory, we will see that the logic here consists of selecting the appropriate Creator based on the type passed in from the orchestrator. In practice, this approach is called Simple Factory, not Factory Method. It does not appear as a separate pattern among GoF’s official 23 Design Patterns, but it is a creation idiom that is widely used in practical Java applications.
Note: A question may come up here: the previous version had an if, and here it became a switch. What did we gain? The answer is that this process used to live inside the orchestrator, whereas now it lives inside a class dedicated specifically to making this selection. In other words, if a new change comes along, we will be making the change here (the technical side) rather than in the orchestrator (the business side) class.
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class TransactionCreatorFactory {
CashbackRuleService cashbackRuleService;
CashbackLookupService cashbackLookupService;
public TransactionCreator getCreator(PaymentEventType type) {
return switch (type) {
case COMPLETED -> new AwardingTransactionCreator(cashbackRuleService);
case REVERSING -> new ReversingTransactionCreator(cashbackLookupService);
case REFUND -> new RefundTransactionCreator(cashbackLookupService);
default -> throw new UnsupportedPaymentTypeException(type);
};
}
}2.6 Result
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class PaymentEventConsumer {
TransactionCreatorFactory transactionCreatorFactory;
CashbackBalanceService cashbackBalanceService;
CashbackAuditLogger cashbackAuditLogger;
public void consumePayment(PaymentEvent event) {
TransactionCreator creator = transactionCreatorFactory.getCreator(event.getType());
CashbackTransaction transaction = creator.createTransaction(event);
cashbackBalanceService.applyDelta(transaction.getCustomerId(),transaction.getAmount());
cashbackAuditLogger.log(transaction.describe());
}
}Here PaymentEventConsumer no longer creates the AwardingTransaction, ReversingTransaction or RefundTransaction classes directly. It works only with the TransactionCreator abstraction and calls the createTransaction()factory method.
Get Elmir Taghisoy’s stories in your inbox
Join Medium for free to get updates from this writer.
As a result, how the concrete Product is created is of no importance to PaymentEventConsumer, and as you can see, unlike in the previous example, the consumer’s responsibility has been reduced considerably.
3. Applying the pattern with Spring dependencies.
3.1 Defining the Creator abstraction:
public interface TransactionCreator {
PaymentEventType getSupportedType();
CashbackTransaction createTransaction(PaymentEvent event);
}- getSupportedType() — states which PaymentEventType the Creator works for.
- createTransaction() — is the Factory Method.
3.2 Creating the Concrete Creators:
@Component
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class AwardingTransactionCreator implements TransactionCreator {
CashbackRuleService cashbackRuleService;
@Override
public PaymentEventType getSupportedType() {
return PaymentEventType.COMPLETED;
}
@Override
public CashbackTransaction createTransaction(PaymentEvent event) {
BigDecimal originalCashback = cashbackRuleService.calculate(event);
return AwardingTransaction.builder()
.customerId(event.getCustomerId())
.amount(originalCashback)
.paymentId(event.getPaymentId())
.build();
}
}
@Component
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class ReversingTransactionCreator implements TransactionCreator {
CashbackLookupService cashbackLookupService;
@Override
public PaymentEventType getSupportedType() {
return PaymentEventType.REVERSING;
}
@Override
public CashbackTransaction createTransaction(PaymentEvent event) {
BigDecimal originalCashback = cashbackLookupService.findAwardedAmount(event.getAwardedTxnId());
return ReversingTransaction.builder()
.customerId(event.getCustomerId())
.amount(originalCashback)
.awardedTxnId(event.getAwardedTxnId())
.build();
}
}
@Component
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class RefundTransactionCreator implements TransactionCreator {
CashbackLookupService cashbackLookupService;
@Override
public PaymentEventType getSupportedType() {
return PaymentEventType.REFUND;
}
@Override
public CashbackTransaction createTransaction(PaymentEvent event) {
BigDecimal originalCashback = cashbackLookupService.findAwardedAmount(event.getPaymentId());
return RefundTransaction.builder()
.customerId(event.getCustomerId())
.amount(originalCashback)
.refundReason(event.getRefundReason())
.build();
}
}3.3 Selecting the appropriate implementation with the Spring Bean Registry
Now let’s look at the main part, TransactionCreatorRegistry. The core responsibility of this component is the same as that of TransactionCreatorFactory in the previous example. The only difference is that in the previous example we created the objects in a hardcoded way, whereas in this example Spring injects all TransactionCreator beans into the Registry.
@Component
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class TransactionCreatorRegistry {
List<TransactionCreator> creators;
public TransactionCreator getCreator(PaymentEventType type) {
return creators.stream()
.filter(creator -> creator.getSupportedType() == type)
.findFirst()
.orElseThrow(() -> new UnsupportedPaymentTypeException(type));
}
}3.4 Result
PaymentEventConsumer no longer knows about the concrete Creators or their dependencies:
@Component
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class PaymentEventConsumer {
CashbackBalanceService cashbackBalanceService;
CashbackAuditLogger cashbackAuditLogger;
TransactionCreatorRegistry transactionCreatorRegistry;
@KafkaListener(topics = "payment-events")
public void consumePayment(PaymentEvent event) {
TransactionCreator creator = transactionCreatorRegistry.getCreator(event.getType());
CashbackTransaction transaction =creator.createTransaction(event);
cashbackBalanceService.applyDelta(transaction.getCustomerId(),transaction.getAmount());
cashbackAuditLogger.log(transaction.describe());
}
}At this point, inside PaymentEventConsumer there is:
- No deciding, based on the type, which CashbackTransaction object is to be created.
- No obtaining of the data required for each type from the appropriate services.
- No determining of the balance change.
- No composing of the audit message.
- No applying of the created object to the balance and audit services.
As a result, PaymentEventConsumer merely drives the process through abstractions. And every abstraction manages its own responsibility itself.
4. When should it not be used?
The choice of a pattern should depend not only on the number of types, but on how the system is going to change in the future.
- If PaymentEventType is stable and no new types are expected to be added, a plain switch may be more readable.
- In a small POC or a short-lived project, extra Creator/Registry abstractions may create more structure than is actually needed.
- If the creation logic of the different types is practically identical, creating separate Creator classes may introduce extra complexity.
- Even in a project where Spring is used, there is no need to create a Registry for every problem. If the selection is very simple and stable, a more explicit approach may be a better fit.
Using a pattern should not be a goal merely for the sake of “having used a GoF pattern”. The value of a pattern emerges when it solves a real problem of change and separation of responsibilities.
5. Summary
Advantages
- The creation responsibility is separated — PaymentEventConsumer does not know how the concrete Product is created.
- Loose coupling emerges — PaymentEventConsumer works with abstractions, and its dependency on concrete implementations decreases.
- Business logic is localized — the creation and behaviour belonging to each transaction type is kept in its own corresponding class.
- Dependencies are separated better — in the Spring version, each Creator receives only the services it actually needs.
- Testing becomes easier — Creators can be tested in isolation by mocking/stubbing their dependencies.
- Extension is more convenient in the Spring version — when a new Creator is added, no new switch branch has to be written in the existing Registry or in PaymentEventConsumer.
Drawbacks
- The number of classes grows — a Creator and a Product class may appear for every new behaviour.
- It can be overengineering in simple cases — for just a few stable types, a plain switch may be enough.
- The number of abstractions grows — Creator, Product, the concrete implementations and, in the Spring version, the Registry add extra structure.
- The Spring version requires framework knowledge — List<TransactionCreator> injection and the Registry approach may require extra learning on the team’s part.
6. An addendum on TransactionCreatorRegistry
In Spring, the registry can also be written in the following form.
@Component
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class TransactionCreatorRegistry {
Map<PaymentEventType, TransactionCreator> creatorMap;
public TransactionCreatorRegistry(List<TransactionCreator> creators) {
this.creatorMap = creators.stream()
.collect(Collectors.toMap(TransactionCreator::getSupportedType, Function.identity()));
}
public TransactionCreator getCreator(PaymentEventType type) {
TransactionCreator creator = creatorMap.get(type);
if (creator == null) {
throw new UnsupportedPaymentTypeException(type);
}
return creator;
}
}Both approaches are correct.
- List + stream().filter() — checks the Creators on every call and finds the matching one: O(n). The code is simpler and more readable.
- Map — the Creators are turned into a Map at startup, and subsequent lookups are O(1) on average. This may be a better fit in systems with a larger number of Creators and a high lookup frequency.
In practice, the number of Creators is usually small. For that reason, with only a few Creators the performance difference between List + filter and Map is often insignificant. The choice here mostly depends on the simplicity of the code and the lookup requirements.










