Keep Effects at the Edges

Posted on April 15, 2024

A practice I like to stick with when designing software is to keep effects at the edges. What I mean, in this article, by effects are all of those actions we ask a program to take that change the state of something outside of our program’s process space: allocating data on the heap, reading from foreign memory, reading and writing from files, making system calls, etc. I keep them separate from my core logic so that I can write clearer code that is easier to maintain and test.

Why Effects Should Be Contained

Without effects our programs would not do anything useful with the hardware and we wouldn’t observe our computers doing anything. No graphics. No sound. No networking. No way to get the results!

We need effects.

The problem with some effects is that they produce data, as far as our program is concerned, out of nowhere. For example, reading the current system time. Most programming languages have a function that reads the system’s clock. Every time we call that function, with no argument, we get a different result. When you read data off of a socket, the argument is the number of bytes to read but the result has no relationship to the argument. You’ll probably get a different answer every time. The problems arise when we mix these functions with our core business logic.

Consider:

// Note this is example code, don't write C this way...

#include <stdio.h>

void compare(int x) {
    printf("Enter a number to check: ");
    int y = 0;
    fscanf(stdin, "%d", &y);

    if (x > y) {
        printf("X is greater\n");
    } else if (y > x) {
        printf("Y is greater\n");
    } else {
        printf("X and Y are equal\n");
    }
}

You can probably recall some non-trivial code that mixes up effects like this with the core logic of the program. How do you test code like this?

When we mix up our core logic with effects like this is that it forces us to mock or simulate those effects some how in order to test our program.

It also makes our code harder to read and maintain. When trying to understand the core domain of the problem, what our code is trying to solve, we must also read about what it’s doing and how it’s doing it: reading from files, writing to the database… these effects are not part of the problem domain itself, they’re components in a software system.

Extracting the Core Logic

The core logic here is comparing two integer values using inequality. That can be written as:

enum Comparison {
    GT,
    LT,
    EQ
};

enum Comparison compare(int x, int y) {
    if (x > y) {
        return GT;
    } else if (y > x) {
        return LT;
    } else {
        return EQ;
    }
}

This is nice. Simple. We can test this. All of it’s inputs are given as parameters. It has an output value. The body of the function uses all of the parameters. There are no effects in there.

We know that if we call compare(2, 3) we will get LT. Every time.

I argue that our core domain logic, the essential part of our program, should be written as functions and data structures like this as much as possible. The core should describe the problem domain clearly, succinctly and without mentioning how the data is stored or what web APIs we need to fetch data from. On some level programming is taking data from a source, transforming it, and putting that data somewhere else. Extracting the transform part into functions like this makes our job much easier.

Moving Effects to the Edges

Now that we’ve extracted the core domain logic of our program, here’s how it would look with effects added back in:

#include <stdio.h>

enum Comparison {
    GT,
    LT,
    EQ
};

enum Comparison compare(int x, int y) {
    if (x > y) {
        return GT;
    } else if (y > x) {
        return LT;
    } else {
        return EQ;
    }
}

int main() {
    // We do some I/O here...
    printf("Enter a number for X: ");
    int x = 0, y = 0;
    fscanf(stdin, "%d", &x);
    printf("Enter a number for Y: ");
    fscanf(stdin, "%d", &y);

    // Then we feed our data into our core domain logic...
    enum Comparison result = compare(x, y);

    // And we do some more I/O here...
    switch (result) {
    case GT:
        printf("X is greater\n");
        break;
    case LT:
        printf("Y is greater\n");
        break;
    case EQ:
        printf("X and Y are equal\n");
        break;
    }

    return 0;
}

Notice how we have the effects at the edges of the main function here. Right at the beginning we call a bunch of library functions that make system calls to get data from the user. Then we pass the data we received into our domain logic. We then interpret the results of that logic and call some more functions that output data to the console.

This is the pattern to strive for. Extract your core logic. Sandwich it between those effects. This keeps the surface area for effects small and manageable.

Strategies for Extracting Core Logic

There are many tricks we can use to extract the core business domain logic from a piece of code. I can’t enumerate all of them. Some may have yet to be discovered. Here I offer a handful:

Use Meaningful Result Values

The implementation of compare returns a enum Comparison value. It doesn’t use int. That’s a purposeful choice: using meaningful values of types you define in your core domain logic makes your code easier to understand by nearly becoming self documenting. They also allow the effects at the other end to interpret the results and do the right thing.

When you see code that is interleaving effects into the results of a decision or check, it’s likely you can use this strategy. Replace the calls to the effectful functions with a sensible result value. Interpret the result value to re-introduce the effects to your program.

Choose An Allocation Strategy

Don’t let your core domain logic take responsibility for managing the heap. Instead allocate the memory your program will use ahead of time. Pass around the interface for allocating and deallocating domain objects on this managed area of memory. This way your core logic is still deterministic and easy to test.

Layer Cake

Occasionally, you can’t structure your code to avoid having to call an effect in the middle of your program before your domain logic can continue processing. This is rare but it’s okay.

Keep the core logic away from effects. Just add the effects you need in the middle. Then call more core logic code. The key thing is that core business logic code shouldn’t be running the effects. It’s okay if you have a bit of a layer cake.

When to Avoid Managing Effects

If you’re writing a small script, especially if it’s a throw-away, it might not be worth the effort compared to the convenience of avoiding having to think about the structure of your script.

When it’s not necessary; perhaps because it’s a hobby project. Don’t go overboard with this. But do take up an opportunity to learn and try it out in your own code!

Conclusion

Strive to keep your core business logic free from the concerns of where data is stored or how to get it from the network. These are important but should be kept to the edges of the program as much as possible.

This will help you test your core business logic while keeping the surface area for the hard-to-test code small and contained.

Happy hacking!