Skip to content

Instantly share code, notes, and snippets.

@fclesio
Created September 4, 2026 12:12
Show Gist options
  • Select an option

  • Save fclesio/f05d63bc64062e9801dfcd85e6d60dba to your computer and use it in GitHub Desktop.

Select an option

Save fclesio/f05d63bc64062e9801dfcd85e6d60dba to your computer and use it in GitHub Desktop.

Python Abstraction and Complexity Guidelines

High Scrutiny

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

Contextual Justification

These constructs can be appropriate, but their use should be justified by the specific problem they solve:

  • class
  • property
  • getter/setter
  • super()
  • dependency injection
  • decorator

Generally Low Complexity

These constructs generally do not require additional justification when used appropriately:

  • function
  • dataclass
  • NamedTuple
  • TypedDict
  • Enum

1. class

Rule

Use a class when state and behavior need to remain associated, or when the object must enforce invariants or represent a meaningful lifecycle.

Bad example

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.df

Usage:

result = (
    FeatureProcessor(df)
    .clean()
    .normalize()
    .encode()
    .result()
)

The intermediate state is hidden inside the instance.

Alternative

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)

Use a class when

  • 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.

2. Abstract Base Class (ABC)

Rule

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.

Bad example

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.

Alternative

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)

Use an ABC when

  • 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.

3. @abstractmethod

Rule

Use @abstractmethod only when a subclass must provide a concrete implementation of behavior that is part of a real polymorphic contract.

Bad example

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.

Alternative

Use ordinary functions:

def prepare_data(data):
    ...


def train(data):
    ...


def evaluate(model, data):
    ...

Use @abstractmethod when

  • 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.

4. Inheritance

Rule

Use inheritance when a subclass is genuinely substitutable for its base class and specialization through method overriding is part of the design.

Bad example

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.

Alternative

Make the data flow explicit:

def prepare_production_data(data):
    ...


def train_production_model(data):
    prepared = prepare_production_data(data)
    return fit_model(prepared)

Use inheritance when

  • 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.

5. super()

Rule

Use super() when inherited behavior is intentionally extended as part of a coherent class hierarchy.

Bad example

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

Alternative

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

Use super() when

  • 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.

6. property

Rule

Use a property when the value is semantically an attribute of the instance and accessing it should behave like attribute access.

Bad example

A property that hides a significant data transformation:

class Dataset:
    @property
    def normalized_features(self):
        return normalize(self.features)

Then:

dataset.normalized_features

looks like attribute access but actually performs data processing.

Alternative

Make the transformation explicit:

normalized_features = normalize(dataset.features)

Use a property when

  • 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.

7. Getter / Setter

Rule

Use a getter or setter when reading or writing an attribute requires validation, invariant enforcement, or encapsulated behavior.

Bad example

class Model:
    def get_model(self):
        return self._model

    def set_model(self, model):
        self._model = model

Usage:

model.set_model(trained_model)
trained = model.get_model()

If these methods only read and write the attribute, they add indirection without adding behavior.

Alternative

Use direct attribute access:

model.model = trained_model
trained = model.model

Or represent the data explicitly:

from dataclasses import dataclass


@dataclass
class TrainedModel:
    model: Model
    metrics: dict[str, float]

Use getters/setters when

  • 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.

8. Factory

Rule

Use a factory when object creation contains meaningful selection or construction logic that should be separated from the caller.

Bad example

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.

Alternative

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)

Use a factory when

  • 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.

9. Strategy

Rule

Use the Strategy pattern when multiple interchangeable behaviors are selected or injected at runtime and the consumer genuinely benefits from treating them uniformly.

Bad example

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.

Alternative

Use functions:

def select_by_correlation(data):
    ...


def select_by_importance(data):
    ...

Then select the function directly:

selector = select_by_correlation
features = selector(data)

Use Strategy when

  • 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.

10. Registry

Rule

Use a registry when the system genuinely needs dynamic registration and discovery of independently defined components.

Bad example

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.

Alternative

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)

Use a registry when

  • 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.

11. Dependency Injection

Rule

Use dependency injection when a dependency must be replaceable, independently configured, or isolated from the component that consumes it.

Bad example

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.

Alternative

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

Use dependency injection when

  • 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.

12. Multiple Layers of Wrapping / Indirection

Rule

Keep the number of indirection layers proportional to the architectural boundaries that they represent.

Bad example

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.

Alternative

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

Use multiple layers when

  • 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.

General Rule: Minimize Behavioral Indirection

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment