Creating an object is sometimes just allocation plus a few obvious values. In that case, direct construction is easy to read and easy to maintain. But construction can gradually become a decision process: choose an implementation, supply required collaborators, apply defaults, validate combinations, and ensure every caller assembles the object the same way.

When that policy is copied across callers, a change to construction becomes a search-and-edit exercise. One caller may miss a new dependency or keep an obsolete default even though the resulting objects share the same conceptual role.

A factory is a function, method, or object whose job is to create another object. Its main value is not hiding the new keyword. It is giving construction policy one clear home. This article explains how to recognize that policy, design a focused factory, and avoid adding a factory when direct construction already says everything the reader needs to know.

Separate construction data from construction policy

Start with a small example. A report exporter needs a formatter and a destination:

exporter = ReportExporter(
    MarkdownFormatter(),
    FileDestination("/reports")
)

This is clear. The caller can see exactly what exists, and there is no meaningful decision to centralize.

Now imagine that several entry points build exporters from application configuration:

if config.format == "markdown":
    formatter = MarkdownFormatter()
else if config.format == "plain_text":
    formatter = PlainTextFormatter()
else:
    raise UnsupportedFormat(config.format)

if config.outputDirectory is missing:
    destination = FileDestination("/var/app/reports")
else:
    destination = FileDestination(config.outputDirectory)

exporter = ReportExporter(formatter, destination)

The input values are construction data: requested format and optional output directory. The rules that interpret those values are construction policy: which formatter corresponds to each name, what the default directory is, and which combinations are accepted.

If several callers reproduce those rules, they do not merely duplicate syntax. They duplicate knowledge that must change together.

A factory can contain that knowledge:

exporter = exporterFactory.create(config)

The caller now asks for a configured exporter. The factory owns the rules for assembling one.

That is the core mental model:

Introduce a factory when creating an object requires decisions that should change in one place rather than in every caller.

Keep the smallest useful factory small

A factory does not need a class hierarchy or a framework. A function is enough when the policy is simple:

function createExporter(config):
    formatter = createFormatter(config.format)
    directory = config.outputDirectory ?? "/var/app/reports"
    destination = FileDestination(directory)
    return ReportExporter(formatter, destination)

The important improvement is ownership. Callers no longer need to know the mapping from format names to formatter implementations or the default output directory.

A dedicated factory object becomes useful when construction itself needs collaborators. For example, a factory may need a credential provider to construct clients or a registry that contains supported implementations:

class ExporterFactory:
    constructor(formatterRegistry, defaultDirectory)

    function create(config):
        formatter = formatterRegistry.get(config.format)
        directory = config.outputDirectory ?? defaultDirectory
        return ReportExporter(
            formatter,
            FileDestination(directory)
        )

Do not promote the function to a class merely because the pattern is called Factory. Choose the smallest form that gives the policy a clear owner.

Decide what the factory promises

A useful factory has a stronger contract than “returns some object.” Callers need to know what is guaranteed about the returned value.

For the exporter example, the contract might be:

  • every returned exporter has a supported formatter;
  • every returned exporter has a destination;
  • an omitted directory uses the application’s configured default;
  • an unsupported format is rejected during construction.

Those guarantees matter because they reduce defensive work later. If ReportExporter can only be created through paths that establish its required collaborators, its methods do not need to repeatedly check whether a formatter or destination exists.

However, decide carefully where each invariant belongs. If every ReportExporter must have a formatter, its constructor should still reject a missing formatter when the language and design permit it. A factory is not a substitute for protecting invariants inside the type itself.

The distinction is useful:

constructor: protects rules true for every instance
factory:     chooses how this application builds an instance

For example, “formatter must exist” is an object invariant. “use Markdown when configuration says markdown” is application construction policy.

Put variant selection at the construction boundary

Factories are particularly useful when callers should depend on a common capability but configuration selects the concrete implementation.

Suppose the application supports local and remote report storage:

interface ReportStore:
    function save(report)

The factory can translate external configuration once:

function createReportStore(config):
    if config.storeType == "local":
        return LocalReportStore(config.directory)

    if config.storeType == "remote":
        return RemoteReportStore(
            config.endpoint,
            credentialProvider.credentialsFor(config.endpoint)
        )

    raise UnsupportedStoreType(config.storeType)

Application code receives a ReportStore and uses save. It does not need to switch on storeType again.

The type decision has not disappeared. It has been placed where external representation becomes an internal object. This keeps configuration terminology from spreading into code that only needs storage behavior.

Be explicit about unknown values. Silently falling back from an unrecognized store type to local storage could send data to an unintended place. If fallback behavior is valid for the product, make it part of the factory’s documented contract. Otherwise, fail construction with an actionable error.

Avoid turning the factory into a service locator

A focused factory creates a known family of objects. A service locator is different: callers ask a general registry for arbitrary dependencies whenever they need them.

Compare these two shapes:

exporter = exporterFactory.create(config)

and:

formatter = services.get("formatter")
destination = services.get("destination")
logger = services.get("logger")
exporter = ReportExporter(formatter, destination, logger)

The first call expresses a specific capability: create an exporter according to the application’s construction rules. The second makes the caller responsible for discovering and assembling dependencies through a broad registry.

A factory should not become an excuse to hide every dependency behind get(name). If an object needs a logger, formatter, and destination, those dependencies should remain visible in the object’s construction contract even if the factory supplies them.

This visibility helps tests and maintenance. A developer can inspect the constructor and understand what the object needs without knowing the contents of a global container.

Keep runtime work out of construction when possible

Another common failure mode is letting a factory perform large amounts of unrelated work before returning an object.

For example:

function createRemoteStore(config):
    client = RemoteClient(config.endpoint)
    client.uploadPendingReports()
    client.deleteExpiredReports()
    return RemoteReportStore(client)

The name suggests construction, but the function also changes remote state. That makes creation surprising and makes failures harder to reason about. Did object construction fail, or did maintenance work fail after a usable client already existed?

Prefer construction that establishes dependencies and valid initial state. Put operational actions behind explicit operations:

store = createRemoteStore(config)
store.uploadPendingReports()

Some resources genuinely require work during creation, such as opening a file or establishing a connection. When that is necessary, make the failure behavior part of the factory contract. Callers should know whether creation can perform I/O, how long it may block, and what kind of failure it reports.

The general principle is not “factories must be pure.” It is “construction should not conceal unrelated effects.”

Do not let the factory absorb business decisions

Because a factory already contains conditionals, it can attract rules that do not belong there.

Suppose premium customers should receive a different report layout. It may be tempting to write:

if customer.isPremium:
    return ReportExporter(PremiumFormatter(), destination)

Whether that belongs in the factory depends on what the rule means. If PremiumFormatter is simply an implementation selected by application configuration, construction policy may be appropriate. If premium formatting is a business rule that changes with subscription behavior, hiding it inside object assembly makes that rule harder to find and test as a business decision.

A useful boundary is:

business decision -> construction request -> factory -> object

The business layer can decide that the requested style is premium. The factory can know how to construct the formatter that implements that style. This keeps “who qualifies for premium?” separate from “how do we build a premium formatter?”

Test policy at the factory boundary

Factory tests should focus on construction decisions rather than retesting the full behavior of every created object.

For the exporter factory, useful cases include:

markdown config  -> exporter uses MarkdownFormatter
plain-text config -> exporter uses PlainTextFormatter
missing directory -> configured default is used
unknown format    -> UnsupportedFormat

If the created collaborators are observable only through behavior, test the smallest public behavior that proves the selection. Avoid adding production getters solely so tests can inspect private construction details.

Also test failure paths that are part of the factory’s contract. If creating a remote store requires credentials, verify what happens when credentials are unavailable. The important question is not only whether creation fails, but whether it fails before returning a partially usable object and whether the error gives the caller enough information to respond.

When a factory becomes complex, its tests are a useful design signal. A large matrix of unrelated decisions may mean the factory owns several construction policies that should be separated.

Recognize when one factory is doing too much

A factory can become a second application hidden behind a create method. Warning signs include dozens of unrelated configuration flags, nested decisions for independent subsystems, and returned objects whose dependencies have little to do with one another.

Suppose ApplicationFactory.create() constructs storage, billing, notifications, reporting, caches, and background workers. That may be acceptable as a thin composition root—the place where the application is assembled—but it should mostly delegate to narrower construction functions rather than contain every rule itself.

A useful decomposition is by coherent construction responsibility:

createReportStore(config.storage)
createNotifier(config.notifications)
createExporter(config.reporting)

Each factory owns one set of related choices. A top-level composition function can connect the resulting components.

This keeps changes local. Adding a notification transport should not require understanding report-storage construction merely because both happen during startup.

Know when direct construction is better

Do not introduce a factory around every constructor.

This code is already clear:

point = Point(3, 5)

Wrapping it as pointFactory.create(3, 5) adds another name and another place to navigate without containing meaningful policy.

Direct construction is usually preferable when the concrete type is exactly what the caller intends, required values are obvious, defaults are stable and local, and there is no repeated assembly knowledge to protect.

A named construction function can still help when there are several meaningful ways to create the same type. For example, Money.zero(currency) may communicate intent better than a general constructor even though no complex factory object is needed.

The decision should follow the cost of change. If construction rules are duplicated and evolve together, centralization can reduce mistakes. If construction is simple and stable, indirection is a cost without a corresponding benefit.

Conclusion

A factory is most useful when object creation contains policy: selecting implementations, supplying application defaults, assembling required collaborators, or translating external configuration into a valid internal object. Centralizing those decisions keeps callers focused on what they need rather than how the object is currently assembled.

Keep object invariants protected by the object, keep business decisions in the business layer, and keep unrelated runtime effects out of construction where practical. Start with a function, introduce a factory object only when it needs its own collaborators or state, and split factories when their responsibilities stop being coherent.

The practical test is simple: if a construction rule changes tomorrow, how many places must change with it? A good factory gives that rule one clear home. If there is no rule to centralize, direct construction is probably the clearer design.