OOP and Bijection

OOP and Bijection

November 24, 2021

How do you make good OOP software?

Bijection: Each domain object must be represented by a single object in our computable model and vice versa.

What does that mean? 1:1 relationship between real world and models.

Composition: Use has a, has many relationships over is a relationships.

Interfaces Use implements over is a relationships.

Common Violations

One model represents n things in the real world. ie, 10 for both 10 m and 10 in. In this case, 10m + 10in = 20?

Mars climate orbiter exploded for this very error.... OOPs.

Solution: 1:1

class Distance {
distance
constructor(distance) {
this.distance = distance
}
addDistance(distance) {
return new Distance(this.distance + distance.distance)
}
}
const distanceInMiles = new Distance(10)
const distanceInInches = new Distance(10)
const cummulativeDistance = distanceInMiles.addDistance(distanceInInches)
// Better
class ConverterCalculator {
calculate(value) {
}
}
class Distance {
distanceInMeters
constructor(distance) {
this.distanceInMeters = distance
}
}

N models represent 1 thing in the real world. ie, Jane Doe is a judge and an athelete, and we create an athelete model and a judge model which both represent Jane... Soon we will have a responsibilty assigned to one and not the other when it should actually happen... Should be the same object fulfilling different roles in different contexts.

Solution 1:1

const isCareer = new Symbol('isCareer')
const interfaceMap = new Map([
['isCareer', isCareer]
])
class Person {
careers = []
constructor({careers}) {
this.careers = careers
}
}
class Judge {
constructor() {
this[isCareer] = true
}
work() {
}
}
class Athelete {
constructor() {
this[isCareer] = true
}
work() {
}
}
const judge = new Judge()
const athelete = new Athelete()
const jane = new Person({
careers: [judge, athelete],
})

Anemic Objects. ie, Bitcoin wallet might be just a data struct with balance, address, etc. This does nothing.

Solution: Functional Objects (ie, wallet handles reciving transactions, writing to block chain, etc).

Failing Slow. ie, in many languages a date can be constructed from a day, month, year. Like this: new Date(31, 11, 2020). But wait... there is not 31 days in November... Some will kick back to November 30, others to December 1... But this hides the true issue of how we got 31 in the first place, and will give us major headaches later.

Solution: Fail Fast.

Mutating Models. ie, model has constructor that sets it up. Constructor has validation. But, we expose attributes to be set. Validation no longer happens, unless if it is duplicated... which is bad practice. Also, consider following: Dates, once created, can't magically transform themselves. Invoices, once written, cannot be changed without criminal consequences.

Solution: Immutable Models. Mutations only in accidental aspects, never their essence. In other words, remove all setters, remove all getters (unless it exists in the real world).

Coupling - Global Variables. ie, multiple models rely on some global variable to define their behavoir. This prevents the ability to fully mock and test these behaviors.

class Circle {
r
pi
constructor(r, pi) {
this.r = r
this.pi = pi
this.area = pi.value * r * r
}
}
class MathConstants = {
_pi
constructor({pi}) {
this._pi = pi
}
pi() {
return this._pi
}
tao() {
return this._pi * 2
}
}
const pi = 3.14
const circle = new Circle(3, pi)
const morePrecisePi = 3.1415926
const morePreciseCircle = new Circle(3, morePrecisePi)
const badPi = 10
const badCircle = new Circle(3, badPi)
const apiBase = 'https://mydomain.com'
class API {
}

Solution: Every method, function should accept everything it needs outside it's context as parameters.

Coupling - Settings. ie, ENV variables, etc. Special case of Global Variables.

Solution: Models should accept an environment/settings object

Coupling - Hidden Assumptions. ie, 10 means 10 meters. These always come up to bite you later.

Solution: Be explicit. Create a distance class for example.

Coupling - Null. Special case of Hidden Assumptions. What does null even mean? There is no bijection. Null means every object needs to check for it's existence, unwrap, and decide what to do if it is empty.

class Email {
constructor(email) {
}
sendEmail() {
}
shareEmail() {
}
}
class Emailless {
sendEmail() {
throw new Error('Can not send an email to email less')
}
shareEmail() {
throw new Error('no email to share')
}
}
class Person {
name
email
constructor({name, email}) {
}
sendEmail(contents) {
this.email.sendEmail(contents)
}
shareEmailWithPerson(person) {
this.email.shareEmail(person)
}
email() {
return this.email
}
}
const email = new Email()
const noEmail = new Emailless()
const jane = new Person({ name: 'jane', email: email })
const john = new Person({ name: 'john', email: noEmail })

Solution: Create Empty Models with all needed stuff.

Coupling - Singletons. Special Case of Bijection violation. Is there really only one instance of any given thing in the world? Also, it adds global variables, makes testing difficult, etc.

class God {
constructor() {
}
// Whatever
}
const god = new God()
const zeus = new God()

Coupling - Accidental Ifs, Switches, etc. Couples condition to the place where evaluation occurs. Violates Open/Closed Principle (open for extension, closed for modification). ie, if employee position === junior, pay 10_000 is bad, if employee has worked 3+ years, pay bonus. 1st is not an essential business rule, but is accidental. 2nd is essential business rule.

Solution: Polymorphism, Composition, etc.

Coupling - Comments / Documentation. Documentation typically is out of sync. Violates DRY (you have to repeat yourself). Indicates that the code is not clear. Special case - The 'Extra Line' in methods to split thoughts. If an Extra Line is tempting, means you should break it out into separate functions.

Solution: Readable Code

Ripple Effect. High Coupling (due to the above) causes a small change to have massive changes across the software. This causes unpredictability.

Solution: Uncoupled code.

Exposed Collections. ie, polygon is defined with X points. Each point is essential. Exposing collection means that could be mutated, which directly changes the polygon. BAD. Twitter Account Follows can be mutated, but makes more sense for Account model to handle that rather than someone else.

Solution: Collections are never exposed. Models handle any potential changes, iterations, etc.

class Private {
#private1
#private2
constructor({p1, p2}) {
this.#private1 = p1
}
#doStuff() {
}
}

Refactor Together

class Angle {
#angle
constructor({angle}) {
this.#angle = angle
}
isRight() {
return this.#angle === 90
}
}
class Line {
#a
#b
constructor(a, b) {
if (a.length !== b.length)
throw new Error('Points must be same dimension')
this.#a = a
this.#b = b
}
distance() {
let sum = 0
for (const {dimension, position: p1Position} of this.#a.iterator()) {
const p2Position = this.#b.valueAt(dimension)
const difference = p1Position - p2Position
sum += difference * difference
}
return Math.sqrt(sum)
}
angleTo(line) {
// Calculate Angle
return new Angle({angle: 90})
}
}
class Coordinates {
constructor({points}) {
this.#points = points
}
hasSameDimensionAs(coordinates) {
return this.#points.length === coordinates.#points.length
}
equalTo(coordinates) {
return this.#points.every((position, dimension) => position === coordinates.#points[dimension])
}
*iterator() {
for (const [dimension, position] of object.entries()) {
yield { position, dimension }
}
}
valueAt(dimension) {
if (dimension > this.#points.length)
throw new Error('Invalid Dimension')
return this.#points[dimension]
}
}
class Polygon {
constructor({points}) {
if (points.length <= 2)
throw new Error('Must have 3+ points')
for (let pointIndex = 0; pointIndex < points.length; pointIndex++) {
const point = points[pointIndex]
for(let otherPointIndex = pointIndex + 1; otherPointIndex < points.length; otherPointIndex++) {
if (point.equalTo(points[otherPointIndex]))
throw new Error('Cannot use the same point twice')
}
}
}
getArea() {
throw new Error('Not implemented')
}
}
class Rectangle extends Polygon {
#lines
constructor(({points})) {
super({points})
const pointsLength = 4
if (points.length !== pointsLength)
throw new Error('Must have 4 points')
const lines = []
for (const [index, point] of points.entries()) {
lines.push(new Line(point, points[(index + 1) % pointsLength]))
}
if (!lines.every((line, lineIndex) => line.angleTo(lines[(lineIndex + 1) % pointsLength]).isRight()))
throw new Error('Must have right angles between lines')
this.#lines = lines
}
getArea() {
return this.#lines[0].distance() * this.#lines[1].distance()
}
}

Contact Us


Don't forget to give us a follow!

© 2022 AIDIA LLC, all rights reserved.