The 3 laws of Robotics applied to OOP
As a junior developer I sometimes struggled to anticipate the consequences of architectural choices, favoring immediate simplicity and readability over (what I interpreted as) theoretical purity.
Being a good teacher, life made sure that I got a chance to see the wisdom in SOLID principles.
Even though the principle names make for a cool acronym collectively, they are quite cryptic individually. Would they be easier to understand and apply if presented like Asimov’s laws of Robotics?
If you’re not familiar with Asimov’s laws of Robotics, here’s a quick refresher:
- A robot may not injure a human being or, through inaction, allow a human being to come to harm.
- A robot must obey orders given it by human beings except where such orders would conflict with the First Law.
- A robot must protect its own existence as long as such protection does not conflict with the First or Second Law.
Another superseding law was later prepended, which is why it’s called the “zeroth” law:
0 A robot may not injure humanity or, through inaction, allow humanity to come to harm.
These laws provide general guidance that translate to actionable rules for behaving in society, much like SOLID principles ensure that objects interface with each other without breaking business rules.
OOP translations
One of the main focus in OOP is setting boundaries between objects. If we consider models as robots interacting with other entities, we can translate Asimov’s laws into the following:
- A model must not modify another model’s data directly or, through callbacks, allow another model’s data to be modified.
In short: Don’t modify another model’s internal data. - A model must honor the contract exposed by its public methods, and signal when it needs to refuse to prevent data corruption.
In short: Obey the messages you receive. - A model must protect its data from corruption — through validations, encapsulated writers, and database constraints.
In short: Protect your data from corruption. - A model must not corrupt domain data or, through rigid adherence to the other laws, let business rules be violated.
In short: Use service objects to coordinate changes to multiple models, and query objects to combine reads across models.
Applying these laws using Rails
Rails provides sharp knives, and the ActiveRecord drawer contains particularly hazardous items: callbacks, validation-skipping shortcuts, etc.
-
The first law totally bans callbacks that modify other models, and calling
other.update_columns. Note that in this regard:touch,:counter_cacheand:dependentassociation options are quite borderline. They are tolerated, for the convenience they bring compared to the little harm they may cause. Callbacks in particular need strict rules: anafter_savethat callsother.update(…)is a strong code smell and should be replaced with a better architecture. Enforcing this is tricky, but RuboCop::CallbackChecker tries to prevent inappropriate callback usage from backfiring. -
To apply law 2, it’s best to make all accessors and methods private unless they are justifiable as public methods. This also helps to prevent message chains. Finally, methods should inform their caller when they cannot proceed.
-
Law number three requires appropriate validations, including database constraints (unique index, foreign keys…). The
Rails/SkipsModelValidationsRubocop rule enforces this law. This banssave(validate: false). -
Finally, since business rules frequently require simultaneous changes in various models, the zeroth law introduces meta-objects whose purpose is to coordinate multi-model reads and writes, to prevent inconsistencies. Service, Form and Query objects are typical ways of respecting this law.
As in Asimov’s stories, giving law-contradicting orders always ends badly. Any time you’re tempted to break one of these laws is a signal that an architectural fix is required.