Technical Discussion

Notes on Simple Made Easy

 —  Based on Rich Hickey's presentation
Return Home

Based on Rich Hickey's presentation.

Definitions

Simple

Derives from Latin Sim-plex, meaning "one fold" or "one twist". As opposed to com-plex, meaning "folded together".

That which is simple, is one-folded

  • It has one role; or
  • Does one task; or
  • Lends to one concept; or
  • Exists in one dimension

It is not necessarily as strict as

  • One instance; or
  • One operation Remember, simple involves interleaving, but interleaving only a single idea.

Simple is an unchanging attribute of an object Tying a knot is simpler than knitting

Easy

Derives from French, speculated to mean "nearby".

That which is easy, is nearby, ie available

  • It is at hand
    • Already in our project/disk
    • Already provided in our toolchain, IDE, etc
  • It is in mind
    • We already have the understanding/skillset/familiarity
  • It is learnt
    • We've already put in effort

It is not necessarily as broad as

  • Low effort

Easy is a relative to a person Speaking English is easy for me, but French is not

Distinguishing Easy and Simple

How does easy impact a project

Easy affects the construction of a project

  • It is easier for programmers to work with familiar technology, (eg React, CSS BEM, VSCode) than with bleeding-edge technology (eg NextJS/Server Components, Tailwind, NeoVim)
  • Programmers are more replaceable, if one programmer falls sick, quits, or is fired, another programmer can easily continue their work

A recent project started off by taking the easy approaches

  • Using any liberally in TypeScript
  • Adding on behaviours to functions rather than refactoring
  • Adding functionality to known files rather than creating new ones

How does simple impact a project

Simple affects the output of a project

  • The correctness and quality of a project is better known
  • Maintaining and changing features are stable

The aforementioned project failed to maintain simplicity

  • Single components had many responsibilities, often handled by switch cases
  • Duplicated code was not refactored, and their functionality drifted apart
  • The type system was derived from poor assumptions, by using any or naïve derivatives

Tradeoffs

  • We can only achieve reliability with what we understand (the need for ease)
  • We can only consider a few things at a time (the need for simplicity)

But generally we will find that focusing on simplicity first lends to ease, while focusing on ease will lend to complexity.

Change

  • Simplicity makes planning changes easier
    • Analysing what needs to be changed requires less effort
    • The impact of changes is more isolated
    • Where changes need to be made is clearer
    • Reasoning your program is more straightforward
  • Ease makes actioning your changes simpler
    • The path to completion is known
    • But, it does not necessarily mean the effects of completion are known

Debugging

A fact of every bug found

  • It passes the type checker
  • It passed all the tests

Typing and testing is great, but we don't drive a car by banding around on guard rails. By keeping a system simple cause-effect analysis and replicating issues is simpler.

Development Speed

  • Starting easy provides early speed
  • Ignoring simplicity will eventually slow you down

Flexibility

In simple systems there is no interleaving

  • Imagine diffusing a bomb, with fewer wires to cut changing the behaviour of that bomb is easier
  • Both these bombs do the same thing - they explode. So choose the system with fewer conjoined parts. Complex bomb from Keep Talking and Nobody Explodes Simple bomb from Keep Talking and Nobody Explodes

Making things easy

  • Bring to hand
    • Installing dependencies
    • Approving for use
  • Become familiar
    • Learning sessions
  • Reduce the amount of stuff
    • Minimise dependencies only there for ease
    • Rely on specialised tools
  • Reduce complexity
    • Object, functions, files, etc fulfil a single responsibility
    • Choose better tools

Complect vs Compose

Complect is an archaic term, which is the act of interleaving.

  • Don't knot things together! Demonstration of ropes being braided; complected Compose means to place things together
  • Rather than tie parts of a system together, plug them in together
    • Composed parts only think about abstractions of other parts Slide showing two lego bricks, only thinking of their joining parts Blue doesn't care whether the other is blue or yellow, or big or small. It only cares for the parts it connects with.

Toolkit

Complexity Complects on Simplicity Via
State Everything that touches it Values Final, persistent collections
Objects State, identity, value Values Same as above
Methods Function and state, namespaces Functions, Namespaces Stateless methods, Language support
Variables Value and time References Language Support
Inheritance Types Polymorphism Protocols, Type Classes
Switch, Matching Multiple who/what pairs Polymorphism Same as above
Syntax Meaning, Order Data Maps, Arrays, sets XML, JSON, etc
Loops What, how Set Functions Libraries
Actors What/who Queues Libraries
ORM OMG! Declarative data manipulation SQL, etc
Conditions Why, rest of program Rules Libraries, Prolog
Inconsistency Understanding Consistency Transactions, Values

Code Examples

State to Values

// State causes multiple moving parts to change
const Component = () => {
	const [value, setValue] = useState();

	useEffect(() => {
		fetch(url).then((res) => setValue(res.statusText))
	}, [])

	// How is this state going to affect down the tree as it changes?
	return <Child prop={value} />
}

// With server components, we can use values which are simpler; there are fewer moving parts
const ServerComponent = async () => {
	const value = (await fetch(url)).statusText;

	// The value doesn't change
	return <Child prop={value} />
}

Objects to Values

// An object complected by state (mutable), identity, and values
const user = {
	id: 1,
	name: 'John',
};
user.name = 'Jane';

// An immutable object
const user = Object.freeze({
	id: 1,
	name: 'John',
});
const updatedUser = {
	...user,
	name: 'Jane',
}

Variables to References

// Variables may change, which can cause unexpected behaviour
const array = [3, 2, 1];

// Later...
array.sort();

// Later... We may not realise array is now sorted
const firstItem = array[0]; // 1

// Using references that don't mutate prevents unknown changes
const array = [3, 2, 1];

// Later...
const sortedArray = array.toSorted();

// Later...
const firstItem = array[0]; // 3

Methods to Functions

// Methods are complected by tying functions, state, and namespaces together
class User
	constructor(name) {
		this.name = name;
	}

	greet() {
		return `Hello, ${this.name}!`;
	}
}

// Functions are stateless
function greetUser(name) {
	return `Hello ${name}`;
}

Inheritance/Switches to Polymorphism

// Inheritance complects when depending on types
class Field {
	// ...
	getNode() {
		switch (type) {
			case 'typography':
				return (
					<p>Hello, world!</p>
				)
			case 'text':
				return (
					<input
						type="text"
						validation={(value) => value.length > 0}
					/>
				)
			case 'number':
				return (
					<input
						type="number"
						validation={(value) => value !== 0}
					/>
				)
		}
	}
}

// Polymorphism breaks apart logic into subclasses
// Code that appears duplicated realisticly has seperate responsibility, so it's still DRY
// Adding new variants doesn't require you to touch unrelated code
class Field {
	abstract getNode();
}

class Typography extends Field {
	getNode() {
		return (
			<p>Hello, world!</p>
		);
	}
}

class Text extends Field {
	getNode() {
		return (
			<input
				type="text"
				validation={(value => value.length > 0)}
			/>
		)
	}
}

class Number extends Field {
	getNode() {
		return (
			<input
				type="number"
				validation={(value => value !== 0)}
			/>
		)
	}
}

Syntax to Data

// Using some custom, loosely defined syntax
// Alike symbols do different things depending on context
const customSyntax = '!list[Item 1[item!Item 2[item]'
const list = customSyntax.slice(
	customSyntax.indexOf('[') + 1,
	customSyntax.indexOf(']'),
).split('!')
const targetText = list[0].split('[')[0];

// Use data directly
const object = {
	id: 'list',
	items = [
		{ className: 'item', text: 'Item 1' },
		{ className: 'item', text: 'Item 2' },
	],
};
const targetText = object.items[0].text;

Loops to Set Functions

// Loops complect on what and how; I have to read the parens and body of the loop
const doubled = [];
for (let i = 0; i < array.length; i += 1) {
	doubled.push(array[i] * 2);
}

// Set functions, built into JavaScript
// What is clear: Each item is multiplied
// How is clear: map
const doubled = array.map(x => x * 2);

Actors to Queues

// Actors handle their own state
// Actors may work on unrelated data together
class Actor = {
	queue = [];

	receive(message) {
		this.queue.push(message);
		this.process();
	}

	async process() {
		while (this.queue.length) {
			const message = this.queue.shift();
			await work(message);
		}
	}
}

// Queues isolate work
// Users can use queues as they like
// No concern of responsibility
const queue = [];

async function dispatch() {
	if (queue.length) {
		const message = queue.shift();
		await work(message);
	}
}

Conditions to Rules

// Conditions scatter logic throughout code
if (order.priority === 'high') {
	handlePriorityOrder(order);
} else {
	queue.push(order);
}

// Rules keep conditions bundled together and reusable
const rules = [
	{
		when: order.priority === 'high',
		then: handlePriorityOrder,
		with: [order],
	},
	{
		when: order.priority !== 'high',
		then: queue.push,
		with: [order],
	},
];

processRules(rules);

Inconsistency to Consistency

if (age < 18) {
	console.log("You're not allowed to drink alcohol");
}

// Later... we have an inconsistent rule
const allowedAlcohol = age > 18;

// Consistency can be achieved by explicitly defining behaviour
const MINIMIM_DRINKING_AGE = 18
function isTooYoung(age) {
	return age < MINIMUM_DRINKING_AGE;
}

if (isTooYoung(age)) {
	console.log("You're not allowed to drink alcohol");
}

// Later...
const allowedAlcohol = !isTooYoung(age)

Information

Information itself is simple, there's no need to add complexity

  • Don't hide information behind a micro-language
    • eg a class with information-specific methods
    • Tying logic to representation details

Directing abstraction towards simplicity

When we abstract something we commonly abstract to draw away details; and often times to oversimplify or hide complexity. Usually that won't help, and can take aware from your ability to do what you want for a complicated system. Consider the who, what, when, where, why aspects of abstraction.

What (Operations and Behaviours)

  • Form abstraction from related sets of functions.
    • Functions should have small, focused interfaces
    • Functions should be represented through simple means
      • Polymorphism for logic depending on a type
      • Composition for login within a system
  • Specify inputs, outputs, semantics
    • Don't make consumers decide how something should happen
// Bad
/**
 * @param participant is a massive interface
 * @param specialisation forces us to decide how something happens
 */
function doTriathalon(participant, specialisation) {
	if (specialisation === 'running') {
		participant.effort = 0.9;
		participant.sweat -= 0.3;
		// do some running stuff
	}
	// do some running stuff
	
	// do some swimming
	participant.sweat = 0;
	if (specialisation === 'swimming') {
		participant.stroke = 'butterfly'
		// do some swimming stuff
	}

	// do some cycling
}

// Good
function run(participantController) {
	participant.prepareForActivity('run');
	// do some running stuff
}

// ...

/**
 * @param participantController is a small interface to control a participant of whatever specialisation
 */
function doTriathalon(participantController) {
	run(participant);
	swim(participant);
	cycle(participant);
}

Who (Implementation and Composition)

  • Entities implementing abstractions
  • Abstract through composition
    • Pursue many subcomponents
  • Don't complect with
    • Component details
class Vehicle {
	speed = 0;
	fuelLitres = 60;
	
	accelerate() {
		this.speed += 1;
		this.fuelLitres -= 0.5;
	}

	brake() {
		this.speed -= 1;
	}
}

class Jeep extends Vehicle {
	honk() {
		console.log('Beep!')
	}
}

class Tesla extends Vehicle {
	// Oh, wait!!
	// Vehicle has fuel, now we have to refactor
	// Or if we don't realise - we have complexity from an unneeded fuel property
	batteryPercent = 100;
}

// Better, use composition
const jeep: Vehicle = {
	energySource: new PetrolTank(),
	wheels: Array.from({ length: 4 }, () => new Wheel()),
	noiseEmitter: new Horn(),
}

const tesla: Vehicle = {
	energySource: new Battery();
	wheels: Array.from({ length: 4 }, () => new Wheel()),
}

How (Logic)

  • Implementing logic only through defined interfaces
    • Don't reach beyond interfaces into implementation details
  • Connect to abstractions and entities via polymorphism
// When calling the validation here, we are concerning ourselves with the implementation details
interface Validator {
	(value: any): boolean;
	inputType: 'number' | 'text' | 'select' | 'option';
}

const validateNumber: Validator = {
	(value) {
		return value !== 0;
	}
	inputType: 'number',
}

function validate(input, validator) {
	let valid = false;
	if (validator.inputType === 'number') {
		valid = validator(Number(input))
	}
	// ...

	return valid
}

// Don't concern ourselves with what's going on in the implementation
type Validator = (value: string | string[] | number) => boolean

const validateNumber: Validator = (value) => {
	return Number(value) === 0;
}

function validate(input, validator) {
	return validator(input);
}

When/Where (Temporal/Positional relationships)

  • Avoid complecting with anything in the design
  • Avoid connected objects
// These two are highly coupled, they likely won't accept anything except each other
const mailService = {
	sendWelcomeEmail(data) {
		console.log(`Welcome, ${user.data.email}`);
		user.data.welcomed = true;
	}
}

const user = {
	data: {},
	register(data) {
		this.data = data;
		mailService.sendWelcomeEmail(this);
	},
};

// mailService and user no longer know of each other
const mailService = {
	sendWelcomeEmail(email) {
		console.log(`Welcome, ${email}`);
	},
};
window.addEventListener(
	'registerEmail',
	(event) => mailService.sendWelcomeEmail(event.detail),
);

const user = {
	data: {},
	register(data) {
		this.data = data;
		window.dispatchEvent(new CustomEvent(
			'registerEmail',
			{ detail: data.email },
		));
		this.data.welcomed = true;
	}
}

Why (Rules and Policies)

  • Keep policies external
// We are deciding within our function what to use, when this is something that will not change for a project
const renderLink = () => {
	let A = await import('next/link').default;
	if (!A) {
		A = await import('@remix-run/react').Link;
	}
	if (!A) {
		A = await import('react-router-dom').Link;
	}
	if (!A) {
		A = 'a';
	}

	return <A href="/">Home</A>;
}

// Set a static policy to refer to
import Link from 'next/link';

const routingPolicy = {
	linking = Link,
};

const renderLink = () => {
	const A = routingPolicy.linking;

	return <A href="/">Home</A>;
}

Identifying Complexity

  • Avoid fallacies of simplicity
    • Driving development through tests and type checking is insurance - not a method of improving the logic of your system
  • Constructs should do one thing
    • Be aware of functions with sections separated by comments
    • Be aware of names with "ands" or "ors"
  • Take action
    • Create abstractions towards simplicity
    • Simplify the problem space before starting
    • Make more distinct parts, not fewer