Structure-State-Value Architecture for OOP
Why aren't there any OOP guidelines for creating small/medium-sized classes? MVC is cool, but it's too high-level! MVC tells you where components should go, but it doesn't inform engineers on how the components should operate or communicate or fit together. Of course design-patterns are also cool, but there are so many! Each design pattern is focused for very specific problems and optimizations, and doesn't provide any "framework" for thinking about how fit everything together.
So to complement large frameworks like MVC and focused templates like design-patterns, I present to you the Structure-State-Value (SSV) Architecture! Here are some major principles of SSV:
- assert information-loss
- isolate moving-parts
- minimize moving-parts
- reuse structures
- compose structures
This framework is No Silver Bullet! However, if a new class doesn't fit into SSV, it's sometimes an indicator that your object is doing too many things — just sayin' 💁
SSV unabashedly promotes clarity over performance. If you find that performance is lacking, it's usually a structural problem. Learning to think with pipes is a good start for making fast, memory-efficient systems without touching the smaller pieces of the system.
In this guide, I present common methods that you'll find in each class. This does not mean that you should try to implement them if they don't exist! The methods described in the SSV framework are for identifying how a class might work, rather than prescribing how a class should work.
Thanks to Jon Anderson for sparking this idea!
Class | Singular? | Stateless? | Examples |
---|---|---|---|
Structure | ❌ | ❌ | Array , Tree , Graph , Tuple , Set |
State | ✅ | ❌ | Customer , HttpRequest , Transaction , Socket |
Value | ✅ | ✅ | String , Email , UUID , URI , Color , Maybe |
Value
Classes for singular and stateless chunks of information.
Examples: String
, Time
, Dollar
, DateRange
, EmailAddress
, LastName
, UUID
, JSON
, PostalAddress
, URI
, FilePath
, SHA5
, Color
class Length
{
// Represented as meters.
private x = 0;
constructor( x )
{
if( x === null || x === undefined )
throw new Error(`Length() cannot be null or undefined.`);
if( x < 0 )
throw new Error(`Length() expects a non-negative number.`);
this.x = x;
}
static fromFeet( x )
{
return new Length( x / 3.28084 );
}
static fromMeters( meters )
{
return new Length( x );
}
get toFeet()
{
return this.x * 3.28084;
}
get toMeters()
{
return this.x;
}
}
Value objects are useful as immutable structures that can be combined and transformed. States and structures use value objects to store information.
Value classes should never have side-effects. They don't make HTTP requests, they don't touch the file-system, and they don't talk to databases.
Value classes should be immutable. Nothing inside the object should ever be updated after construction. Value classes have no setter methods. Of course, sometimes it's necessary to mutate data in-place for memory/performance reasons, but that kind of stuff should be avoided when possible.
Constructor
Constructors for value classes only do two things:
- validate the arguments
- set the internal properties
That's it! Easy!
Constant Methods
Examples: Number.infinity()
, Color.green()
, DateTime.unixEpoch()
, FilePath.root()
Constant methods are static methods for generating unique/significant value objects.
Null objects are a special case of constant methods.
Decoder Methods
Examples: JSON.parseString('{}')
, String.fromCharList(['x'])
, PostalCode.fromInteger(91234)
, Country.fromString('US')
, char.fromKeyCode(21)
Decoder methods are essentially just wrappers around the class constructor.
The constructor handles validation and declares the instance's properties. So all the decoder has to do is transform the input into something that's palatable for the constructor!
Encoder Methods
Examples: datetime.toUnixTimestamp()
, filePath.toString()
, json.stringify()
, hash.toString(privateKey)
, color.toHexString()
Encoder methods produce equivalent objects of different types.
When creating encoders, the key is to avoid loss of information! Anticipate where information might be lost. For example, consider some DateTime
object with timezone information: datetime.toUnixTimestamp()
is ambiguous. Is it going to return the timestamp in PST or UTC? A better design would be to require a timezone argument, e.g. datetime.toUnixTimestamp('UTC')
.
If any information is lost during the encoding process, make sure it's clear. And avoid default-values at all costs! Make no assumptions about which information to throw away.
Extract Methods
Examples: uri.host()
, float.floor()
, string.charAt(7)
, color.saturation()
, string.startsWith('🐸')
, signature.isSignedBy(publicKey)
Extraction methods are exactly the same as encoder methods, but with a lot more information loss. They're used to construct a view of a small subset of the value object.
Compound values like URI
may have multiple properties like protocol
, host
, and path
, and query
. In this case, extractors act as getters.
When extracting an Integer
from a Float
, you're forced to throw away the fractional part of the number. float.toInteger()
would be a bad idea, because you don't know how the integer is being calculated. That's why we need float.floor()
, float.round()
, and float.ceiling()
.
Cut Methods
Examples: int.absoluteValue()
, string.slice(1,3)
, datetime.midnight()
, string.toLowerCase()
Cut methods simply throw away some information. They produce objects of the same type, just with some stuff missing.
Mix Methods
Examples: string.reverse()
, number.negate()
, boolean.not()
, string.exclaim()
Mixers produce objects of the same type with no information loss!
Merge Methods
Examples: int.add(21)
, string.concat('!')
, path.join(Path.home())
, uri1.equal(uri2)
Merge methods add information to the object that may or may not destroy information. Merge methods always produce objects of the same type. For instance, datetime.addMinutes(10)
will create a new DateTime
object offset by an equivalent amount of minutes.
Operator Methods
Operator methods are special cases of Merge methods that accept arguments of the same type and produce a value of the same type.
Operators are particularly handy because they allow you to reduce structures of values with minimal work!
const integers = Group(
Integer(1),
Integer(2),
Integer(3)
);
const sum = integers.foldl((x,y) => x.add(y), Integer(0))
When you have homogenous objects, you can merge them together with methods like .reduce
and .foldl
!
Another common use case of operators is to use value.compare()
with structure.sort()
.
State
Classes are for singular and stateful chunks of information.
Examples: Customer
, HttpRequest
, Transaction
, Socket
// This is a contrived example to show off weird state stuff.
// Please do not copy this; it's not a very good way to handle requests.
class WebPage
{
// "NOT-ASKED" | "WAITING" | "SUCCESS" | "FAILED"
private status = "NOT-ASKED";
// Web page as HTML string when status is "SUCCESS
// Error message as string when status is "FAILED"
private data = null;
constructor( url )
{
const request = fetchHtml( url );
this.status = "WAITING";
request.on( "success", body => {
this.status = "SUCCESS";
this.data = body;
});
request.on( "error", message => {
this.status = "FAILED";
this.data = message;
});
}
get getPage()
{
if( this.status === "SUCCESS" )
return this.data;
else
return null;
}
get getErrorMessage()
{
if( this.status === "FAILED" )
return this.data;
else
return null;
}
}
The methods of state classes are verbs. Actions like customer.purchase(item)
and httpRequest.respond(200,body)
describe how things change internally or produce change in other systems.
Constructor
Constructors for state classes have two essentials:
- validate the arguments
- set the internal properties
Beyond that, there's little restriction to what you can do in your constructors!
Constant Methods
Examples: ShoppingCart.empty()
const cart = ShoppingCart.empty();
cart.addItem(item1);
cart.addItem(item2);
cart.addItem(item3);
await cart.purchase(paymentInfo);
With state classes, constant methods are useful as "starting points" for creating objects.
Perspective Methods
Examples: customer.sendNewsletterEmail(newsletter)
, document.print(printer)
, car.honk()
Sending messages to the outside world!
Although these methods may affect internal state, the grand purpose is to do something from the perspective of the state object.
In other words, these methods are more concerned with producing change in the outside world than the internal state.
The behavior of perspective methods vary based on what state the object is in. For instance, car.honk()
won't change if the car is in PARK
or REVERSE
, but it may throw an error if car.isBatteryDead() === true
.
Manipulation Methods
const cart = ShoppingCart.empty();
// Manipulate the cart.
cart.addItem(item1);
cart.removeAllItems();
// Attempt state-transition.
try
{
await cart.purchase(paymentInfo);
} catch( error )
{
console.error( error );
// "Couldn't complete purchase because cart is empty!"
}
// Manipulate the cart.
cart.addItem(item1);
// Attempt state-transition.
await cart.purchase(paymentInfo);
// Manipulate the cart.
try
{
cart.addItem(item2);
} catch( error )
{
console.error( error );
// "Cannot add items to a purchased cart!"
}
Examples: shoppingCart.addItem(item)
, customer.setAddress(address)
, car.applyGas(force)
Manipulation methods are for non-state-transition updates.
These are usually for updating properties unrelated to stages.
As demonstrated in the example code, manipulation methods often change behavior depending on the state of the system.
Manipulation methods like customer.setAvatarImage(imageUrl)
may produce side-effects like saving a photo to AWS/S3, but the main intent is to update some variable data.
State-Transition Methods
Examples: shoppingCart.submit()
, httpRequest.respond(200,body)
, user.suspend(reason)
, trafficLight.stop()
, customer.verifyEmail(verificationCode)
, car.park()
class TrafficLight
{
// "RED" | "YELLOW" | "GREEN"
private color = "RED";
constructor( color )
{
if( !["RED","YELLOW","GREEN"].includes( color ) )
throw new Error(`'${color}' is not a valid TrafficLight color.`);
this.color = color;
}
stop()
{
switch( this.color )
{
case "RED":
throw new Error('TrafficLight is already stopped.');
case "YELLOW":
this.color = "RED"; break;
case "GREEN":
throw new Error('TrafficLight must slow before stopping.');
}
}
slow()
{
switch( this.color )
{
case "RED":
throw new Error('TrafficLight cannot slow cars while they're already stopped.');
case "YELLOW":
throw new Error('TrafficLight is already slowing.');
case "GREEN":
this.color = "YELLOW"; break;
}
}
go()
{
case "RED":
this.color = "GREEN"; break;
case "YELLOW":
throw new Error('TrafficLight cannot make cars go while they're already slowing.');
case "GREEN":
throw new Error('TrafficLight is already going.');
}
}
The intent of these methods is to move an object into a different "stage" of its lifecycle.
It's helpful to map out these transitions using a state-transition diagram! In particular, it's helpful to throw errors on illegal state-transitions, so that your program can't be put into an errant state.
A car object may have stages like PARK
, REVERSE
, NEUTRAL
, and DRIVE
. The object may also have orthogonal stages like NEW
or USED
. But note that an enumeration like HONDA
or FERRARI
may not be a stage; many categories are not used to describe the quality of a changing process.
Query Methods
Examples: shoppingCart.items()
, user.isEmailVerified()
, trafficLight.color()
, car.speed()
Query methods are getters.
Outside code shouldn't be poking and prodding around objects' internal properties, so use these methods to expose controlled "views" of the data.
Structure
Classes for organizing multiple values or states generalized over any type.
Examples: Array
, List
, Graph
, Tuple
, Tree
, Stack
class NonEmptyStack
{
private data = [];
constructor( xs )
{
if( xs.length <= 0 )
throw new Error('NonEmptyStack cannot be empty!');
this.data = xs;
}
map(f)
{
this.data = this.data.map( f );
}
toList()
{
return this.data;
}
push( x )
{
return new NonEmptyStack(
this.data.concat([ x ])
);
}
pop()
{
if( this.data.length <= 1 )
throw new Error('Cannot pop off NonEmptyStack when only one item remains!');
return new NonEmptyStack(
this.data.slice(-1)
);
}
}
Lastly, we have structures! Structures are generalized classes for holding other objects.
Structures should be immutable when performance permits it.
MAP METHODS
Examples: list.map(f)
, dict.mapKeys(f)
, tree.forEach(f)
Map methods are useful for updating items at once! If built correctly, computers can efficiently run these operations in parallel on each item.
COLLAPSE METHODS
Examples: list.reduce(f,x)
, dict.foldl(f,x)
, array.scanl(f,x)
, tree.collapse(f,x)
, graph.collect(f,start)
The goal of these methods is to collapse all the values into a single accumulator value.
MIX METHODS
Examples: list.sortBy(f)
, dict.shuffle()
, tree.rebalance()
, array.removeDuplicates()
, tuple.reverse()
For ordered structures, it's always nice to non-destructively sort the information.
CUT METHODS
Examples: list.filter(f)
, dict.filterKeys(f)
, tree.chopLeft()
, array.removeDuplicates()
, list.slice(1,2)
, graph.removeNonNeighbors(i)
, tuple.first()
Remove chunks of the structure!
MERGE METHODS
Examples: list1.concat(list2)
, dict1.deepMerge(dict2)
, tree.appendBranch(branch)
, array1.zip(array2)
, set1.difference(set2)
Take two structures and make a new structure!
ITEM METHODS
Examples: list.getItemAt(0)
, dict.insert(k,v)
, tree.removeNode(i)
, array.update(i,x)
, graph.addNode(edges)
, tuple.setSecond(5)
Perform CRUD operations on one or more items in your structure.
QUERY METHODS
Examples: list.length()
, dict.keyExists(k)
, tree.depth()
, array.indexOf(42)
, graph.shortestCycle()
These are helper functions to find information about singular items or properties of the whole structure.
IMPORT/EXPORT METHODS
Examples: list.toSet()
, set.toList(sortFunction)
, dict.toPairs()
, Graph.fromTree(tree)
, tuple.toList()
Sometimes it's helpful to transfer between different structures!