Is there no pattern, way of architecturing my objects to escape this?
The good news is, there is. The slightly worse news is that it requires you to change how you view your types/classes interfaces and how they represent the logic you need them to perform.
The main problem you're battling with here is that your approach is something that is not really how OOP should generally be approached. The best way to describe the approach I mean is:
I have a [thing], now I want to figure out what this [thing] is
In OOP, your "things" are inherently defined by their internal structure, i.e. their type. Weakly typed languages don't enforce specific types at every turn, but even in a weakly typed language you are expected to know the kind of content you can find in the object you pass. You will write code like this:
void DoSomething($myObj) {
$foo = $myObj.MyProp
}
...which proves the point that you knew that myObj
would have a MyProp
property. The language didn't make you specify the exact type in the method parameter, but there was an implicit expectation that you know what you'll be passing into the method.
Your intended code is exactly doing this kind of figuring out:
// I have a $object, now I want to figure out what this $object is
if( $object instanceof ScriptInterface) {
//Do some stuff that should only happen if this check passes.
}
And it also very quickly show you how this will inevitably devolve into a violation of the Open-Closed Principle:
//Do the same thing for N interfaces
To summarize the linked page, OCP is violated when you start having an ever-expanding list of snippets that you always need to remember to adjust (e.g. when you develop interface N+1
in the future).
This leads to forgetfulness, which leads to runtime errors and your old code (with only N
implementations) not being able to handle any N+1
interface objects. Sometimes, that is a giant roadblock that leads to high-pressure just-after-the-release bugfixes, which no one likes.
Maybe it helps if I show a diagram:
┌──────────────┐
┌─────┤ A1 │
│ └──────────────┘
┌──────────────────┐ │
│ MyService ├───┘
├──────────────────┤ ┌──────────────┐
│A1-handling logic ├─────────┤ A2 │
│A2-handling logic │ └──────────────┘
│A3-handling logic ├───┐
└──────────────────┘ │
│ ┌──────────────┐
└─────┤ A3 │
└──────────────┘
If you develop an A4
tomorrow, then you're going to need to remember that MyService
needs to be updated with A4-handling logic
. That might be easy to remember in a tiny codebase, but in an enterprise-grade one, it is asking for trouble.
Keep in mind that clean coding practices are principles. In tiny codebases, the problem they solve may be trivial to keep track of and deal with, but the larger the codebase gets, the more impossible it becomes to track this problem. The main advice is to remain clean no matter the size of the codebase - plenty of people have been burned making shortcuts "just for now" and then getting stuck when the codebase grew.
The better approach here is to tie the new type to the new logic in a way that you can't forget it:
┌─────────────────┐
│ A1 : IA │
┌───────┼─────────────────┤
│ │A1-handling logic│
│ └─────────────────┘
┌──────────────────┐ │
│ MyService │ │ ┌─────────────────┐
├──────────────────┤ ┌───────┴┐ │ A2 : IA │
│ ├─────┤ IA ├──────┼─────────────────┤
│ IA.Handle() │ └───────┬┘ │A2-handling logic│
│ │ │ └─────────────────┘
└──────────────────┘ │
│ ┌─────────────────┐
│ │ A3 : IA │
└───────┼─────────────────┤
│A3-handling logic│
└─────────────────┘
The key things to notice here are:
IA
acts as a common interface between the A
types, so that all A
types can be handled the same way by a consumer. In this case, it contains a Handle()
method which MyService
can call, but the A
classes themselves get to (individually) choose what to do when Handle()
is called.
MyService
no longer knows any of the specific types involved. It only works with IA
. Therefore, MyService
is capable of handling any IA
derived type - even if it doesn't exist yet today.
- Each
A
class is aware of its own handling logic. Which makes sense, since types like these are created specifically because you have a particular role in mind. Therefore, we define the role in the type itself.
- Compare both diagrams. Now imagine that a new
A4
has to be developed. How does that impact the diagram?
- In the first diagram, you need to both create an
A4
and update MyService
.
- In the second diagram, all you need to do is create an
A4
(which implements IA
), and all the rest will be taken care of for you, because the code you wrote was change-friendly and didn't need to be reworked.
You haven't really mentioned what you intend to do with your specific types, so it's very hard to distill a concrete example of an interface that would suit your needs here.
The best advice I can give right now is to develop your common interface (IA
) in a way that its implementations can all be handled the same way. For example:
interface IModule
{
public function getName();
public function getVersion();
public function WriteToLog($log);
}
Every module has a name, a version number, and it is able to perform a specific job (writing to a $log
). However, each module gets to decide on its own:
- What its name is
- How to calculate its version number
- If/how it will write a message, and what kind of message, into
$log
Your ModuleService
, which handles all kinds of modules, can now be written to only handle IModule
objects, and can blindly trust that you've made sure that all your modules correctly implement the IModule
interface. This ensures that you don't have to constantly update your ModuleService
logic whenever a new module is developed, as long as the IModule
interface does not need an update.
function execute()
in the interface that requires to implement the logic in the clients and then in your generator you just call...->execute();