NOTE: this article series is superceded by this article
In part 1 we talked about managing complexity in software development with modules, and in part 2 applied the ideas to views. In this article, we set our sights on the "business-logic".
Entangled Actions
If the view is the face of your app, the business-logic is the brain - and actions are the neurons that hold it together. As your app becomes more featureific, actions get more complex and interconnected.
Take these as an example:
const StartNewGame = (state) => ({
...state,
chips: 10,
})
const NewRound = (state) =>
(!state.chips == 0 || state.bet > 0) ? state : [
{
...state,
chips: state.chips - 1,
bet: 1,
},
shuffle([1, 2, 3, 4, 5], Deal)
]
const Deal = (state, deck) => ({
...state,
dealer: deck[0],
hand: deck[1],
})
const Bet = (state) =>
(state.bet !== 1 || state.chips == 0) ? state : {
...state,
chips: state.chips - 1,
bet: 2
}
const Showdown = (state) =>
state.bet == 0 ? state : {
...state,
chips: state.chips + (
state.hand > state.dealer
? state.bet * 2
: 0
),
bet: 0,
}
Here,
shuffle
is an effect that dispatches the given action with a shuffled copy of the given array.
Unless you wrote them, it's hard to tell what these actions are supposed to do. They define a simple betting game with the following rules:
- Each round the player "buys in" (= bets one chip) for the dealer to shuffle the deck, deal one card face up to the player and one card face down to themselves. The "deck" is just five cards numbered 1-5.
- Before the dealer reveals their card, the player may chose to bet an additional chip.
- When the dealer's card is revealed, whoever has the highest card wins. If the player wins, they get their bet back times two. If they lose the bet is forfeit.
- The rounds repeat as long as the player has chips.
Domains
Someone who needs to change the rules later will likely be bit worried about breaking something. We can help them by breaking out the logic in to separate domains.
How you slice domains is up to you. What matters is that you find it natural and convenient to think about each domain in isolation. Me, I see two domains in there: the "chips" and the "cards".
Primitive Transforms
What is the domain of chips? – Betting, and winning or losing the bet. The domain logic defining these processes could be formulated as:
// this is chips.js
const bet = (state) =>
!state.chips ? state : {
chips: state.chips - 1,
bet: state.bet + 1
}
const win = (state) => ({
chips: state.chips + state.bet * 2,
bet: 0,
})
const lose = (state) => ({
chips: state.chips,
bet: 0,
})
export {bet, win, lose}
Those functions, bet
win
and lose
look like Hyperapp actions – but they are not! They take a state and return a transformed version of it, but it is not the full app state – just something specific to this domain. They are not meant to be dispatched as actions in their own right. Instead they are meant to be used within action-implementations. I call these sorts of functions "primitive transforms".
Encapsulation
The chip-state needs to be kept in the full app state, since there is no where else to put it. The mathematical operations have been moved in chips.js
but the actions still have the job of moving the chip-state in and out of the full app state.
Ideally, actions shouldn't know what chip-state looks like. It should be treated as just some kind of value, and any operation you might need for changing the value should be defined as a primitive transform in chips.js
. Likewise, any kind of information we want to get out of the chip-state, needs to be defined as a function:
//this is `chips.js`
...
const getBet = state => state.bet
export {bet, win, lose, getBet}
Finally, chips.js
needs to export an init
function for creating a new chip-state, or we'd never have anything to pass to the transforms:
//this is `chips.js`
const init = (startWith) => ({
chips: startWith,
bet: 0,
})
...
export {init, bet, win, lose, getBet}
That sufficiently encapsulates everything to do with chips. Now let's do the same for cards:
// this is cards.js
const DECK = [1, 2, 3, 4, 5]
const init = (deck) => ({
player: deck[0],
dealer: deck[1],
})
const isWin = state => state.player > state.dealer
export {DECK, init, isWin}
Better?
Our new cards.js
and chips.js
modules let us refactor the actions we started with as:
import * as chips from './chips.js'
import * as cards from './cards.js'
const StartNewGame = (state) => ({
...state,
chips: chips.init(10),
})
const NewRound = (state) =>
chips.getBet(state.chips)
? state
: [
{...state, chips: chips.bet(state.chips)},
shuffle(cards.DECK, Deal)
]
const Deal = (state, deck) => ({
...state,
cards: cards.init(deck)
})
const Bet = (state) =>
chips.getBet(state.chips) != 1 ? state : {
...state,
chips: chips.bet(state.chips)
}
}
const Showdown = (state) =>
!chips.getBet(state.chips)) ? state : {
...state,
chips: (
cards.isWin(state.cards)
? chips.win
: chips.lose
)(state.chips)
}
}
Is this better? It's not less code ...
The intent of domains and operations is conveyed more clearly, since they are expressed with english words instead of math. The math is tucked away in modules, so there is less risk of introducing a bugs when refactoring actions (like a -
where it should be +
, for example). In other words it will be easier to add more features to the app in the future. –Complexity managed!
Furthermore, if you wanted to change just how cards work – say, you want to turn this into a kind of simple poker-game – you can do most of that work just in cards.js
. Primitive transforms are easy to combine and reuse, so you could make a library of general transforms for yourself, to speed up future development.
Conclusion, Part 3
If you find your Actions are a confusing tangle of operations, replace those operations with functions that transform just a subset of the state.
Gather the functions that operate on the same subset of state in modules. Each such module describes a "domain" of the business logic. Give the module an init
function and whatever query-functions that make sense.
Now, even if you dutifully broke out every single h
call from the main view as we described in part 2, and replaced every expression in your actions with primitive transforms and queries, you are still left with a monolithic list of actions, and a monlithic view passing them around.
That may be absolutely fine for you! But if you're looking for a way to break up that monolith, go on to part 4.
Top comments (0)