A SOLID framework - Inversion of Control Pt 1

miketalbot

Mike Talbot ⭐

Posted on June 19, 2020

A SOLID framework - Inversion of Control Pt 1

Read this tutorial if:

  • You want to architect systems that may be extended or improved over time
  • You’ve heard about Inversion of Control but have never applied it practically
  • You are building systems with disparate development teams working on different features and want to work together better
  • You are building a solution that needs to be customised differently for individual users or clients
  • You want to write modular, encapsulated code that is easy to test
  • You want to build SOLID architectures in Javascript
  • You’d like to take on some exercises in practically applying Inversion Of Control principles with a useful sample project to work on

The What? & The Why?

The requirements we first hear about for a project often end up being different to those that we eventually implement. As we move through the project and get user feedback new ideas pop up, initial assumptions are invalidated and the whole thing can start to slide dangerously beyond the boundaries of the system we initially architected. There are many ways we can address this but the one I choose for most of my non trivial solutions is “Inversion of Control”.

Inversion of Control (IoC) is the opposite way of solving a problem when compared to the imperative style that we learn when we start coding. Rather than telling the computer what to do, we declare things we know how to do and orchestrate interactions using loosely coupled abstract events. These events form a framework contract that comprises a series of events and their interfaces. The contract is fundamentally extensible, enabling elements of the code written years later to seamlessly integrate and extend the initial solution, often requiring no changes to the core code. An IoC solution is therefore easily maintained and extended.

It may sound weird to start with, but there’s something so beautifully simple about the solutions built using IoC that properly encapsulate functionality and can easily separate concerns.

A properly architected IoC solution provides significant benefits:

  • We write modules that are fully encapsulated, so we can easily work with teams of people who are all writing different parts of the code without worrying about lots of inter-team communication to figure out what goes where.
  • We can easily write tests for modules as they are inherently isolated with clearly defined communications
  • Works brilliantly for both UI and backend code
  • We can easily adapt a solution to have different functionality in different circumstances. Client X wants feature Y, but client Z wants it a different way? No problem.
  • We can try out new features for a select group of customers or testers
  • It’s honestly liberating! IoC removes a lot of the fear about changing something that works - because that’s what it is begging to do…

This series is going to explore IoC through two non-exclusive architectural paradigms: events and behaviours. The first few parts will focus on event driven IoC and will use the example game project that I built for the sole purpose of delivering a practical real world implementation of UI and processing under IoC. The second part will extend this to include behaviours which are used significantly in game development frameworks, but as I will show, can be equally applied to business systems.

We tend to use behaviours when individual elements of a solution that are naturally presented as “the same thing” will behave differently and should be provided with different functionality. For instance a document driven system might have documents that could be drawings or a textual descriptions. Both types of document appear to be the same to the user in a list, share some common properties and functions, but behave differently when interacted with in specific contexts.


Event driven IoC works great for the whole UI and framework of an application - you can probably see that a complex app may well end up using both - we often start with events though, as they drive our framework.

The Demo Game

This is the game that we are using for this series to exhibit the benefits and principles of IoC. Feel free to refer to it and its source code whenever you want to dive into the concepts or practical realities. As this series progresses we will extend the code further.

The game implements a “framework” and some uses of that framework that actually make up the game you play. We’ll introduce the elements of this at the end of this article before challenging you to use the techniques presented to make a customised version of the game.

A SOLID solution

Michael Feathers coined the SOLID acronym to describe Robert C Martin’s core principles of Object Oriented Design which he introduced in 2000 as a way of describing how to make software solutions easy to understand and easy to maintain.

Inversion of Control is a way that we can construct an object oriented system that adheres to the SOLID principles. It specifically helps with some of the principles and can be easily coded to follow others. Here’s solid with the Wikipedia descriptions:

Let’s see how they apply.

Single responsibility

The key principle of Inversion of Control is to identify events and states and have zero or more things respond appropriately to this information. IoC significantly simplifies having things hold only a single responsibility and liberates other parts of the code to declare interesting information without thinking about how such information could be used.

In our example game popping a bubble or collecting an apple declares the event with an appropriate value. Something else entirely uses that value to update a total score, and something else uses that score to play an animation of a rising “sting” number for player satisfaction! None of these things need to know anything specific about the other and the game will happily function with no score or special effects.

Score understands scoring. Apples understand collection. The mission understands the value of collecting an apple.

plug(
    "mission-indicator",
    ({ item }) => !item.red && !item.green,
    BonusIndicator
)

function BonusIndicator({ isCurrent }) {
    useEvent("collect", handleCollect)
    return null
    function handleCollect(apple) {
        if (!isCurrent) return
        cascadeText({
            x: apple.x,
            y: apple.y,
            color: "gold",
            number: 12,
            duration: 3.5,
            speed: 300,
            scale: 4
        })
        raiseLater("score", { score: 1500, x: apple.x, y: apple.y })
    }
}
Enter fullscreen mode Exit fullscreen mode

Skipping the details of the implementation of the IoC events for a moment (we’ll get to it later…) here we can see the indicator component that is responsible for showing Apple data during a mission. The plug() inserts this indicator on a “mission step” which has no specific requirement for red or green apples. In this case you get a bonus for collecting one.

The component itself doesn’t render anything, but does add an event handler of the “collect” event sent by an apple when it reaches the bank. On a collection, the component plays a gold star splash animation to indicate a successful collection and then just says, I think this is worth 1500 points and it happened right here.

I’ve chosen to deal with scores like this:

import React from "react"
import { Box, makeStyles } from "@material-ui/core"
import { floatText } from "../utilities/floating-text"

const { handle, useEvent } = require("../../lib/event-bus")

let gameScore = 0
handle("ui", (items) => {
    items.push(<Score key="score" />)
})

const useStyles = makeStyles((theme) => {
    return {
        scoreBox: {
            fontSize: 48,
            textShadow: "0 0 4px black",
            position: "absolute",
            left: theme.spacing(1),
            top: 0,
            color: "white",
            fontFamily: "monospace"
        }
    }
})

function Score() {
    const classes = useStyles()
    const [score, setShownScore] = React.useState(gameScore)
    const [visible, setVisible] = React.useState(false)
    useEvent("score", updateScore)
    useEvent("startGame", () => {
        gameScore = 0
        setShownScore(0)
        setVisible(true)
    })
    useEvent("endGame", () => setVisible(false))
    return (
        !!visible && (
            <Box className={classes.scoreBox}>
                {`${score}`.padStart(6, "0")}
            </Box>
        )
    )
    function updateScore({ score, x, y }) {
        gameScore = gameScore + score
        setShownScore(gameScore)
        let duration = score < 500 ? 2 : 3.5
        let scale = score < 1000 ? 1 : score < 200 ? 2.5 : 4
        floatText(x, Math.max(100, y), `+ ${score}`, "gold", duration, scale)
    }
}
Enter fullscreen mode Exit fullscreen mode

Again we’ll discuss the way that the event bus works in a moment. Suffice it to say here we generally add a score component to the “ui” - a service for rendering things that is provided by the game’s framework. The framework knows nothing except how to provide a space for components, it has no idea what a score is.

Our Score component listens for “startGame” events and sets the total score to 0 and displays the score. When a “score” event happens it updates the total score and floats up a text “sting” with a size and duration dependent on the value. In other words, it’s really good at understanding and reacting to scores. It has no clue what made them.

A part of the apples system also understands what happens when you collect apples. It is completely separate to the thing that animates apples, which is itself completely separate to the thing that moves them. The red apple collector component knows it’s a bad idea to collect green apples.

plug("mission-indicator", ({ item }) => item.red !== undefined, RedIndicator)

function RedIndicator({ item, isCurrent, next }) {
    const [red, setRed] = React.useState(item.red)
    useEvent("collect", handleCollect)
    return (
        <Badge color="secondary" invisible={!isCurrent} badgeContent={red}>
            <Avatar src={apple1} />
        </Badge>
    )
    function handleCollect(apple) {
        if (!apple.color) return
        if (!isCurrent) return
        if (apple.color() === "red") {
            raise("success", apple)
            cascadeText({
                x: apple.x,
                y: apple.y,
                color: "gold",
                number: 12,
                duration: 3.5,
                speed: 300,
                scale: 4
            })
            item.red--
            setRed(item.red)
            if (!item.red) {
                next()
            }
            raiseLater("score", { score: 2500, x: apple.x, y: apple.y })
        } else {
            raise("error", apple)
            cascadeText({
                x: apple.x,
                y: apple.y,
                color: "red",
                text: "",
                number: 6,
                duration: 3.5,
                speed: 300,
                scale: 3
            })
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

When you collect a red apple WooHoo, when you collect a green one it plays an animation indicating an error - and raises that as an event. It has no idea what a life is… it just knows that the user did a bad thing and raises an error. It doesn’t even know what an apple is apart from it must support an interface that has the color() method on it that will return “red” sometimes and a coordinate.

It also knows that the current “mission step” has an interface that has a “red” on it as a number and it’s provided a method to say “we’re all done with my interest here” called next(). You know, the thing that provided the original “red” count - yep that was a component too, all it knew was how to read a configuration file or make up a number of apples…. Everything is very separated and communicates the minimum information necessary.

Open/Closed Principle

According to SOLID an object should be open for extension but closed for modification.

The only way to get to a RedIndicator is by issuing a “collect” event and passing something with a color() to it. So it’s not possible to modify it directly hence this solution passes the conditions of the “closed” principle, but according to the “open” part we have also declared how it can be extended. We raise “score”, “success” and “error” events which are the connection points for that extension.

Through the inherent way that my method of IoC works though, we can also totally replace the functionality of RedIndicator if we wish. Let’s say we add magic apples that RedIndicators know nothing about (we will do this exercise in a later part, but here’s a sneak peek):

  • We can override the whole red indicator by creating a plug() with a higher priority that just disables the current one conditionally
  • We can add an additional renderer for magic apples that is displayed before or after the existing RedIndicator
  • We can handle the “collect” event alongside RedIndicator
  • We can handle the “collect” event at a higher priority than RedIndicator and modify what is being sent, or just never pass on the event any further

So without ever modifying a line of code in the framework, or a single line of code in RedIndicator we can extend the system to have a totally new feature that we can conditionally enable and disable. We don’t even need to see the code for RedIndicator to do this and all we need to do is have this code loaded by something for it to function.

Open/Closed is a vital principle and I hope that you are beginning to get an idea of just how much we can exploit it with a very few very simple lines of code.

Liskov substitution

This principle says that derived items should function exactly as they’re ancestor but with additional functionality as required.

This is more of a stretch for IoC. Clearly we could derive something from RedIndicator and its brethren using prototypical inheritance and then use that instead by overriding RedIndicator’s plug, but Liskov is referring more to classical inheritance and IoC favours composition. You can do either, but modern thinking is that we should use composition unless we can think of a good reason why inheritance would provide a benefit.

IoC gives us an excellent way to enhance or replace a component, should you override it then implementing the same tiny interface is all you need to have a fully functioning override.

Interface Segregation

The messages we pass through events in IoC define our interfaces and they are normally very minimal indeed as suggested by the Interface Segregation principle. Indeed between components we tend to not call methods at all, just provide information that can be consumed through a tiny interface.

Let’s consider the heart of our game, the Apple. An Apple you see floating around is actually two loosely coupled components. One that knows how to draw an Apple on the screen and into the physical world model - this is combined with another that knows how to move an Apple and have it be collected.

In addition to its physical attributes and movement, Apples are also part of a mission. To the “mission” an Apple provides a straight forward interface that contains an x, y and a color() through the collect interface.

As mentioned, an Apple is also a part of the physical world. It represents this by declaring its position and radius through the circle interface. It declares this every frame that it is visible. Apples also consume this interface which they use to keep them apart from other Apples and bottles - plus of course anything you fancy adding yourself.

Finally the movement component is more tightly coupled as it needs to rotate the Apple and move it based on a velocity derived from its interactions with the player and the rest of the world, it is also using that velocity to control the depth the Apple sinks below the water.

Even given this tight coupling there is still very little information to be passed - an Apple has a move(x,y) function, a setDepth() and one more for rotation that isn’t shown in this extract. The multi-frame functionality using yield here is implemented through js-coroutines.

      while(mode==='float') {
            //Apply friction
            v.x = interpolate(v.x, baseX, t)
            v.y = interpolate(v.y, 0, t)
            //Apply buouancy
            coreDepth = coreDepth > 0 ? coreDepth - 0.02 : 0
            //Apply downward pressure based on speed (v.length)
            coreDepth = Math.max(
                0,
                Math.min(2, coreDepth + Math.min(0.027, v.length() / 34))
            )
            //Set the depth
            apple.setDepth(coreDepth)
            //Wait for the next frame
            yield
            //Update the apple (v.x/v.y may have been modified by events)
            apple.move(apple.x + v.x, apple.y + v.y)
            //Collect if at the bank
            if (apple.y < 100) {
                mode = "collect"
            }
            //Remove if off screen to left or right
            if (apple.x < -50 || apple.x > 1050) {
                mode = "lost"
            }
       }
Enter fullscreen mode Exit fullscreen mode

Dependency Inversion

This says that the code should only depend on things injected into it. We take that a step further with IoC by just not having declared dependencies and instead relying on the events and interfaces as a method of interacting with the wider system.

The Demo Framework

Ok so we’ve spent a load of time talking about the principles and seeing some examples from the demo game. It’s time to talk a little about how IoC is being implemented here.

The first principle with IoC is to create some kind of framework into which we can put our components. This is a wide topic and you can make all kinds of decisions, often it’s best to try something and then adjust it until it works. This is normally a quick series of iterations at the start of a project followed by a process of deciding to “promote” things you’ve built to be in the framework later.

The heart of a system wide framework is usually an event system. In our demo that’s exactly what we’ve got.

Event Bus

You don’t only have to have one event source, but it often helps. In the game’s framework we’ve implemented an event bus (a global source of events) based off EventEmitter2. I like this module because it supports wildcards, multipart events, it has asynchronous events and it’s quick.

The plan is to have simple methods to raise events and easy ways to consume them.

Raising an event is declaring a state and together with the event parameters make up the interface. Handling an event registers an ability.

Our event bus has core methods to raise and handle events. To raise an event we have:

  • raise - raises an event immediately - we do this when we will use values supplied by the handlers
  • raiseLater - raises an event the next time the main thread hits an idle, we use this with notifications like “I just collected something”
  • raiseAsync - raises an asynchronous event and continues when all handlers have returned, we use this where we wish to allow handlers to take some time and perform async operations. So usually in configuration and setup.

To handle events we have:

  • handle registers a global handler for an event. This is normally used to register whole elements of the system, like Apple and Bubble.
  • useEvent is a React hook that will add and remove event handlers on component mount events. It makes sure we don’t accidentally leave things attached to the bus and is the primary way a component registers relevant responses.
  • using is a generator function that passes an “on” function for handling events to an inner generator coroutine. This ensures that all event handlers are removed when the coroutine exits for any reason.

Raising an event is a bit like calling a method, but you may expect many responses or no responses at all, so handling return results is slightly different. We tend to return values through the parameters to the event too:

const [elements] = React.useState(() => {
        const [elements] = raise("initialize", { game: [], top: [] })
        elements.game.sort(inPriorityOrder)
        elements.top.sort(inPriorityOrder)
        return elements
    })
Enter fullscreen mode Exit fullscreen mode

raise(event, ...params) -> params

We raise an event and return the parameter array allowing us to combine variable initialisation with the actual call.

// Instead of writing this
const elements = {game: [], top: []}
raise("initialize", elements)

// It is replaced by

const [elements] = raise("initialize", { game: [], top: [] })
Enter fullscreen mode Exit fullscreen mode

Here we are declaring that the system is initialising and we are offering the opportunity for any interested party to add SVG elements to two layers. The in game layer below the river bank (but above the water, we use this for apples, bottles etc) and a layer on top of everything (which we use for bubbles, collected apples, scores etc)

Because we have many elements we often perform sorts on the results. But event handlers have priority too, which dictates their order.

handle("initialize", addMyThing, -2)

Plugs and Sockets

In this React implementation of a framework we are also going to want to write dynamic components that allow the whole user interface to operate on Inversion of Control principles. These also use the event bus, but provide super helpful functions and components to mean that our UI is fully inverted too.

Here’s the code for part of the mission introduction screen. In the middle of the Grid you can see we are using a Socket with a type of “mission-item”. All of the other properties are passed to a plug() which will fill this socket. In fact more than one plug can be used and either the plugs or the socket can choose whether to render only one, or render all of them. Socket will also render its children as one of the dynamic components so you can just write a normal wrapper and still have a hook point to insert extra functionality and interface later, or to remove the default implementation.

<CardContent>
       {!!levelSpec.instructions && levelSpec.instructions}
       <Grid container spacing={2} justify="center">
            {levelSpec.mission.map((item, index) => (
                 <Grid item key={index}>
                       <Socket
                          index={index}
                          type="mission-item"
                          step={item}
                       />
                 </Grid>
            ))}
        </Grid>
</CardContent>
Enter fullscreen mode Exit fullscreen mode

We then fill a mission-item Socket with a plug like this:
plug("mission-item", ({ step }) => step && step.red, RedItem)

function RedItem({ step, index }) {
    return (
        <Card elevation={4}>
            <CardHeader subheader={` `} />
            <CardMedia
                style={{ paddingTop: 60, backgroundSize: "contain" }}
                image={apple1}
            />
            <CardContent>
                {step.red} red apple{step.red !== 1 ? "s" : ""}
            </CardContent>
        </Card>
    )
}
Enter fullscreen mode Exit fullscreen mode

plug takes a “type” and an optional predicate, followed by the component to render and an optional priority. The minimum requirement is a type and a component.

plug("mission-item", ImAlwaysThere)
Enter fullscreen mode Exit fullscreen mode

Using plugs and sockets, modules written or loaded later can populate the interface, override existing behaviour or augment it as per our IoC principles.

A Socket takes a type and an optional filter which is passed the array of items to be displayed. It can do what it likes with this for instance take the first element for only the highest priority item, or everything that isn’t a default etc.

<Socket type={"anything"} filter={arrayFilter}/>
Enter fullscreen mode Exit fullscreen mode

The plug(type, predicate, Component, priority) function as mentioned above takes a type and a component at minimum, it can also have a props based predicate and a priority.

Framework

The core framework of our game is pretty small. We create an HTML based wrapper around an SVG graphic. The framework also handles tracking the player’s finger or mouse.

In this first example the framework also includes the river and river bank - this is one of those framework choices, we could easily have made these inverted, but I’ve left this as an exercise for a later part.

export default function App() {
    const [uiElements] = raise("ui", [])
    return (

        <div className="App">
            <GameSurface>{uiElements}</GameSurface>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Our app is therefore super simple. We render the game surface having first asked for some UI elements to put on top of it.

The game surface itself handles screen resizing and all player interactions. It knows nothing about anything else, but offers up the ability for modules to include their components and UI.

export function GameSurface({ children }) {
    const [windowWidth, setWidth] = React.useState(window.innerWidth)
    const playing = React.useRef(false)
    const ref = React.useRef()
    const [elements] = React.useState(() => {
        const [elements] = raise("initialize", { game: [], top: [] })
        elements.game.sort(inPriorityOrder)
        elements.top.sort(inPriorityOrder)
        return elements
    })
    React.useEffect(() => {
        window.addEventListener("resize", updateWidth)
        return () => {
            window.removeEventListener("resize", updateWidth)
        }
        function updateWidth() {
            setWidth(window.innerWidth)
        }
    }, [])
    useEvent("startLevel", () => (playing.current = true))
    useEvent("endLevel", () => (playing.current = false))

    let ratio = Math.max(1, 1000 / windowWidth)
    let height = Math.min(window.innerHeight, 700 / ratio)
    let width = (height / 700) * 1000
    let offset = (windowWidth - width) / 2
    let x = 0
    let y = 0
    let lastTime = Date.now()
    React.useEffect(() => {
        return update(standardPlayer(getPosition, playing.current)).terminate
    })
    return (
        <Box
            ref={ref}
            onTouchStart={startTouch}
            onTouchMove={captureTouch}
            onMouseMove={captureMouse}
            position="relative"
            width={width}
            style={{ marginLeft: offset }}
        >
            <svg
                viewBox="0 0 1000 700"
                width={width}
                style={{ background: "lightblue", position: "relative" }}
            >
                <RiverBank>{elements.game}</RiverBank>
                {elements.top}
            </svg>
            <Box
                position="absolute"
                style={{ zoom: 1 / ratio }}
                left={0}
                top={0}
                right={0}
                bottom={0}
            >
                {children}
            </Box>
        </Box>
    )

    function captureTouch(event) {
        event.stopPropagation()
        event.preventDefault()
        lastTime = Date.now()
        const rect = ref.current.getBoundingClientRect()
        const p = width / 1000
        x = (event.targetTouches[0].clientX - rect.left) / p
        y = (event.targetTouches[0].clientY - rect.top) / p
    }

    function startTouch() {
        lastTime = 0
    }

    function captureMouse(event) {
        lastTime = Date.now()
        const p = width / 1000
        const rect = ref.current.getBoundingClientRect()

        x = (event.clientX - rect.left) / p
        y = (event.clientY - rect.top) / p
    }

    function getPosition() {
        return { x, y, time: Date.now() - lastTime }
    }
}
Enter fullscreen mode Exit fullscreen mode

Again we use a coroutine to handle the player, in this case calculating how far the finger or mouse have moved every frame and announcing this on the event bus.

function* standardPlayer(getPosition, playing) {
    yield* using(function* (on) {
        on("startLevel", () => (playing = true))
        on("endLevel", () => (playing = false))
        let lx = undefined
        let ly = undefined
        while (true) {
            yield
            if (!playing) continue
            const { x, y, time } = getPosition()
            if (time > 500) {
                lx = undefined
                ly = undefined
            }
            lx = lx || x
            ly = ly || y
            let dx = x - lx
            let dy = y - ly
            let distance = Math.sqrt(dx ** 2 + dy ** 2)
            lx = x
            ly = y
            raise("player", { x, y, dx, dy, distance })
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

This article has sought to introduce the principles of Inversion of Control and how they can be simply implemented using an event bus with reference to a simple Javascript/React game. Hopefully from this you can see that this simple technique brings significant benefits in terms of extensibility and single responsibility. The subsequent parts will look at how we consider the refactoring of a framework, how we can extend an IoC application using code splitting and dynamic loading and later, how we can use behaviours to create a varied and dynamic solution to a wide class of problems.

Exercise

Fork the example game and add an achievements system that will show a message to the player under the following circumstances:

  • They pop their first 10 bubbles
  • They pop their first 100 bubbles
  • They pop their first 500 bubbles
  • They pop their first 1000 bubbles
  • They collect their first red apple
  • They collect their first green apple
  • They finish their first level
  • They collect 50 apples of either colour
  • They collect 100 apples of either colour

You should add a source file and import it from App.js.

In this file you will use handle to register your components with the ui handle("ui", items=>items.push(<YourComponent key="myComponent"/>))

Your component will then use useEvent() to handle the various events and make your component visible for a few seconds with the achievement and some fun text.

The interesting events are popped, collect (which takes an apple parameter with a color() function) and endLevel

💖 💪 🙅 🚩
miketalbot
Mike Talbot ⭐

Posted on June 19, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related