I write a lot of object-oriented code these days and I am a strong advocate of the SOLID principles. I have noticed that if I follow those principles, I tend to write better code that’s more testable, more flexible, and more maintainable.
However, sometimes when I am initially designing code for a new feature I fall into this weird trap and I have to stop and bring myself back to reality. It goes something like this:
We need to model a vehicle. Hmm, the requirements just ask to model cars and maybe trucks. But who knows what the requirements will be next week. Let’s keep it simple but let’s make sure we don’t box ourselves in I tell myself.
interface VehicleInterface { public function getPropulsionStrategy(): PropulsionStrategyInterface; public function getOccupancyInterface(): OccupancyInterface; /* TODO add this? */ public function getType(): VehicleTypeInterface; }
Perfect, We went a little abstract right out of the gate, but I think this is going to be a clean and flexible design. Everyone will marvel at it for years to come. Let’s just fill in some of these things. I didn’t want to couple ourselves to wheel-propulsion vehicles only (even though the requirements never mention anything else and it’s completely insane to think that we would support this given the business objectives), so I added a propulsion strategy for now. I’ll figure this out as I go.
interface PropulsionStrategyInterface { public function getMechanism(): PropulsionMechanismInterface; }
Good enough for now. Leaving it flexible so we can have propulsion mechanisms. I’m imagining wheel-based is probably most common, and it’s what’s in the requirements doc we created, but I don’t want to rule out hovercrafts or maglev trains, or ION thrusters? Oh shit maybe this should be ThrustProviderInterface
? We’ll deal with that later.
interface PropulsionMechanismInterface { public function getTorqueProvider(): TorqueProviderInterface; }
Hmm not sure if we need this yet, but I’ll go ahead and create it. Pretty sure torque is going to be important. Wait that ThrustProviderInterface
seems like it might be the way to go. Hm what is torque? That’s basically a subclass of thrust except with rotation anyway right? I can’t remember… we’ll circle back to this later, I’ll just “stub” it out for now:
interface TorqueProviderInterface { /** * @param PlanetaryConstantsProvider $provider TODO - create a PlanetaryConstantsProviderInterface in case we need * to deal with other solar systems * @return FrictionAdapterInterface */ public function getFrictionAdapterInterface(PlanetaryConstantsProvider $provider): FrictionAdapterInterface; public function getUniverseInterface(): UniverseInterface; }
Ok we are getting somewhere now. It’s getting deep. This code is going to be so flexible once it’s done. Just need to map out a few more things and I can get down to the nitty-gritty here and push out this MVP. Also, need to carve out some research time for the 26 universal constants in our current known universe, but I can do that later.
interface UniverseInterface { /** * Get the plank constant for this universe. * * TODO: we probably don't need to support other universes, but if we do, the plank constant might be different * there. Also look into other constants. * * @see CosmologicalConstantProviderInterface * @see WeakNuclearForceConstantProviderInterface * @see FineStructureConstantProviderInterface * @see StrongCouplingConstantProviderInterface * @see NeutrinoMixingParameterRegistry * @see QuarkMixingParameterRegistry * @see StandardModelRegistry TODO -- higgs boson wtf is that ??? * FIXME: list all of them here */ public function getPlankConstant(): UniversalConstantInterface; }
So just to make sure this is both testable and flexible moving forward I need to make sure this is compatible with different universes that might have different constants that we don’t know about yet. It’s also possible that these constants aren’t constants at all and they can be mathematically derived (/* TODO research string theory? */
), so consider creating a UniversalConstantFactory
.
You know what? We will probably want a way to register universes, just to handle this possibility. We can create a sane default later for our known universe.
namespace App\Universe; final class UniverseRegistry { private static $universeMap; /** * @param UniverseInterface $universe the universe to register * @return void */ public static function registerUniverse(UniverseInterface $universe): void { if ($universe->isUnstable()) { throw new \RunTimeException('Refusing to register unstable universe. Check weak nuclear force?'); } self::$universeMap[$universe->getName()] = $universe; } /** * @return UniverseInterface[] */ public static function getUniverses(): array { return self::$universeMap; } /** * @param string $universeName the universe name * @return UniverseInterface */ public static function getUniverse(string $universeName): UniverseInterface { $universe = self::$universeMap[$universeName] ?? null; if (!$universe) { throw new \RuntimeException(sprintf('Unknown universe "%s".', $universeName)); } return $universe; } }
Nice, that will be super handy later. We’re doing great so far — just need to glue all of this together and we’ll have a great product to iterate on.
interface UniversalConstantInterface extends FloatInterface, ItsTurtlesAllTheWayDownInterface { public function whenAmIGoingToActuallyWriteSomeCodeHere(): void; }
This is where you realize you went too far with the abstraction and forgot to write any actual code. I know this is an intentionally silly example, but have you ever been guilty of something like this?
I feel like there should actually be some kind of lesson here, where I tell you how to avoid this pitfall. I don’t have the answer, but I can at least try to give some advice from my experience (my experience doing it wrong for a long time).
1. YAGNI (You ain’t gonna need it). Consider the possibility that you may never need anything better than the MVP. It happens all the time. You create the MVP, they say “This is amazing!” and you never hear from them again. Why put all of that effort into building support for scenarios that no one ever asked for?
2. Agile doesn’t mean write bad code There’s nothing wrong with forecasting future feature requests and building something a little bit more flexible than was strictly requested. Just don’t go overboard. It’s often better to just put a note like /* this is currently tightly coupled to SuchAndSuchConcrete. If we need to add support for different SuchAndSuches, we can generalize this later by doing x,y,z... */
. That way if you eventually do get that request to support something else, your previous self has given you a hint about it and you know where to resume that work.
3. Get the requirements! Usually the root cause of this problem is not actually having the requirements, or the requirements being vague. Make sure the requirements are clear from the beginning so that expectations can be managed. Sometimes you need to push back too. “Can we just have this send an email, or write to a log file for v1, then if we need (insanely complex edge-case handling you suggested) we can add that in a later iteration?”
Finally, never forget that perfect is the enemy of good.
Phew, that was a doozy! I appreciate the over-the-top-ness of the initial example and the succinct three points to remember. They remind me a bit of my own take, or rather, refinement, on YAGNI – https://stevenharman.net/yagni-aint-what-you-think-it-is
And I always appreciate wrapping with a nod to Voltaire. 😉
Agree with your assessment. Not only is it nuanced, but I’ve noticed that YAGNI can often turn into a form of _malicious compliance_ (well you should have asked for that shouldn’t you?) which is obviously an unproductive attitude for a developer to take.