loading...

SAM Pattern : Where should I put this code?

dagatsoin profile image Daniel Neveux ・13 min read

TLDR;
This is my attempt to answer: "Where should I put this code?" in the SAM pattern context.

In short:

  • business code goes into the model
  • functional code goes into the action
  • model and view are in total isolation

A recent discussion on the gitter channel of SAM has reminded me one of the main reasons I am writing Ravioli for. It was about how to maximize code sharing and Separation of Concern (SOC).

One recurrent issue of a developer is : "Where should I put that code". In which folder? In which file? In which class/function...
This struggle comes at any level of experience, when a junior dev joins a team or even when a senior dev starts a new project.

As my long shot project is a MMORPG, I had/have also this issue. Each time I think about implementations, the same issues remain:

  • Never trust the client. 100% of cheaters are players.
  • Where should I put that code without adding a security breach?
  • How can I ensure isolation to keep my code maintainable?
  • How can maximize code reuse?

SAM is an awesome pattern to answer those issues. And here are some key points about.

I will use two stories to illustrate; one for a MMORPG and one for an online store.

/*
 * As a Player, I can drink a health potion
 * to restore my health points (hp).
 * Rules:
 *  - Players must be alive
 *  - The base hp is an idempotent random between 1 and 8
 *  - The bonus hp depends on the my buffs
 *  - In case of critics I heal my pet of 50% of the total gain.
 */
/*
 * As a User, I can change my password.
 * Rules:
 *  - The password must be conform (> 8 chars, only chars and number)
 *  - The password must be different from the last two passwords.
 *  - An mail is sent in case of success
 */

Now let's go through each element of the SAM pattern to see what goes where.

Model

This is the place where you will put:

  • data that the view does not need to know about
  • data validation code (acceptors) concerning business context

There is a total isolation between the model and the View. View will not have access to the model, and vis versa.

This isolation is the opportunity to design your architecture with private by design in mind :)

What should the exterior world (the representation) should know?
What should be kept secret inside the model?

Data that the view does not need to know about

Lets set a quick list of what fields our stories will need, and for each, answer one question: does it concern the internal functionalities of the model?

RPG story

  • the player's health points ✔️ Used to know if we are alive. Plus, it is a private information that enemies do not need to know!
  • the potion type (effects, points,...) ❌ the only thing we need to know is how many HP we have to add.
  • the inventory details ✔️ needed to decrease an item quantity (the potion). Also private, other players does not have no know about our inventory.
  • active buffs ✔️ directly alter our internal properties (add extra HP, force, ...). It is highly bound to player.
  • pet ✔️ the id of the player's pet.

That gives us

type Player = {
  // ...
  id: string // lets assume there is already an ID
  buffs: Array<{id: string, bonusPoints: number}> // its active buffs
  inventory: Array<{id: string, quantity: number}> // its inventory
  petId: string
  hp: number // its health points
}

Online shop story

  • user password ✔️ clearly not a public thing! Note that we also need the two last passwords. A single field is enough to store the current and old password hashes.
  • email ✔️ also very private!
  • regex to format the password ❌ No need in the model. It is more a functionality parameter. Putting this in the model would limit its reusability. Imagine you need to reuse it in an other app with another password pattern. This is the responsibility of the action. More on that later.
type User = {
  // ...
  passwordHashes: [string, string] // Last and current password hashes.
  email: string // its email
}

Data validation code: acceptors

The role of the model is to accept or reject a proposal (a payload) presented by an action.
It is done thanks to some acceptors. An acceptor has two roles:

  • check if the data are acceptable
  • mutate the model

In Ravioli, I am used to separating those roles as a validator function and a mutator function. Both composing an Acceptor. I will keep this semantic for the rest of the article.

So, let's see what acceptors are needed by our stories:

RPG Story

Player need to be alive

The story states that the player needs to be alive to restore its HP.
So that means that any HP value proposed to the model will be accepted ONLY if the model is still alive.
It is our first validator!

// check if the data is acceptable
function isAlive(model: Player): boolean {
  return model.hp > 0
}

The base potion hp gain are an idempotent random between 1 and 8

This rules specifies how to compute the power of the potion.
This is not a concern of the player. Next.

The bonus hp depends of the my buffs

Once drink, the potion adds some base HP to the player + some extra points, depending on the buff.
Once drink. That means that we are already in the player's body => business concern!
Is it a validator or a mutator? As this rules does not validate anything, it is a mutator. Indeed, it will take the base HP and add some points to it.

Let's write some functions for that.

// Compute the bonus points of a Player
function bonusPoint(player: Player): number {
  return player.buffs.reduce(function(totalBonus, buff) {
    return totalBonus + buff.bonusPoints
  }, 0)
}

// The full mutator
function addHP(model: Player, hp: number {
  if (isAlive(model)) {
    model.hp += hp + bonusPoint(model)
  }
}

In case of critics I heal my pet of 50% of the total gain.

This sound to be a side-effect. That means it something which reacts when the model had been mutated. Not a Player concern.

Removing the potion of the inventory

As we drank the potion. We need to remove it from the inventory.

Here we need to write another acceptor.

The story does not specify any validation for this, we will write only one mutator.

// Remove an item from the inventory
function removeItem(model: Player, itemId: string) {
  // No validator specified.

  // Locate the potion stack
  const index = model.inventory.findIndex(({id}) => id === itemId)
  // do the mutation
  model.inventory.splice(index, 1)
}

Online shop

The password must be conform

As we saw earlier, this is not a concern of the model. Even if it seems to be a validator, and it is ; it can be exported to an other level. Let's keep our model lean.

An mail is sent in case of success

Sounds also like a side-effect. "In case of success" means that the model has finished and successfully accepted the new password. Next.

The password must be different from the last two.

This rule needs to know about the two last user's password (in fact, the current one and the last one).
This information is stored in the model. To this is a good place to write our code.
Also this rule is clearly a validator and must prevent the mutation if the password is not different from the last two.

Again a simple function:

function isDifferentFromTwoLastPwd(model: User, pwdHash: string): boolean {
  return model.passwordHashes.every(old => old !== pwdHash)
}

So the full acceptor will be:

function setPassword(model: User, pwdHash: string) {
  if (isDifferentFromTwoLastPwd(model, pwdHash) {
    model.passwordHashes.shift()
    model.passwordHashed.push(pwdHash)
  }
}

View

Ok, so now we have implemented what we need in our model. Let's take a look to the View.

In SAM the Model and the the View are strongly decoupled. That means that the model is invisible to the view.

The view is an immutable representation of the model, which is updated during during the SAM loop, after that the model had been updated.

The View can be some UI, 3d assets, some JSON, or any other immutable representation.

When you think about what to put in the view, you need to answer this question: How I want to represent my model, my private world, my private data, to the rest of the world. A bit like Instagram, but for your model.

So let's crawl our stories' needs.

RPG story

For this case I suggest to derivate the model as a JSON Object that we will send to client. The client will consume this model representation into its UI. Done.

Note in this simple example how SAM is flexible enough to design a solution around a distributed system (client server architecture).

Let's review fields:

  • the ID of the player ✔️
  • the player's life representation ✔️ Useful to know if we are alive. So, we finally need to expose this from the Model.
  • the player max point ✔️ If we want to draw a life bar we need to know the max point a player can have.
  • the inventory details ❌ No needed for this story. Maybe next time.
  • a list of consumable ✔️ If we don't need to have the full inventory, some items are still needed to be known by the view. In our case: the potions. How does many potions remain? What is the item id?
  • active buffs ❌ No needed too. Out of scope
  • pet id ❌ We might think so in the first place, but we will see later that is not the View who will need it.

That gives us

type PlayerView = {
  // ...
  id: string
  life: number
  maxLife: number
  consumables: Array<{itemId: string, type: ITEM_TYPE, nb: number}>
}

We end with a lean view which can display all we need for this story.

Online shop

  • the ID of the user ✔️
  • old password ❌ surely not!

Quite simple here. Indeed, we just need to know the user ID to update the right one. (for simplicity, let's assume that the user auth system is not taken in account here)

So:

type UserView = {
 id: string
}

Actions

An action is just a function which takes an Intent and output a Proposal

type Action<I extends Intent, P extends Proposal> = (intent: I) => P

Lets begin with some rules about actions:

The action has not access to the model.

It does not do any validation that involves non exposed data in the ModelRepresentation.
The action is executed in a functional context and don't know about how the model is made.
It only knowns one function of the model. Present. Which take a payload called the proposal.

In our case we can define it like this type:

type AddHP = {
  type: 'addHP'
  payload: {
    hp: number 
  }
}

type RemoveItem = {
  type: 'removeItem'
  payload: {
    itemId: string
  }
}

type Proposal = Array<AddHP, RemoveItem>

The effect of an action is not guaranteed!

The proposal is something that the model should accept. Remember, only the Model can accept or reject the proposal.
If you trigger an action, consider the effect as hypothetic.

An action should be pure and idempotent

This rules depends on your need. But in video game there are common pattern based on undo/redo for security and lag compensation purpose.

The action, for the same intent, should produce the same proposal.

So, be careful when you action uses externals services. Make sure their response will be idempotent.

Example: imagine a Player triggering an action LaunchFireBall on an Enemy. The life bar Enemy is 1hp, the Player is already thinking at its loot.
But Enemy has a secret buff, which will restore its HPs and switch on Berserk mode when it receives damage while it has 1hp. Oups.

The proposal can be composed

A good practice is to think acceptors as a the lowest level API of your application and actions as a higher level API.

In our first case, the littlest operations the model can do is to:

  • add some hp
  • remove an item of the inventory

We can think our actions as a composition of those acceptors:

  • drink a potion is composed of:

    • add some hp to the player
    • remove the potion from the inventory
  • change your password is just:

    • set a new password

This way of split low/high level API can help a lot to have a clean and reusable base code. For example you can easily grab removeItem and use it in the online shop model.

Ok. Now let's implement our stories.

RPG story

Here the main action is to drink a potion. There is also a secondary action. Heal the pet under condition.
This is a side-effect, a reaction, of the first action. We also need to define it.

Drink a potion

The intent
How would the View call this action.
In the View, we know:

  • the number of available potions
  • the potion id

so the UI (in the Player bar action) should trigger this code:

function drinkPotion() {
  const hasPotion = playerView.consumables.some(({type}) => type === 'HP_POTION')
  if (hasPotion) {
    const itemId = playerView.consumables.find(({type}) => type === 'HP_POTION')!.itemId
    actions.drinkHealthPotion(itemId)
  }
}

The proposal

We see early that we can decompose the action of drinking a potion as two acceptors:

  • add some hp
  • remove an item of the inventory

So we already can define the proposal resulting of this action.

type DrinkHPPotionProposal = [AddHP, RemoveItem]

Functional logic

Now we know what to return, we need to implement the rules which will operate during the action.

What are they?

  • Players must be alive ❌ Nope. Already implemented in the Model. Plus, we can't be sure that the HP exposed in the view are the same as in the model. (remember isolation)
  • The base hp is an idempotent random between 1 and 8 ✔️ Yes, this, is part of the context of the Action. We need to know how many point this potion will generate.
  • The bonus hp depends on the my buffs ❌ Nope. Also implemented in the model. Buffs are not exposed to the View.
  • In case of critics I heal my pet of 50% of the total gain. ❌ out of scope.

Ok so the action should be a thing like this.

function drinkHealthPotion(itemId: string): [AddHp, RemoveItem] {
  // ask to the GameMaster service how many point will be this shot.
  // Note that GameMaster service needs to be idempotent here.
  // For simplicity we assume the service is synchronous.
  const hp = GMService.getHP(itemId)
  return [{
      type: 'removeItem',
      payload: {
        itemId
      }
    },
    {
      type: 'addHP',
      payload: {
        hp
      }
  }]
}

Heal the pet

For this one, let's assume we have already a Pet model somewhere which use the same addHP acceptor as the Player model (see decoupled code reuse?)

The intent
Here we need to target the pet id and some hp value.

The proposal
Here we don't need to handle item removal. So it will simply add a few points to the pet hp.

type HealPetProposal = [AddHP]

The functional logic

No validation specified in the story. The 50% of the total gain is not computed here. We will put this outside the action.

So the final action is very simple:

function healPet(petId: string, hp: number): [AddHP] {
  // No logic at all. Just format the proposal.
  return {
    type: 'addHP',
    hp
  }
}

Online shop story

Here the main action is to change the password. There is also a side effect bound to this action. Send an email in case of success. It won't be an action because it does not need to present something to the model.
This spec is more a fire-and-forget, where the eventual impact on a distant system won't affect our system.

Change password

The intent
Well very simple, just a string. (let's assume the user is an global execution context variable)

The proposal

The only usable acceptor accept a hash as a payload.

type SetPwd = {
  type: 'setPassword'
  payload: {
    pwdHash: string
  }
} 

The functional logic

Let's read the story to see what to implement:

  • The password must be conform ✔️ This is functional logic and in the context of the action call. Also it does not depend on the Model.
  • The password must be different from the last two ❌ Already implemented in the Model.
  • We will need to convert a readable password into a hash.

Also the story does not specify what to send as proposal if the password is not conform. This is a common topic which I will cover on a future blog. For now we will just pass an error.

The action could be:

function changePassword(pwd: string): [SetPwd|SetErr] {
  // Check password format
  if (isConform(pwd)) {
    return {
      type: 'setPassword',
      payload: {
        pwdHash: toHash(pwd)
      }
    }
  } else {
    // To implement later.
    return {
      type: 'dummyProposal',
      payload: {}
    }
  }
}

NAP (Next Action Predicate)

Our implementation are nearly complete.

  • We can drink a potion
  • Our health points are restored
  • We can change our password

It remains one thing. Trigger the side-effects to heal our pets / send a mail.

Our actions are already written. Let's take a look where to trigger them.

It will be in the NAP, during the SAM State function computation.

The SAM State context is like a container. It knows everything about the model and the delta between the current model and the previous one.
This is an ideal place to trigger reaction to model changes. And this is the role of NAP.

Computing the delta is up to your SAM implementation. In Ravioli I use a JSON patch to describe the change in my model.

A NAP is of a predicate function which will trigger or not another action given the actual state of the model and its recent changes.

Trigger the pet healing

So what we need to trigger our pet healing?

Here we need to target the pet id and the hp which has been received by the player.

The added HP will be part of the step delta between the model data before and after the drinkHealthPotion action!
Lucky enough, we will use this action in the NAP which is aware or this delta.

function NAP_petHeal(
  model: Player, {
    changes
  }: {
    changes: Changes // A JSON patch representing the changes
  }
) {
  const change = changes.find(({path}) => path === '/hp')
  // Let's assume our GameMaster is aware of what is critic.
  if (change && GMService.isCriticalHeal(changes.value)) {
    // Trigger the action
    // Note how we can access the model here
    // as we are in the SAM State function
    // and preserver the action to know about the model
    healPet(model.petId, hp/2) // Heal to 50% of the HP
  }
}

Send a mail if password is successfully changes

To trigger the mail sending we need to be sure that the password has changed. So, we need to compare the previous and the actual hash passwords. I will do it differently, as in Ravioli I implemented a way to know about accepted and rejected proposals.

function NAP_sendMail(
  model: User, {
    acceptedProposals
  }: {
    acceptedProposals: Proposal[]
  }
) {
  const change = acceptedProposals.find(({type}) => type === 'setPassword')
  if (change) {
    const hasChanged = model.passwordHashes[1] === change.payload.pwdHash
    if (hasChanged) {
      // Trigger the action
      sendSuccessPwdChangeMail(model.id, model.email) // A mail sender. Somewhere.
    }
  }
}

Conclusion

Here it is we have implemented our two stories. I hope you have noticed how our logic is split in different opinionated zone.
This for me a real helper to not struggle with the "Where to put this code" issue.

Also, maybe some of you will be frustrated to not see the wiring between all those elements. It would be out of scope of this article. The fun thing is when you have already implemented you SAM pattern or use a lib out there. You won't have this feeling. You will implementing complexe stories just with a few functions.

Keeping an isolation between the View/Model and Action/Mutations enforces ourselves to think our application as a distributed system with high and low level of responsibilities.

This has several benefits in a large app:
Separation of concern: business code is in the model, functional code is in the view/action
extensibility: with this logic, you can implement new functionalities just by writing new actions and without touching the model.
maintenance/regression: less surface of code changes at each iteration

To reuse an old idea of @metapgmr_twitter Imagine a service were a SAM instance exposes a present function which accepts only a set of the lowest possible mutations.

This would let users to write their own actions in complete independence of the model. Transpose this in a specific domain and you will end with a no-code app editor or a MMORPG with more crafting possibilities that Skyrim or Breath of The Wild together. Just dreaming :)

Posted on by:

Discussion

markdown guide