Loading learning content...
Every object-oriented program creates objects. It's the most fundamental operation in OOP—you need objects to encapsulate data and behavior. But here's the paradox that experienced engineers encounter repeatedly: the act of creating objects can become the biggest source of rigidity and coupling in your entire codebase.
This isn't an abstract concern. At its core, the problem is simple: when you write new ConcreteClass(), you've hard-coded a dependency on that specific class. You've made a commitment at compile-time that cannot be changed without modifying source code. And in large systems, these commitments accumulate until your architecture becomes brittle, untestable, and resistant to change.
By the end of this page, you'll understand exactly why direct object instantiation creates architectural problems. You'll see how the new keyword, despite being the simplest way to create objects, can become an anti-pattern when used carelessly. Most importantly, you'll understand the problem deeply enough to appreciate why the Factory Method pattern exists.
Let's start with a scenario every developer has experienced. You're building an application that processes documents. The first requirement is to support PDF documents, so you write:
class DocumentProcessor {
processDocument(path: string): void {
const document = new PdfDocument(path);
document.parse();
document.validate();
document.render();
}
}
This code is clean, simple, and works perfectly. Then the inevitable happens: a new requirement arrives. Now you need to support Word documents too. And Excel. And HTML. And Markdown.
Suddenly, your simple method becomes this:
123456789101112131415161718192021222324
class DocumentProcessor { processDocument(path: string, type: string): void { let document: Document; // The cascading conditional anti-pattern if (type === 'pdf') { document = new PdfDocument(path); } else if (type === 'word') { document = new WordDocument(path); } else if (type === 'excel') { document = new ExcelDocument(path); } else if (type === 'html') { document = new HtmlDocument(path); } else if (type === 'markdown') { document = new MarkdownDocument(path); } else { throw new Error(`Unsupported document type: ${type}`); } document.parse(); document.validate(); document.render(); }}What went wrong?
Look carefully at this code. The DocumentProcessor class now has multiple problems:
DocumentProcessor class. The class is not closed for modification.ExcelDocument changes its constructor signature, DocumentProcessor must be recompiled—even if you're only processing PDFs.DocumentProcessor in isolation. To test it, you need all concrete document classes available.This isn't just about one class being messy. In real systems, this pattern of direct instantiation spreads. If documents are created in 10 different places, you have 10 switch statements to update when adding a new document type. Miss one, and you have a bug. This is how technical debt compounds.
To truly understand why direct instantiation is problematic, we need to understand what happens when you write new SomeClass().
The new keyword creates a compile-time binding. This means:
SomeClass at compile timeContrast this with run-time binding, where the decision of which class to instantiate is deferred until the program executes. This is exactly what polymorphism provides for method calls—but new doesn't participate in polymorphism.
| Aspect | Compile-Time Binding (new) | Run-Time Binding (Polymorphism) |
|---|---|---|
| When decision is made | When code is written/compiled | When code executes |
| Flexibility | Cannot change without modifying code | Can substitute implementations freely |
| Testing | Requires real classes | Can substitute mocks/stubs |
| Dependencies | Hard dependency on concrete class | Depends only on interface/abstraction |
| Extension | Requires modifying existing code | Add new implementations without modification |
A concrete example:
Consider a payment processing system:
12345678910111213141516
// ❌ Compile-time binding - inflexibleclass OrderService { checkout(order: Order): void { // This line creates a hard dependency on StripePaymentProcessor const processor = new StripePaymentProcessor(); processor.processPayment(order.total); }} // What if you need:// - PayPal for international orders?// - A mock processor for testing?// - Square for in-person transactions?// - A different processor for enterprise clients? // You're stuck. The decision is baked into the code.The OrderService doesn't just depend on StripePaymentProcessor—it depends on Stripe's SDK, their API contract, their connection patterns. When Stripe changes their API, OrderService is affected. When you want to test checkout logic, you're making real API calls (or doing complicated mocking of the new operator).
This problem is deeply connected to the Dependency Inversion Principle (DIP), which states that high-level modules should not depend on low-level modules—both should depend on abstractions. The new keyword creates exactly the dependency DIP warns against. Factory Method is one of the key patterns that helps achieve dependency inversion.
One of the most insidious aspects of direct instantiation is how it creates transitive dependencies. When class A creates class B, class A inherits all of B's dependencies.
Let's trace through a realistic example. Imagine building a reporting system:
123456789101112131415161718192021222324252627282930
// Level 1: ReportGenerator creates PdfExporterclass ReportGenerator { generateMonthlyReport(data: ReportData): void { // Direct instantiation const exporter = new PdfExporter(); const report = this.compile(data); exporter.export(report); }} // Level 2: PdfExporter creates FontManager and ImageProcessorclass PdfExporter implements Exporter { private fontManager = new FontManager(); private imageProcessor = new ImageProcessor(); export(report: Report): void { // ... uses fontManager and imageProcessor }} // Level 3: FontManager creates FileSystemFontLoaderclass FontManager { private loader = new FileSystemFontLoader(); // ...} // Level 4: FileSystemFontLoader uses Node's fs moduleclass FileSystemFontLoader { // Depends on file system, font parsing libraries, etc.}The transitive dependency chain:
ReportGenerator → PdfExporter → FontManager → FileSystemFontLoader → Node.js fs module
Because of direct instantiation at each level:
new calls.If you drew a dependency graph of classes connected by new calls, you'd see a tightly coupled web. Each class is bound not just to what it creates, but to everything those created objects create. Factory Method helps break these chains by replacing new calls with abstract factory methods that can be overridden.
Direct instantiation creates severe testing challenges. Let's be concrete about what goes wrong.
Scenario: You want to test the NotificationService class that sends notifications through various channels.
123456789101112131415161718192021222324252627282930
class NotificationService { sendAlert(user: User, message: string): void { // Direct instantiation - testing nightmare const emailSender = new SmtpEmailSender({ host: 'smtp.company.com', port: 587, credentials: getEmailCredentials() }); const smsSender = new TwilioSmsSender({ accountSid: getTwilioSid(), authToken: getTwilioToken() }); const pushSender = new FirebasePushSender({ serviceAccount: getFirebaseCredentials() }); // Send through all channels emailSender.send(user.email, message); if (user.phoneNumber) { smsSender.send(user.phoneNumber, message); } if (user.deviceTokens.length > 0) { pushSender.send(user.deviceTokens, message); } }}How do you test this?
To write a unit test for sendAlert, you face these obstacles:
new operator — Use libraries that override language behavior. Fragile and confusing.The fundamental issue is that new is a form of hard-coded configuration. You've specified the exact class at the point of use, eliminating any opportunity for substitution. Factory Method addresses this by moving the new call behind an override-able method, enabling subclasses and configurations to provide different implementations.
Real-world applications need to behave differently based on configuration, environment, or runtime conditions. Direct instantiation makes this difficult or impossible.
Common configuration needs:
| Scenario | Requirement | Direct Instantiation Problem |
|---|---|---|
| Development vs Production | Use a mock payment processor in development | Code always creates real processor |
| A/B Testing | Use different recommendation algorithms for different user cohorts | Algorithm is hard-coded |
| Multi-tenant SaaS | Different tenants get different storage backends | Storage class is fixed |
| Feature Flags | Enable new search implementation for 10% of traffic | Search implementation is compiled in |
| Geographic Variation | Use different tax calculators for different countries | Tax calculator is hard-coded |
| Performance Tiers | Premium users get high-performance implementations | Same implementation for all users |
A concrete example: Multi-Region Deployment
Consider an application deployed across multiple regions with different compliance requirements:
12345678910111213141516
class UserService { storeUserData(user: User): void { // ❌ Hard-coded to AWS S3 const storage = new AwsS3Storage({ bucket: 'user-data', region: 'us-east-1' }); storage.store(user.id, user.serialize()); }} // Problems:// 1. EU users' data stored in US (GDPR violation)// 2. Cannot use Azure Blob for Azure-deployed regions// 3. Cannot use local storage for on-premise deployments// 4. Cannot use mock storage for testingThe "solution" with direct instantiation is to add conditionals:
12345678910111213141516171819202122232425262728293031
class UserService { storeUserData(user: User): void { let storage: Storage; // ❌ This gets worse over time if (process.env.REGION === 'eu-west-1') { storage = new AwsS3Storage({ bucket: 'user-data-eu', region: 'eu-west-1' }); } else if (process.env.REGION === 'azure-westeurope') { storage = new AzureBlobStorage({ container: 'user-data', account: 'europeaccount' }); } else if (process.env.NODE_ENV === 'test') { storage = new InMemoryStorage(); } else if (process.env.DEPLOYMENT === 'on-premise') { storage = new LocalFileStorage({ path: '/data/users' }); } else { storage = new AwsS3Storage({ bucket: 'user-data', region: 'us-east-1' }); } storage.store(user.id, user.serialize()); }}This pattern repeats everywhere storage is created. Now you have N classes with identical switch statements. Adding a new region means updating every single one. Miss one location, ship a bug. This is the configuration nightmare that Factory Method solves.
Perhaps the most severe limitation of direct instantiation emerges when you need to extend a system you don't control.
Scenario: Using a Third-Party Framework
You're using an open-source logging framework:
123456789101112131415161718192021222324
// Third-party framework code (you cannot modify)class LoggingFramework { log(message: string, level: LogLevel): void { // Direct instantiation - you cannot change this const formatter = new StandardLogFormatter(); const writer = new FileLogWriter('/var/log/app.log'); const formatted = formatter.format(message, level); writer.write(formatted); }} // You want to:// 1. Use JSON format instead of standard format// 2. Write to CloudWatch instead of local file// 3. Add custom fields like request ID, user ID, etc.// 4. Filter sensitive data before logging // But you CAN'T because the framework uses direct instantiation.// Your only options:// - Fork the entire framework (maintenance burden)// - Wrap it and lose functionality (awkward)// - Find a different framework (migration cost)// - Accept the limitations (compromise)Contrast with a Factory Method approach:
12345678910111213141516171819202122232425262728293031
// Same framework, but designed for extensibilityabstract class LoggingFramework { log(message: string, level: LogLevel): void { const formatter = this.createFormatter(); const writer = this.createWriter(); const formatted = formatter.format(message, level); writer.write(formatted); } // Factory Methods - subclasses control creation protected abstract createFormatter(): LogFormatter; protected abstract createWriter(): LogWriter;} // Now you can extend without modifying:class CustomLoggingFramework extends LoggingFramework { protected createFormatter(): LogFormatter { return new JsonLogFormatter({ includeTimestamp: true, includeRequestId: true }); } protected createWriter(): LogWriter { return new CloudWatchLogWriter({ logGroup: '/app/production', region: 'us-east-1' }); }}Factory Method enables the Open/Closed Principle: "Software entities should be open for extension but closed for modification." By deferring object creation to overridable methods, frameworks become extensible without requiring modifications to their source code.
Let's examine how direct instantiation has caused real problems in production systems. These aren't hypothetical scenarios—they're patterns that recur across industries.
A major e-commerce company needed to migrate from Oracle to PostgreSQL. Their data access layer used direct instantiation (new OracleConnection()). The migration took 18 months and required modifying over 400 files. If they had used factory methods, the migration would have been a configuration change.
A SaaS startup hard-coded Stripe throughout their codebase using new Stripe(). When they expanded internationally and needed to support local payment providers (Adyen, PayU, Razorpay), they discovered Stripe dependencies in 150+ locations. Each required careful refactoring with high regression risk.
A fintech company's test suite took 45 minutes to run because every test made real API calls to banking integrations (direct instantiation of API clients). They couldn't mock the integrations without major refactoring. Developer productivity suffered for years until they invested in decoupling object creation.
The pattern across all cases:
The common thread is that short-term convenience of new creates long-term inflexibility. The cost isn't visible until you need to change something that was assumed to be constant.
We've now thoroughly understood the problem that Factory Method addresses. Let's consolidate our findings:
new keyword creates hard dependencies on concrete classes that cannot be changed at runtime.The Core Problem Statement:
How do we create objects without specifying the exact class of object that will be created, while still maintaining type safety and enabling polymorphic behavior?
This is the problem the Factory Method pattern solves. In the next page, we'll explore the solution: defining an interface for creating objects, but letting subclasses decide which class to instantiate.
You now understand the fundamental problems with direct object instantiation in object-oriented design. These aren't theoretical concerns—they're practical issues that affect maintainability, testability, and extensibility of real systems. Next, we'll see how Factory Method provides an elegant solution to these challenges.