If you're reading this, chances are that you're a person who writes software for personal or professional use.
I know that I do (in both capacities). One thing I hate is wasting my time (or perhaps I'm very lazy). In either case, I'll often look to make my life in software development easier, by writing tooling for myself (frequently shared on my GitHub https://github.com/ahungry) or looking for a better way of doing things.
Recently I had some conversations with co-workers regarding how wasteful much of enterprise coding standards are (boilerplate stacked on boilerplate, redundant docblocks/comments that re-iterate information that's already conveyed by a type system or literally "do the foo" type comments etc.).
In this article, I'm going to compare and contrast and give my biased opinion on quite a few things that I see that are either problematic, wastes of time, or should just be avoided when taking an "enterprise approach" to software development.
For this comparison, I am going to write a simple fictitious scenario to solve a BRD (business requirements document) use case as such:
It seems simple, right? Lets look at how this might be approached in the wild.
POPO = plain old PHP object / POJO = plain old Java(script) object
If you're lucky, you may work on a team that actually keeps domain model constraints close to the model, instead of using an anemic data model that just contains slots (member variables, properties, call it what you like) - essentially reimplementing your language's built-in map type in a thin object wrapper.
Look around at some "OOP" codebases for a bit, and it won't be long until you see something such as this (sample in PHP, but it could be in Java or Typescript or any other language attempting to be java-like):
class UserModel { /** @var string The name of the user. */ private $name; /** @var int The age of the user. */ private $age; /** @var int The id of the user. */ private $id; /** @var string The language of the user. */ private $language; /** * Create a the user * * @return UserModel */ public function __construct () { // What are constructors for again? // Omitting getter/setter docblocks, but you'll see them too. } public function setName (string $name) { $this->name = $name; } public function getName (): string { return $this->name; } public function setAge (int $age) { $this->age = $age; } public function getAge (): int { return $this->age; } public function setId (int $id) { $this->id = $id; } public function getId () { return $this->id; } public function setLanguage (string $language) { $this->language = $language; } public function getLanguage () { return $this->language; } }
So, what's the big deal? (you may ask).
Well, first off, by not actually constructing the object all at once (immutability) at any given point in code execution, your model could be in an "invalid" or incomplete state.
What if a new property is added down the road? Very few IDEs will be able to inform you that you failed to call some later-date added setter in code portions that consume this code.
Secondly, comments (and mandatory docblocks) should always explain the "why", not the "what". Very rarely do these types of comments offer any value whatsoever.
Lastly, the getter/setter fluff offers no value, it's just wasted screen real estate.
Let's clean up the Model a bit:
class UserModel { private $name; private $age; private $id; private $language; public function __construct (string $name, int $age, int $id, string $language) { $this->name = $name; $this->age = $age; $this->id = $id; $this->language = $language; // Favorite prog language. } }
Ok, that's a bit better - it's still a little wordy, but if PHP was your language of choice, that's just a bed you'll have to sleep in at this point. Had it been Kotlin you could use a Data class, or in Typescript you could leverage some nice constructor bonuses:
class UserModel { constructor ( private name: string, private age: number, private id: number, private language: string ) {} }
Or, you know, had you chosen Clojure, you could just use a record to the same effect (check out clojure.spec if you are worried about types):
(defrecord UserModel [name age id language])
which is effectively just the simpler to use 'map' type:
{:name "Matt" :age 36 :id 2 :language "clojure"}
Ok, we're past the data, lets get some business logic!
You might be tempted to whip up a "service" (a fancy name for a class acting as a namespace to encapsulate business logic in functions) that handles operating on this nice plain old model.
This is your first real decision point - do you enhance your PO-O to be a full-fledged object with built in business logic, or do you treat it as a dumb data store? If the latter, you should probably just scrap your "class" at this point and use a more primitive data type (hashmap / map / named array, whatever your language calls it).
To enforce conformance to some spec, types will never get you the power of a runtime schema validation (especially if your system communicates with user input in some way), so stop pretending the "class" wrapper around a plain data object will act otherwise.
If you chose to enhance your model, in PHP land it would probably look like this now:
class UserModel { private $name; private $age; private $id; private $language; public function __construct (string $name, int $age, int $id, string $language) { if (empty($name)) { throw new \InvalidArgumentException('Missing name'); } $this->name = $name; if (0 > $age) { throw new \InvalidArgumentException('Missing age!'); } $this->age = $age; if (0 > $id) { throw new \InvalidArgumentException('Missing id!'); } $this->id = $id; if (empty($language)) { throw new \InvalidArgumentException('Missing language'); } $this->language = $language; } public function isAdult (): bool { return $this->age > 18; } public function isLanguageFan (): bool { return $this->language === 'PHP'; } public function jobOffer (): string { return "Job offer for: {$this->name} (imagine an email is sent now)."; } }
"Not bad" you say, as you pat yourself on the back. I bet something like Clojure that pushes dynamic types and a non-OOP setup couldn't have business logic tightly coupled to their records.
Well, it isn't very difficult there either.
(defrecord UserModel [name age id language]) (s/def ::positive-int (s/and int? #(> % 0))) (s/def ::full-string (s/and string? #(> (count %) 0))) (s/def ::name ::full-string) (s/def ::age ::positive-int) (s/def ::id ::positive-int) (s/def ::language ::full-string) (s/def ::user-model (s/keys :req-un [::name ::age ::id ::language ])) (defn get-user-model "Get a valid instance of the record. Throws message such as: In: [:id] val: -1 fails spec: :blub.core/positive-int at: [:id] predicate: (> % 0) on failure." [name age id language] (let [model (->UserModel name age id language)] (if (s/valid? ::user-model model) model (throw (Throwable. (str (s/explain ::user-model model))))))) (defmulti adult? class) (defmethod adult? UserModel [{:keys [age]}] (> age 18)) (defmulti language-fan? class) (defmethod language-fan? UserModel [{:keys [language]}] (= language "Clojure")) (defmulti job-offer class) (defmethod job-offer UserModel [{:keys [name]}] (str "Job offer for: " name " (imagine an email is sent now)."))
The really nice thing about that Clojure sample is that a new type was not just defined for some pre-runtime linter or compiler - "::name" is now a reusable spec that could be added to models that have nothing to do with a user model. Maybe you store the name of a business? No need for tricky OOP class inheritance to reuse a validation like that, or writing a top level "helper" function that has to be wrapping calls everywhere in the codebase (although Clojure spec does have great pre-runtime facilities for generative testing based on these definitions).
What did I title this again? Oh yea, writing code without IFs. I'm sure some readers will at this point be thinking "fat chance". But, it's more or less possible, less error prone, and more future proof.
It also avoids a huge headache, which is multi-return types. Lets loop back around to our OOP example in PHP:
class UserRepository { // Pretend this comes from a database and could be NULLable public function getUserById (int $id) { $rows = $imaginaryDbh->select($id); if (count($rows) === 0) { return NULL; } // source it from $rows (omitting for brevity) return new UserModel('Matt', 36, 1, 'PHP'); } } class UserService { private $repo; public function __construct (UserRepository $repo) { $this->repo = $repo; } public function getUserById (int $id) { return $this->repo->getUserById($id); } public function acquireCandidateByIdMaybeHiring (int $id): string { $user = $this->getUserById ($id); if (empty($user)) { throw new \Exception('User not found!'); } if (false === $user->isAdult()) { return null; } if (false === $user->languageFan()) { return null; } return $user->jobOffer(); } } $candidate = null; try { $service = new UserService(new UserRepository()); $candidate = $service->acquireCandidateByIdMaybeHiring(2); } catch (\Exception $e) { // handle it } finally { if (null !== $candidate) { echo "Email is being sent: " . $candidate; } }
So, looks like a lot of IFs and maybes (is this call going to yield a string or a null or throw an exception etc.)?
Code like this is also very mistake-prone going forward. A single bad IF comparison added down the road can cause some bad production bugs.
Even the Repository query method has a problem. Can you spot it? It isn't handling a single responsibility - is the call to getUserById an assertion? is it an IF check on doesUserByIdExist? Is it a data fetch?
Yet, I see this many times. A more apt data fetch should act similar to how we receive rows in a SELECT statement in SQL - a result set or collection of data, not a single data point. This allows us to work on the "things" we want to do to N (0 or 1, or maybe even more) elements with minimal effort.
Imagine if we had written our "service" logic using no IFs in a Clojure manner such as this:
(defn get-users [_id] (-> imaginary-sql (.select 2)) [(get-user-model "Carter" 36 2 "Clojure")]) (defn emailer! [s] (str "Email is being sent: " s)) (defn acquire-candidate-by-id-maybe-hiring! [id] (some->> (get-users 2) (filter adult?) (filter language-fan?) (map job-offer) (map emailer!) ))
In one small window or view, we can see all the business logic easily abstracted into a logical way to read it.
If our initial fetch for data fails for some reason, or the record simply does not exist, we don't have to care to handle it with ad-hoc handling/conditional code in our concise service area, we simply don't end up applying any of the latter steps to it.
If we need code to handle "did get-users error out" or return 0 records, at that point, we can write a business logic case for what should happen there, and run those scenarios in tandem with the maybe-hiring one.
While I used Clojure as the good example and PHP as the bad sample, most these could be observed in many different languages. Great software can and is developed in many (any) language, but you should always question everything and not just perform ritualistic ceremony for the sake of doing it (or because everyone else is - see cargo cults).
I notice a lot of the preference for one system or the other is due to past bad experience (I was on the OOP boilerplate bandwagon for a long time due to running into home-rolled amateur PHP solutions in the past). However, most those bad past experiences would have been bad software in any language/paradigm, not necessarily "because it was a dynamic language, and types would have saved me".
The majority of bugs do not seem to be type errors, missing docblocks or not enough model/repository/service layer abstractions, but failures to communicate, check / quality test own work, and programmer over confidence.