2

Lets suppose we have an entity (Car) with different CarTypes:

class CarTypes(Enum):
    SUV = 'suv'
    SPORT = 'sport'

@dataclass
class Car:
    id: UUID
    type: CarTypes
    owner: str

    def run(self):
        ...

    def idk_some_car_stuf(self, speed):
        ...

The class Car implements the domain rules referring to Car, and the application rules (ie, acces DB to load Car, access external APIs, put messages on queues, logs, etc) are implemented in a service class CarService:

class ServiceCar:
    def __init__(self, car_id: UUID):
        self._car = CarRepository.get(car_id)

    def run(self):
        log.info('Car is about to run')
        self._car.run()

        if self._car.type == CarTypes.SUV:
            suvAPI.suv_is_running(self._car)

        elif self._car.type == CarTypes.SPORT:
            ...

        rabbitmq.publish({'car': self._car.__dict__, 'message': ...})

The problem is that different car types can have different application rule types (eg calling different external APIs, etc.) and since I want to follow the Open-Closed principle, I dont want to implements this ifs, so I choose to segregate CarService by CarTypes like this:

class CarService(ABC):
    @abstractmethod
    def run(self) -> None:
        ...

class SUVCarService(CarService):
    ''' Specific implementation here, following CarService interface'''
    ...

class SportCarService(CarService):
    ''' Specific implementation here, following CarService interface'''
    ...

class CarServiceFactory:
    @classmethod
    def build(cls, car_id: UUID) -> CarService:
        car = CarRepository.get(car_id)
        klass: CarService = SUVCarService if car.type == 'SUV' else SportCarService

        return klass(car)

That is my current implementation (oc I used an generic and simples example here ) but im not satisfied, what I really want is to use Metaclasses to build the specific (ie SUVCarService and SportCarService). So, instead my controllers call something like this:


def controller_run(body):
    service = CarServiceFactory.build(body['car_id'])
    service.run()
    ...

It would be call something like:

def controller_run(body):
    service = CarService(car_id=body['car_id'])
    # This CarService calls return the specific class, so
    # if car.type == 'suv' then type(service) == SUVCarService
    service.run()
    ...

But the python documentation about metaclasses are confuse to me, (idk if I need to use __new__ method from the metaclass itself, or __prepare__ ).

1 Answer 1

2

A metaclass could be used there to automatically instantiate a "Car" to the appropriate subclass.

But maybe it would be complicating things beyond what is needed. What is more bureaucratic than necessary in your examples is that the car service factory has no need to be a class on its own - it can be a simple function.

So, for a function-factory:

def car_service_factory(cls, car_id: UUID) -> CarService:
    car = CarRepository.get(car_id)
    # klass: CarService = SUVCarService if car.type == 'SUV' else SportCarService
    # nice place to use the new pattern matching construct in Python 3.10. Unless you 
    # need to support new classes in a dynamic way (i.e. not all car types
    #are hardcoded)
    match car.type:
        case "SUV": 
            klass = SuvCarService
        case _:
            klass = SportsCarService

    return klass(car)

This is "pythonland": it is not "ugly" to use plain functions where you don't need to artificially create a class.

If you want a metaclass, you could move the factory logic into the metaclass __call__ method. It then could select the appropriate subclass before instantiating it. But it is rather subjective if it is more "elegant", and it is certainly less maintainable - as metaclasses are an advanced topic a lot of programmers don't grasp in full. Ultimately, you could get away with a plain Python dictionary working as a Service class registry, keyed to the car types.

Since the question is about a metaclass anyway, here it goes. The only different thing is that it can take advantage of the __init__ method to keep a dynamic registry of all car Service classes. It could be derived from the class name, as a string - but I think it is less hacky to have an explicit type attribute on those as well.


from abc import ABCMeta
from typing import Union, Optional

from enum import Enum

class CarTypes(Enum):
    SUV = 'suv'
    SPORT = 'sport'

class Car:
    ...

class MetaCarService(ABCMeta):
    service_registry = {}
    def __init__(cls, name, bases, ns, **kw):
        cls.__class__.service_registry[cls.type] = cls
        return super().__init__(name, bases, ns, **kw)
   
    def __call__(cls, car_or_id: Union[UUID, Car]) -> "CarService":
        if not isinstance(car_or_id, Car):
            car = CarRepository.get(car_id)
        else:
            car = car_id
        # for hardcoded classses you may use your example code:
        # cls: CarService = SUVCarService if car.type == 'SUV' else SportCarService
        # For auto-discovery, you may do:
        try:
            cls = cls.__class__.service_registry[car.type.value]
        except KeyError:
            raise ValueError(f"No registered Service class for car type {car.type}" )
        instance = super.__call__(cls, car)
        return instance

class CarService(metaclass=MetaCarService):
    type: Optional[CarTypes] = None

    def __init__(self, car_or_id: Union[UUID, Car]): 
        # the annotation trick is a workaround so that you can use the UUID 
        # in your code, and the metaclass can pass the instantiated Car here.
        # You could accept just the UUID and create a new car instance,
        # disregarding the one build in the metaclass, of course
        # (I think the annotation linter will require you to 
        # copy-paste the `isinstance(car_or_id, Car)` block here)
        self.car = car_or_id

    @abstractmethod
    def run(self) -> None:
        ...

class SUVCarService(CarService):
    ''' Specific implementation here, following CarService interface'''
    type = CarTypes.SUV
    ...

class SportCarService(CarService)
    ''' Specific implementation here, following CarService interface'''
    type = CarTypes.SPORT
    ...

...

def controller_run(body):
    service = CarService(body['car_id'])
    service.run()
    ...
Sign up to request clarification or add additional context in comments.

3 Comments

Pretty clean, I think now im getting the metaclass point
In my specific case it makes sense for Services to be classes, but I agree with you that python is not java, and what can be a function must be a function (like my controller example)
"Services" can be classes. The "Service factory" equivalent can be a function and return the proper class - that is the most straightforward pattern.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.