Rock Solid Business Logic: The Action Pattern

The Action Pattern is a rock solid solution for organizing business logic in an application. It works quite well for medium to large-ish projects. I like the Action Pattern because it introduces few concepts, unlike some more elaborate and complex designs. It’s flexible, scalable and helps me feel more confident in handling complex business logic in my work as a developer. Even though generative AI now plays a bigger role in programming, it does not replace actual software design, and the Action Pattern can be a great tool to have in your toolbox!

Introduction

Over the years as a developer, I’ve seen many different ways to organize business logic in an application. From the complex Hexagonal Architecture to Clean Architecture to Railway Oriented Programming, to the more standard “models folder” that back-end frameworks such as Rails, Laravel and Django use. No solution is perfect, and there is a potential use for each of these.

Or, sometimes, no organization at all! For example, if you are simply writing a 30-line script for your terminal, you probably don’t need to worry (right now) about code management, technical debt, etc., let alone well-organized business logic.

But as business logic grows in a solution, so too does the need for patterns to organize business logic.

What about AI & ML?

Of course, the subjects of Artificial Intelligence and Machine Learning in software development are on every programmer’s mind right now. I won’t cover those topics, here and now, except to say: As generative AI continues to play a deeper role in programming, the Action Pattern can serve a purpose.

“Generative business logic”, so to speak, still needs organization. And the Action Pattern is designed to not interact directly with how business logic is generated, so it could fit into the-future-of-how-we-develop-software, whether code is created by human intelligence, artificial intelligence or co-piloted approaches.

There’s a time and place for everything but not now! — Professor Oak

The Action Pattern, aka Interactor

I like to call this pattern the “Action Pattern”, although I think the original name is interactor, as described in Robert C. Martin’s Clean Architecture book.

The idea of the Action Pattern is simple: Wrap all operations (or actions) on our business logic into objects. Then we can do object-oriented things to them, such as testing them in isolation, composing them, chaining them, decorating them, etc.

This is a very object oriented pattern and one of its main advantages is that it leads to SOLID object oriented code.

There are libraries that implement this pattern to some extent, such as the interactor gem for Rails and my personal implementation ts-command for TypeScript.

But what I want to show in this post is that you don’t need a library. After all, a pattern is mainly a way to organize code to solve a certain family of problems.

Implementing the Action Pattern

A picture is worth a thousand words — Fred R. Barnard

Because actions can either succeed or fail, we’ll start by defining the Result type:

type Success<T> = {
  success: true;
  context: T;
};

type Failure<T> = {
  success: false;
  context: T;
};

type Result<S, F = S> = Success<S> | Failure<F>;

A Result represents the outcome of executing an action, which will be either success or failure. Results can also have associated context, if needed.

Because I used TypeScript for the example, we need to specify the types of what our results will hold for both cases: success and failure. We can do that as such:

Result<number, string>

Meaning if the Result is successful, then the context will be of type number. Otherwise, it’s going to be of type string.

Also, if the type for success and failure is the same, you can just omit the second parameter:

Result<number>

Now that we have our Result type, a simple action would be like this:

class SumNumbersAction {
  execute(a: number, b: number): Result<number> {
    return {success: true, context: a + b};
  }
}

const action = new SumNumbersAction()
const result = action.execute(2, 2)
console.log(result.success) // true
console.log(result.context) // 4

An action is simply an object that responds to execute, optionally taking in some parameters, and returns a Result.

We can then inspect the result to know if our action succeeded or failed, and get the context accordingly.

const result = new SumNumbersAction().execute(2, 2)
if (result.success) {
  console.log(result.context) // 4
} else {
  console.log('Oops, something went wrong')
}

And that’s the whole pattern in a nutshell! You see, it’s quite simple.

The real advantage of this pattern comes when you start creating actions that use other actions, and perform complex business logic.

Example: Building a post on a blog

Divide and conquer — Julius Caesar

Let’s say we have a blog, and we need to create a new post. Our imaginary post creation process will consist of 3 steps:

  1. Validation: Make sure the title and the body are not empty
  2. Persistence: Insert the blog into a database or some sort of permanent storage
  3. Notification: Notify subscribed users that a new blog post has been created

Dividing a big problem into smaller ones is one of the fundamentals tools we use as developers in our day to day. We can apply the same principle with the action pattern by creating one action per step:

type Post = {
  title: string;
  body: string;
};

class ValidatePost {
  execute(post: Post): Result<string[]> {
    const errors: string[] = [];

    if (post.title.length === 0) {
      errors.push('Title must not be empty');
    }

    if (post.body.length === 0) {
      errors.push('Body must not be empty');
    }

    return {success: errors.length === 0, context: errors};
  }
}

We define a very simple Post type and then an action. The action simply ensures the title and body are present.

Next step, creating the post:

class CreatePost {
  constructor(private readonly posts: Repository<Post>) {
  }

  execute(post: Post): Result<Post, string> {
    if (this.posts.add(post)) {
      return {success: true, context: post};
    }

    return {success: false, context: 'Could not add post to repository'};
  }
}

Note that we inject a post's object into our action’s constructor. This object is a Repository of Posts.

You can think of this object as your database. It’s very common to have a “data layer” in big applications, where all database-related operations are performed.

In our example above, the repository provides an add method we can use to insert new posts into it. Notice that we don’t care about the actual implementation of the repository. It could be persisting your blog post into a database, a file in your computer, an HTTP API or in memory inside an array; we don’t care. That’s an implementation detail.

Now onto our last action, the notification step:

type Notification = {
  body: string;
};

class CreateNotification {
  constructor(private readonly notifications: Repository<Notification>) {
  }

  execute(notification: Notification): Result<Notification, string> {
    if (this.notifications.add(notification)) {
      return {success: true, context: notification};
    }

    return {success: false, context: 'Could not add notification to repository'};
  }
}

Creating a notification is very similar to the way we create a post. Just using a Repository.

Now onto the last step, create an action that wraps all that business logic:

class PublishPost {
  constructor(private readonly posts: Repository<Post>, private readonly notifications: Repository<Notification>) {
  }

  execute(title: string, body: string): Result<Post, string[]> {
    const post: Post = {title, body};

    // Step 1: Validation
    const validated = new ValidatePost().execute(post);
    if (!validated.success) {
      return validated;
    }

    // Step 2: Persistence
    const created = new CreatePost(this.posts).execute(post);
    if (!created.success) {
      return {success: false, context: [created.context]};
    }

    // Step 3: Notification
    const notified = new CreateNotification(this.notifications).execute(post);
    if (!notified.success) {
      return {success: false, context: [notified.context]};
    }

    return {success: true, context: post};
  }
}

Now we have a PublishPost action that wraps the business logic for creating a new post. This is very useful for long, complicated chains of business logic.

It also allows us to share actions, so we can create new behavior by combining several smaller actions, rather than modifying one massive action. So our code is open for extension, closed for modification.

Using the PublishPost action anywhere in our code is rather straight forward. For example, if we were in an Express route, we could do something like this:

import express from 'express';
import { posts, notifications } from 'my-repositories';

const router = express.Router();

// Handle POST request to /posts
router.post('/posts', (req, res) => {
  const { title, body } = req.body;
  const action = new PublishPost(posts, notifications);
  const { success, context } = action.execute(title, post);

  if (success) {
    res.json({ success: true });
  } else {
    res.json({ success: false, errors: context });
  }
});

Notice that, from the outside world, consumers only use a single action, and they don’t care about how actions do their thing. To the outside world, they are a black box.

Actions could be composed, simple or complex, use the database or just do some logic. All consumers care about is that actions have an execute method, and they return a Result accordingly.

Conclusion

A place for everything, everything in its place — Benjamin Franklin

A very common issue inexperienced developers and teams have is that they don’t quite know where to put things. Sure, a framework might give you a few “buckets” to put things into, such as Rails’ or Laravel’s MVC (Model-View-Controller) or Django’s MTV (Model-Template-View). But as applications grow, you start to outgrow these folders. In the case of Rails, there’s the issue of “fat” controllers and “fat” models. Those files can get very large, and the framework doesn’t really give you more places to put code.

That is where software design can help. You now have a new “bucket”: The Actions bucket!

Advantages

  • Easy to reason about, as each action is small and self-contained
  • Easy to test in isolation (unit testing)
  • Scales quite well as the application grows
  • Follows SOLID principles
  • Business logic is not tied to a particular library or framework. You can change them if needed.
  • Business logic could be shared across multiple applications

Disadvantages

  • More code than just doing the business logic directly, so overkill for small applications
  • Requires some understanding of OOP principles, as it’s possible to end up with technical debt
  • As with any object oriented design, debugging lots of small objects can be tedious. One object leads to another which leads to another…

…And this leads us toward a future blog post where we’ll take a look at some more fancy abstractions. We can build on top of this to make our lives easier, such as creating a way to dynamically (at runtime) compose bigger actions from smaller ones using an action runner.

That’s all for this part! Hopefully you find this useful enough to try out the Action Pattern and adapt it to your needs.

One thought on “Rock Solid Business Logic: The Action Pattern

Leave a Reply