These constructs change how behavior is discovered or resolved at runtime and therefore require strong justification:
ABC@abstractmethod- inheritance
Protocol- multiple inheritance
- Factory
- Strategy
- Registry
- plugin architecture
- multiple abstraction layers
These constructs can be appropriate, but their use should be justified by the specific problem they solve:
classproperty- getter/setter
super()- dependency injection
- decorator
These constructs generally do not require additional justification when used appropriately:
- function
dataclassNamedTupleTypedDictEnum
Use a class when state and behavior need to remain associated, or when the object must enforce invariants or represent a meaningful lifecycle.
A class created merely to group a sequence of transformations:
class FeatureProcessor:
def __init__(self, df):
self.df = df
def clean(self):
self.df = self.df.dropna()
return self
def normalize(self):
self.df["age"] = self.df["age"] / 100
return self
def encode(self):
self.df = encode_categories(self.df)
return self
def result(self):
return self.dfUsage:
result = (
FeatureProcessor(df)
.clean()
.normalize()
.encode()
.result()
)The intermediate state is hidden inside the instance.
Represent each transformation as a function:
def clean_data(df):
return df.dropna()
def normalize_data(df):
result = df.copy()
result["age"] = result["age"] / 100
return result
def encode_data(df):
return encode_categories(df)The pipeline becomes explicit:
result = clean_data(df)
result = normalize_data(result)
result = encode_data(result)- The object represents a meaningful domain entity or state.
- State must persist across multiple operations.
- The class must enforce invariants.
- The object has a meaningful lifecycle.
- Methods naturally operate on the same instance state.
Use an abstract base class only when multiple concrete subclasses must conform to a common interface and the base class provides meaningful shared behavior or state.
from abc import ABC, abstractmethod
class BasePreprocessor(ABC):
def validate(self, df):
if df.empty:
raise ValueError("Empty dataset")
def log(self, message):
logger.info(message)
@abstractmethod
def process(self, df):
...
def save(self, df, path):
df.to_parquet(path)The base class starts as an interface for process() but gradually accumulates unrelated preprocessing functionality.
Use functions and explicit composition:
def validate_data(df):
if df.empty:
raise ValueError("Empty dataset")
def preprocess_training_data(df):
validate_data(df)
df = clean_data(df)
return create_training_features(df)
def preprocess_inference_data(df):
validate_data(df)
df = clean_data(df)
return create_inference_features(df)- Multiple concrete subclasses exist or are an immediate requirement.
- A consumer works against the common base type.
- The consumer needs polymorphism.
- Shared implementation or instance state belongs in the base class.
- The base class represents a coherent abstraction rather than a collection of convenience methods.
Use @abstractmethod only when a subclass must provide a concrete implementation of behavior that is part of a real polymorphic contract.
from abc import ABC, abstractmethod
class ModelPipeline(ABC):
@abstractmethod
def prepare_data(self, data):
...
@abstractmethod
def train(self, data):
...
@abstractmethod
def evaluate(self, model, data):
...If there is only one concrete implementation:
class CurrentModelPipeline(ModelPipeline):
...the abstract methods do not provide meaningful polymorphism.
Use ordinary functions:
def prepare_data(data):
...
def train(data):
...
def evaluate(model, data):
...- Multiple subclasses provide different implementations.
- A consumer relies on the common interface.
- The variation in behavior is intentional and meaningful.
- The method represents a required part of the subclass contract.
Use inheritance when a subclass is genuinely substitutable for its base class and specialization through method overriding is part of the design.
class BaseTrainer:
def prepare_data(self, data):
...
def train(self, data):
...
def evaluate(self, model, data):
...
class ProductionTrainer(BaseTrainer):
def train(self, data):
data = self.prepare_data(data)
...Then:
class SpecialProductionTrainer(ProductionTrainer):
def prepare_data(self, data):
...To understand the behavior of train(), the reader must navigate the class hierarchy.
Make the data flow explicit:
def prepare_production_data(data):
...
def train_production_model(data):
prepared = prepare_production_data(data)
return fit_model(prepared)- A subclass is substitutable for its base class.
- Downstream code intentionally works with the base type.
- Method overriding represents meaningful specialization.
- The inheritance hierarchy remains shallow and coherent.
- The relationship represents more than code reuse.
Use super() when inherited behavior is intentionally extended as part of a coherent class hierarchy.
class BaseProcessor:
def process(self, data):
data = self.validate(data)
return data
class TrainingProcessor(BaseProcessor):
def process(self, data):
data = super().process(data)
return self.add_training_features(data)
class SpecialTrainingProcessor(TrainingProcessor):
def process(self, data):
data = super().process(data)
return self.apply_special_transform(data)The effective implementation is distributed across:
SpecialTrainingProcessor.process
↓
TrainingProcessor.process
↓
BaseProcessor.process
Make the sequence explicit:
def process_training_data(data):
data = validate_data(data)
data = add_training_features(data)
data = apply_special_transform(data)
return data- The inheritance hierarchy is intentional.
- Extending the parent implementation is part of the design.
- The method resolution order remains easy to understand.
- The inherited behavior is genuinely useful to the subclass.
Use a property when the value is semantically an attribute of the instance and accessing it should behave like attribute access.
A property that hides a significant data transformation:
class Dataset:
@property
def normalized_features(self):
return normalize(self.features)Then:
dataset.normalized_featureslooks like attribute access but actually performs data processing.
Make the transformation explicit:
normalized_features = normalize(dataset.features)- The value is conceptually an attribute of the instance.
- The computation is simple and unsurprising.
- The access does not hide significant processing or I/O.
- A property improves the object's API.
Use a getter or setter when reading or writing an attribute requires validation, invariant enforcement, or encapsulated behavior.
class Model:
def get_model(self):
return self._model
def set_model(self, model):
self._model = modelUsage:
model.set_model(trained_model)
trained = model.get_model()If these methods only read and write the attribute, they add indirection without adding behavior.
Use direct attribute access:
model.model = trained_model
trained = model.modelOr represent the data explicitly:
from dataclasses import dataclass
@dataclass
class TrainedModel:
model: Model
metrics: dict[str, float]- Setting the attribute requires validation.
- The class must preserve an invariant.
- Reading or writing requires meaningful behavior.
- Direct access would expose an implementation detail that must be protected.
Use a factory when object creation contains meaningful selection or construction logic that should be separated from the caller.
A factory that only forwards construction:
class ModelFactory:
@staticmethod
def create(config):
return XGBoostModel(config)Usage:
model = ModelFactory.create(config)The factory adds an additional layer without solving a real creation problem.
Call the constructor directly:
model = XGBoostModel(config)If runtime selection is actually required:
MODEL_TYPES = {
"xgboost": XGBoostModel,
"lightgbm": LightGBMModel,
}
model_class = MODEL_TYPES[model_type]
model = model_class(config)- Multiple concrete types must be selected at runtime.
- Construction requires meaningful logic.
- Object creation has a lifecycle or configuration that should not be exposed to callers.
- The factory removes complexity from the consumer rather than merely moving it.
Use the Strategy pattern when multiple interchangeable behaviors are selected or injected at runtime and the consumer genuinely benefits from treating them uniformly.
from abc import ABC, abstractmethod
class FeatureSelectionStrategy(ABC):
@abstractmethod
def select(self, data):
...
class CorrelationStrategy(FeatureSelectionStrategy):
...
class ImportanceStrategy(FeatureSelectionStrategy):
...
class FeatureSelector:
def __init__(self, strategy):
self.strategy = strategy
def select(self, data):
return self.strategy.select(data)Usage:
selector = FeatureSelector(CorrelationStrategy())
features = selector.select(data)If the strategy is simply a function, the object hierarchy adds unnecessary indirection.
Use functions:
def select_by_correlation(data):
...
def select_by_importance(data):
...Then select the function directly:
selector = select_by_correlation
features = selector(data)- There are multiple interchangeable behaviors.
- The behavior is selected or injected at runtime.
- The consumer should not depend on the concrete implementation.
- Treating the behaviors uniformly simplifies the consumer.
Use a registry when the system genuinely needs dynamic registration and discovery of independently defined components.
class ModelRegistry:
_models = {}
@classmethod
def register(cls, name, model):
cls._models[name] = model
@classmethod
def get(cls, name):
return cls._models[name]And across several modules:
ModelRegistry.register(...)Now behavior depends on which modules have executed registration code.
If the set of implementations is known, use an explicit mapping:
MODELS = {
"xgboost": create_xgboost_model,
"lightgbm": create_lightgbm_model,
}model = MODELS[model_type](config)- Components are independently registered.
- Components need to be discovered dynamically.
- The set of components is intentionally extensible.
- Registration is part of the architecture rather than an implementation convenience.
Use dependency injection when a dependency must be replaceable, independently configured, or isolated from the component that consumes it.
A class receives many dependencies simply because dependency injection is considered a best practice:
class TrainingService:
def __init__(
self,
data_loader,
validator,
feature_engineer,
trainer,
evaluator,
model_repository,
logger,
):
...And the object is passed through multiple layers:
API
↓
Service
↓
Pipeline
↓
Trainer
↓
Repository
without a clear need for those boundaries.
Keep dependencies close to where they are used:
def train_model(config):
data = load_data(config.source)
data = validate_data(data)
features = create_features(data)
model = fit_model(features, config)
metrics = evaluate(model, features)
save_model(model, config.output_path)
return metrics- A dependency must have multiple implementations.
- A dependency must be replaced for testing.
- Infrastructure needs to be separated from application logic.
- Different runtime configurations require different implementations.
- Explicit dependency boundaries reduce coupling.
Keep the number of indirection layers proportional to the architectural boundaries that they represent.
api_handler(
logging_wrapper(
validation_wrapper(
metrics_wrapper(
retry_wrapper(
training_service
)
)
)
)
)Or:
APIHandler
→ Service
→ Manager
→ Processor
→ Pipeline
→ Executor
→ Trainer
Each layer may individually appear reasonable, but understanding the effective behavior requires following several objects and calls.
Consolidate responsibilities when the boundaries do not provide an independent architectural benefit:
def train(config):
data = load_data(config)
validate_data(data)
features = create_features(data)
model = fit_model(features, config)
metrics = evaluate(model, features)
save_model(model, config)
return metrics- Each layer has a distinct responsibility.
- Each boundary reduces meaningful coupling.
- The layers correspond to actual architectural boundaries.
- The additional indirection makes the system easier to change, test, or reason about.
For data processing, ETL, preprocessing, feature engineering, and model training code:
Prefer representations that make data flow, state transitions, and behavior easy to follow locally.
A simple function provides a direct relationship:
result = transform(data)The reader can generally follow:
call → implementation → result
An object-oriented implementation may require following:
instance
↓
class
↓
inherited method
↓
method override
↓
super()
↓
decorator
↓
injected dependency
↓
another object
These mechanisms are valid Python and can be appropriate. The key requirement is that their additional indirection must correspond to a concrete design need.