|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +author: Tony Schneider |
| 4 | +title : The Command Pattern |
| 5 | +date : 2020-06-24 |
| 6 | +tags : software |
| 7 | +--- |
| 8 | + |
| 9 | +If you google the word “command”, you’ll eventually find a definition that’s something to the effect of: |
| 10 | + |
| 11 | +> A command is a **directive** to a computer **program** to perform a specific **task**. |
| 12 | +
|
| 13 | +In other words, a command is a way to encapsulate behavior. |
| 14 | + |
| 15 | +Ok, so now that we got all the super generic stuff out of the way, let’s try to get more specific than “program”, “directive” and “task”. |
| 16 | + |
| 17 | +## The Program |
| 18 | + |
| 19 | +No matter what you’re working on, you’re working in some sort of _domain_. |
| 20 | +It could be anything; a game, a specific menu inside that game, an insurance company, or maybe you’re just implementing some forms on an admin dashboard. |
| 21 | + |
| 22 | +After all, from 10,000 feet, no matter what software you’re building, it pretty much fits into the mold of a computer program accepting directives that in return perform tasks. |
| 23 | + |
| 24 | +If you think it doesn’t, I challenge you to step outside the specifics of whatever you’re working on. |
| 25 | +Forget about whatever language or framework you’re using and try to think about the problem you’re solving. |
| 26 | + |
| 27 | +Before we start, let’s take a moment to establish our domain. |
| 28 | +I’m going to choose the admin dashboard because it’s pretty broadly applicable and most software projects have at least some notion of an admin dashboard. |
| 29 | +However, **at a high level**, I think you could apply almost everything below to pretty much any domain you want. |
| 30 | + |
| 31 | +So from here on out, our “program” is an “admin dashboard”. |
| 32 | + |
| 33 | +In my experience an admin dashboard usually serves two main roles: |
| 34 | + |
| 35 | +1. Exposing an insider look at data to aid in debugging and monitoring. |
| 36 | +1. Exposing actions that only certain people are able to perform under certain conditions |
| 37 | + |
| 38 | +Let’s focus on the second for now. |
| 39 | + |
| 40 | +## The Directive |
| 41 | + |
| 42 | +One example of a directive in our fictional admin dashboard might be the ability to cancel a user’s subscription. |
| 43 | + |
| 44 | +Naming is certainly hard, but I think we have this one under control for now. |
| 45 | +Let’s call our command `CancelSubscription`. |
| 46 | + |
| 47 | +Regardless of the naming scheme you choose, try to follow these rules: |
| 48 | + |
| 49 | +1. Name the command after the behavior it implements _(this usually involves a verb)_ |
| 50 | +1. Do this in such a way that behaviors in the same domain live near one another _(this may evolve over time)_ |
| 51 | + |
| 52 | +As domains get more mature, they often become more specialized. |
| 53 | + |
| 54 | +Eventually your admin dashboard might have tens of commands related to subscriptions. |
| 55 | +If this is the case, maybe you go with something like `Subscriptions::Cancel` instead. |
| 56 | +Renaming or reorganizing shouldn't be a herculean effort. |
| 57 | + |
| 58 | +## The Task |
| 59 | + |
| 60 | +Given the admin dashboard (program) and our desire to cancel subscriptions (directive), we need to define our _specific_ task. |
| 61 | + |
| 62 | +In this case, our task is concerned with two questions: |
| 63 | + |
| 64 | +1. Are the conditions such that I am able to cancel the subscription? |
| 65 | +1. If able, how do I go about cancelling the subscription? |
| 66 | + |
| 67 | +I really like that the definition uses the words _specific_ task. |
| 68 | +In other words, if it doesn’t have to do with either of these two questions, do it somewhere else :) |
| 69 | + |
| 70 | +If we do need a piece of data in order answer either of these questions, we can pass it into our command so long as our command doesn’t know or care where it came from. |
| 71 | +This will make your command more re-usable and easier to test. |
| 72 | + |
| 73 | +For instance, our `CancelSubscription` command likely needs a subscription, a date the cancellation is to go into effect, the reason it’s being cancelled, and maybe the administrator that is performing the cancellation. |
| 74 | + |
| 75 | +### The Task: Am I Able? |
| 76 | + |
| 77 | +Before we perform the task, we need to make sure we are able to perform the task. |
| 78 | +This is where you implement your business rules. |
| 79 | + |
| 80 | +For instance, a couple usual suspects: |
| 81 | + |
| 82 | +* Only administrators with certain permissions can cancel subscriptions |
| 83 | +* The effective date must be between the subscription start date and the subscription end date |
| 84 | +* A cancellation reason must be supplied and be one of several defined reasons |
| 85 | + |
| 86 | +There are certainly other libraries out there to choose from for both Ruby and other languages. |
| 87 | +I think a lot of this comes down to personal preference and willingness to learn new APIs. |
| 88 | +As a heads up, Commands may go by different names such as: Interactors, Mutations, Operations, and others I’m sure. |
| 89 | + |
| 90 | +Whatever they do, they likely do something similar, but vary in syntax/DSL and feature set (e.g type coercion, checking, etc). |
| 91 | + |
| 92 | +When using Ruby I tend to gravitate towards `ActiveModel` (and friends) since it’s _good enough_, almost guaranteed to be present, and usually avoids any sort of holy war, letting us focus on stuff that actually matters (i.e cancelling subscriptions!). |
| 93 | + |
| 94 | +```ruby |
| 95 | +class CancelSubscription |
| 96 | + include ActiveModel::Validations |
| 97 | + |
| 98 | + attr_reader :subscription, |
| 99 | + :administrator, |
| 100 | + :effective_date, |
| 101 | + :reason |
| 102 | + |
| 103 | + validates :subscription, presence: true |
| 104 | + validates :administrator, presence: true |
| 105 | + validates :effective_date, presence: true |
| 106 | + validates :reason, presence: true, inclusion: { in: Subscription::CancellationReasons::ALL } |
| 107 | + validate :authorized_administrator |
| 108 | + |
| 109 | + def initialize(subscription:, administrator:, effective_date: nil, reason: nil) |
| 110 | + @subscription = subscription |
| 111 | + @adminstrator = administrator |
| 112 | + @effective_date = effective_date |
| 113 | + @reason = reason |
| 114 | + end |
| 115 | + |
| 116 | + private |
| 117 | + |
| 118 | + def administrator_authorized |
| 119 | + unless can_cancel_subscription?(administrator, subscription) |
| 120 | + errors.add(:administrator, "does not have permission to cancel subscriptions") |
| 121 | + end |
| 122 | + end |
| 123 | +end |
| 124 | +``` |
| 125 | + |
| 126 | +Including `ActiveModel::Validations` defines a `valid?` that returns `true` or `false`. |
| 127 | +If `valid?` returns `false`, it populates the `errors` on the `CancelSubscription` instance. |
| 128 | + |
| 129 | +In the case of our admin dashboard, we’d probably want to use these errors to re-render an invalid form or construct a JSON payload. |
| 130 | + |
| 131 | +#### Required |
| 132 | + |
| 133 | +In the example above, I used keyword arguments to indicate that `subscription` and `administrator` are required. |
| 134 | + |
| 135 | +Without these two things, we’re not even going to try to perform our task. |
| 136 | +If this happens, something else must be wrong. |
| 137 | + |
| 138 | +#### Optional |
| 139 | + |
| 140 | +Similarly, I indicated that `effective_date` and `reason` are optional by having their values default to `nil`. |
| 141 | + |
| 142 | +I have them as optional because they are likely set by the administrator’s selection in a form. |
| 143 | +In this example, I defaulted them to `nil`, but in real life, there might be a more reasonable default. |
| 144 | +Worth noting that as written, if the user doesn’t make a selection, the command will not execute due to our validations. |
| 145 | + |
| 146 | +### The Task: How do I? |
| 147 | + |
| 148 | +So, assuming we got past our validations, how does one cancel a subscription? |
| 149 | + |
| 150 | +The good part is that it **_really doesn’t matter_** so much. :rainbow: |
| 151 | +That’s the beauty of the command. |
| 152 | + |
| 153 | +```ruby |
| 154 | +def execute |
| 155 | + # Mark subscription as cancelled as of some date |
| 156 | + # Maybe create a cancellation audit record documenting whodunnit/reason |
| 157 | + # Maybe send out cancellation email? |
| 158 | + # Maybe publish event to external system? |
| 159 | +end |
| 160 | +``` |
| 161 | + |
| 162 | +Sure, ideally it’s expertly modeled code that checks all the boxes that you subscribe to. |
| 163 | +In reality, it’s probably less than ideal and that’s okay. |
| 164 | + |
| 165 | +Because we used the command pattern, folks that want to cancel a subscription don’t have to _care_ exactly how a subscription is cancelled — they just need to source the dependencies needed to perform the cancellation. |
| 166 | + |
| 167 | +## Summary |
| 168 | + |
| 169 | +### Fat Models or Fat Controllers? |
| 170 | + |
| 171 | +How about neither? :sweat_smile: |
| 172 | + |
| 173 | +For example, let’s imagine we’re exposing the `CancelSubscription` command as a form in our admin dashboard. |
| 174 | + |
| 175 | +We might have a controller that looks something like this: |
| 176 | + |
| 177 | +```ruby |
| 178 | +module AdminDashboard |
| 179 | + module Subscriptions |
| 180 | + class CancellationsController < AdminDashboardController |
| 181 | + before_action :setup_subscription |
| 182 | + |
| 183 | + def new |
| 184 | + @command = Commands::CancelSubscription.new( |
| 185 | + subscription: @subscription, |
| 186 | + administrator: current_user |
| 187 | + ) |
| 188 | + end |
| 189 | + |
| 190 | + def create |
| 191 | + @command = Commands::CancelSubscription.new( |
| 192 | + subscription: @subscription, |
| 193 | + administrator: current_user, |
| 194 | + **cancellation_form_params |
| 195 | + ) |
| 196 | + |
| 197 | + if @command.valid? |
| 198 | + @command.execute |
| 199 | + else |
| 200 | + render :new |
| 201 | + end |
| 202 | + end |
| 203 | + end |
| 204 | + end |
| 205 | +end |
| 206 | +``` |
| 207 | + |
| 208 | +We let the controller deal with authentication, sessions, parameter parsing, and orchestrating the usage of our commands. |
| 209 | + |
| 210 | +We let the database models handle things that have to do with persistence and data integrity. |
| 211 | + |
| 212 | +Our command owns the business rules. |
| 213 | +Because the command knows the calling context, we avoid the problem of bestowing behavior on all consumers of a database model. |
| 214 | + |
| 215 | +The layer between our controllers and our database models decouples us from our database representation. |
| 216 | +This frees us up to create representations that aren't 1-1 with database models (avoiding any nested attributes shenanigans). |
| 217 | + |
| 218 | +We’re better positioned to handle new requirements because we can always make a new command variant or even compose commands with one another. |
| 219 | + |
| 220 | +In addition, we’re able to write high value tests without making a single request/response (you should still write end-to-end tests, just maybe fewer than you otherwise might). |
| 221 | + |
| 222 | +### Going a Step Further: Result Objects |
| 223 | + |
| 224 | +Depending on the size and discipline within your codebase, you may want to limit the surface area exposed by your commands. |
| 225 | + |
| 226 | +Rather than expecting folks to initialize the command and call execute on it, you might consider exposing a class level method that does this for you under the covers and returns a result object. |
| 227 | + |
| 228 | +While you can certainly do this in many ways, I usually make a simple object that exposes two methods: `success?` and `payload`. |
| 229 | + |
| 230 | +Here’s a starting point that you can adapt to your own needs: |
| 231 | + |
| 232 | +```ruby |
| 233 | +# As a Caller of the command |
| 234 | +# |
| 235 | +result = CancelSubscription.run( |
| 236 | + subscription: subscription, |
| 237 | + administrator: administrator, |
| 238 | + effective_date: Time.zone.today, |
| 239 | + reason: "just cuz" |
| 240 | +) |
| 241 | +result.success? # true/false |
| 242 | +result.payload # An interface to the external world |
| 243 | + |
| 244 | +# In the command |
| 245 | +# |
| 246 | +def self.run(**kwargs) |
| 247 | + command = new(**kwargs) |
| 248 | + if command.valid? |
| 249 | + payload = command.execute |
| 250 | + Result.new(success: true, payload: payload) |
| 251 | + else |
| 252 | + Result.new(success: true, payload: command.errors) |
| 253 | + end |
| 254 | +end |
| 255 | +``` |
| 256 | + |
| 257 | +Usually when doing this it’s because I’m exposing something that might be used by another team and I want to control their access to the internals. |
| 258 | + |
| 259 | +This usually means taking extra care to ensure that both the arguments into the command and the result’s payload are POROs. |
| 260 | + |
| 261 | +—- |
| 262 | + |
| 263 | +So, in summary, just give the computer program the directives it needs to perform some tasks and you’ll be fine. |
0 commit comments