Structure-State-Value Architecture for OOP

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!

ClassSingular?Stateless?Examples
StructureArray, Tree, Graph, Tuple, Set
StateCustomer, HttpRequest, Transaction, Socket
ValueString, 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!