The Type Object Pattern: Replace Bloated Class Hierarchies with Objects

When to use enums, objects, or polymorphism to model variants in Python — using a SaaS subscription system.
Using a SaaS subscription system as a case study, this article breaks down three approaches to modeling domain variants in Python: subclass inheritance, pure data enums, and the Type Object Pattern. Inheritance is intuitive but causes class hierarchies to bloat when subclasses only differ in data. Pure data is clean but scatters knowledge across dictionaries. Type Objects group variant configuration into cohesive objects that better reflect real domain semantics. When variants involve truly different behavior — like fixed vs. usage-based billing — Type Objects can be combined with Python Protocols to manage configuration and behavioral variation separately. The guiding principle: labels call for enums, data and rules call for objects, and different behaviors call for polymorphism.
When building software, we often encounter situations where a single domain concept has multiple variants. Take a SaaS subscription system as an example: a Free tier, a Pro tier, and a Business tier — each offering different features, pricing, project limits, and storage. When faced with this kind of variation, many developers reach instinctively for inheritance — creating a subclass for each tier. But is that really the best approach?
This article, based on a deep-dive design analysis from the Iron Codes (ArjanCodes) channel, breaks down three ways to model these kinds of variants in Python, and discusses when each approach is appropriate. The core takeaway isn't "one pattern is always better" — it's about making decisions based on what exactly is changing.
Inheritance: Intuitive but Easy to Lose Control
The most common initial approach is subclassing. You have a Subscription base class with attributes like customer ID, start date, monthly fee, max projects, storage, and a feature list — plus a handful of methods. Then you create subclasses for each tier: FreeSubscription, ProSubscription, BusinessSubscription, each just setting different values in its __init__.
When the number of tiers is stable — say, just three — this isn't a huge problem. Polymorphism works well: given a subscription object, whether it's Free or Pro, you can call methods like "does this support a given feature" or "can the user create a project" without caring about the specific type.
The problem is that these subclasses don't implement fundamentally different algorithms — they just hold different data. Once tiers start multiplying — Starter, Education, Business Plus, Legacy Pro, Enterprise, Flagship — every new product configuration requires a new Python class, and old tiers have to stick around indefinitely.

This raises a fundamental question: if it's just data, why does it need inheritance? Using a class hierarchy to store business configuration is essentially treating the language's type system as a configuration management tool.
Pure Data: Simple but Knowledge Gets Scattered
The second approach is to remove subclasses entirely and define all tiers purely as data. Keep the Feature enum, add a PlanType enum, then use dictionaries to define each tier's attributes separately: a features-per-plan dict, a monthly-price-per-plan dict, a max-projects-per-plan dict, and so on.
Adding a new tier just means adding an entry to the data. This looks simpler, and when the number of configuration dimensions is small, it's actually good enough.
But the drawbacks accumulate over time. Subscriptions tend to grow in complexity as the business evolves — features, pricing, project limits, and storage are just what you have today; more dimensions will be added tomorrow. Worse, each tier's information is scattered across multiple independent dictionaries, fragmenting knowledge. You might forget to define a monthly fee for one tier, causing the app to crash in certain situations.
What if you consolidated everything into one big configuration dictionary? As it turns out, you'd have more or less accidentally invented the Type Object Pattern.
The Type Object Pattern: Make Variants Into Objects
The third approach returns to classes, but in a different way. Define a SubscriptionPlan class — because if you're building a SaaS platform, a "subscription plan" is a legitimate concept in your domain and worth modeling explicitly.
The key difference: instead of a customer's subscription storing a PlanType enum value, it holds a reference to an actual SubscriptionPlan object. You then create a handful of SubscriptionPlan instances — free, pro, business — each containing all the settings for that tier.

We're still treating these as data — we're just using classes (objects) to express that data more effectively in Python. This is the essence of the Type Object Pattern: instead of using the programming language's type system to represent variation, you use objects to represent it.
From a domain modeling perspective, the three designs express three different semantic relationships:
- Inheritance: "A Pro subscription is a subscription" (is-a relationship)
- Enum: "A subscription has a plan type enum value"
- Type Object: "A subscription has a subscription plan" (has-a relationship)
The Type Object version is actually the closest to the real domain. Customers don't hold some special object called a "Pro Subscription" — they hold a subscription to the Pro plan.
The Type Object Pattern was formally described by Ralph Johnson and Bobby Woolf in Pattern Languages of Program Design 3. Its core idea: when you need a group of instances to share the same behavior but differ only in attribute values, rather than creating a subclass for each variant, you create a "type class" to hold that configuration and have the main class hold a reference to a type instance. Adding a new variant then requires instantiating a new object, not modifying the class hierarchy. This pattern is especially common in game development — for example, a
MonsterTypeobject describes the attributes of a "Goblin," and all goblin instances reference the same type object rather than inheriting from aGoblinsubclass. The distinction from the Strategy Pattern: Strategy encapsulates interchangeable algorithms, while Type Object encapsulates interchangeable configuration data. The two can be combined, as shown in the final example of this article.
How to Choose? It Depends on What's Changing
The author repeatedly emphasizes: the Type Object Pattern is not automatically the better choice. The decision should be based on — what exactly is changing?
- If the variation is just a label or category — like subscription status (active, overdue, cancelled) — an enum or simple value is sufficient. There's no need to make it a full object.
- If the variation carries data and rules — like a subscription plan with pricing, project limits, storage constraints, and a feature set — then you should consider making the variants into objects. Objects are exactly the right tool for grouping this kind of related information.
- If the variation represents fundamentally different behavioral implementations, then polymorphism via classes or protocols is the right tool.
The Power of Composition: Type Object + Protocol
Subclassing is exactly what you need in certain scenarios. Suppose the subscription system needs to support fundamentally different billing mechanisms: fixed-price subscriptions charge the same amount each month, while usage-based billing collects usage data and computes the final price. These aren't just different configurations — they're genuinely different behavior.

The author presents a combined approach: define a PricingModel protocol, with FixedPricing and UsageBasedPricing each implementing it. Then SubscriptionPlan holds a reference to a PricingModel — the Pro plan uses fixed billing, while the Business plan can use usage-based billing.
This uses both the Type Object Pattern (representing plan variants as objects) and protocols (representing different billing behaviors). It's a very powerful combination: we haven't eliminated polymorphism — we've put each kind of variation exactly where it belongs.
Python's Protocol (from PEP 544, introduced in Python 3.8) implements structural subtyping — also known as "statically-typed duck typing." Unlike abstract base classes (ABCs), which require explicit inheritance, Protocol only requires that an object structurally satisfy the interface — meaning it has the required method and attribute signatures — with no inheritance declaration needed. In the pricing model scenario,
FixedPricingandUsageBasedPricingdon't need to inherit from any common parent; they just each implement the agreed-uponcalculatemethod, and type checkers like mypy or Pyright can statically verify correctness. This approach fully decouples the billing implementations from each other and makes them easier to test independently. Combining Protocol with Type Object is a concrete embodiment of the "composition over inheritance" principle: use has-a relationships to manage configuration variants, and structural polymorphism to manage behavioral variants — each dimension of change in its proper place.
Engineering in Practice: Where Does the Data Live?
One unavoidable practical question: where do these plan definitions and settings actually live?
If plans are fixed and only change at deployment time (which is true for many SaaS platforms), the simplest approach is to define immutable objects in a dedicated plans.py file and import them wherever needed.

For larger applications where plan configurations need to change without deploying new code, these objects can be loaded from a database — subscription records store a customer ID and a plan ID, a plans table stores the plan definitions, and a join table stores the feature list. Adding a new plan just means inserting a new database record.
But the database approach has costs: if you store available features per customer individually, product evolution can lead to data inconsistencies (some customers may lose access to features if updates are missed); there are more combinations to test; and frequently reading configuration from the database can slow down the application. Flexibility is purchased at the cost of complexity.
Summary
When you encounter a source of variation in your domain, don't reflexively reach for inheritance or the Type Object Pattern. First, think clearly: is the variation a value? Domain data and rules? Or genuinely different behavior? Then decide on the implementation. The ability to understand when to use which design is far more valuable than memorizing a list of pattern names.
Related articles

From the Afghan Battlefield to Microsoft Research: Chris White's Path to Tech for Good
Microsoft Research Catalyst Lab lead Chris White shares his journey from Harvard postdoc to the Afghan battlefield, DARPA's Memex dark web search project, and driving tech for good at Microsoft—exploring problem selection, humble service, and holding onto hope in the dark.

Opus 5.5 Refuses to Ghostwrite Paper Section: Why Users Applaud This AI Guardrail
A Reddit user shares how Opus 5.5 refused to ghostwrite a dissertation paragraph: the model identified school AI restrictions, explained academic integrity risks, and returned decision-making power to the user. Why is this "explained refusal" called an ideal guardrail? This article analyzes the three-layer logic of AI guardrail design and product implications.

Google Open-Sources TorchTPU: How a PyTorch Native Backend Reshapes the TPU Ecosystem
Google and Meta open-source TorchTPU, a PyTorch native backend for Cloud TPU with eager execution by default and torch.compile/XLA support, set to replace PyTorch/XLA for new workloads. This article analyzes its architecture, execution trade-offs, and impact on developers and the TPU ecosystem.