I find that both debugging production issues as well as designing to avoid them requires being able to think about code as it behaves over time, including cyclically.
It's often necessary to think of the present as what you want to achieve, like charging a customer's credit card. You must then travel to the past and consider the conditions required to do that, especially if you are coming from the future, repeating the operation. Then, in the future, figure out what message to send to the past to make it work.
This is a common need anytime there is a background job in play, but it also can happen when users are impatient and keep clicking. You simply can't control that an operation is performed more than once.
But, with a little fourth-dimensional thinking, you can make sure the effects are only felt once.
Charging customers money is always a great example, because when things go wrong, it irritates the customer who gets charged multiple times.
Starting with what we directly want to do, our code might look like so:
def charge(customer,order)
PaymentProcessor.new.charge(
customer.payment_id,
order.total_cents)
end
If you've never had the pleasure of writing and managing payments code, what we have above isn't ready for production. charge
can—and will—be called multiple times with the same arguments. Our job is to make sure it only charges the customer once.
Let's travel to the past.
If our Order
has zero or more related Charge
objects, we could check order.charges
for any successful charges and, if we found one, exit early and avoid a potential double-charge.
We'll need to travel to the future to set this up, but for now, we can check before charging and assuming it'll be there if time repeats itself:
def charge(customer,order)
→ if (order.charges.successful.none?)
PaymentProcessor.new.charge(
customer.payment_id,
order.total_cents)
→ end
end
Now, we need to set all this up after it happens so when time is reset, it's in place.
We'll assume the PaymentProcessor
returns true if the charge succeeded and false if it was declined. We'll store the result as a Charge
.
def charge(customer,order)
if (order.charges.successful.none?)
success = PaymentProcessor.new.charge(
customer.payment_id,
order.total_cents)
→ order.charges.create!(success: success)
end
end
And now, the cycle is complete!
But, like all time-travel stories, it's never quite this simple.
We've fallen victim to a common trope in time-cycle stories which is failing to understand why we are in a cyclical loop of retrying in the first place. In this example, the most common cause—at least in my experience—is a networking error.
If the payment processor did actually charge the customer, but we lost our network before we created the Charge
, when time repeats, we'll see no successful charge and try again.
We need to leave ourselves more clues from the future.
Our payment processor supports metadata and has a search function. They would know if we charged a customer for an order, if we craft the right metadata. Let's assume we have and check with the payment processor before proceeding:
def charge(customer,order)
→ payment_processor = PaymentProcessor.new
if (order.charges.successful.none?)
processor_charge = payment_processor.search(
customer.payment_id,
success: true,
→ metadata: { order_id: order.id }
)
success = nil
→ if !processor_charge
success = payment_processor.charge(
customer.payment_id,
order.total_cents)
→ else
→ success = processor_charge.success
→ end
order.charges.create!(success: success)
end
end
Whew! This is getting complicated. But time travel always is. We now have to, yet again, go to the future and leave ourselves information.
We're searching for metadata, so now we just have to send it when we create the charge with the payment processor:
def charge(customer,order)
payment_processor = PaymentProcessor.new
if (order.charges.successful.none?)
processor_charge = payment_processor.search(
customer.payment_id,
success: true,
metadata: { order_id: order.id }
)
success = nil
if !processor_charge
success = payment_processor.charge(
customer.payment_id,
order.total_cents,
→ metadata: {
→ order_id: order.id,
→ }
)
else
success = processor_charge.success
end
order.charge.create!(success: success)
end
end
Our single line action has now turned into a web of breadcrumbs, backwards logic, and out-of-order behavior, but it should cover our bases. Right? I'll let you figure that out.
This can be hard to wrap your head around, but the process I went through is one I use often. You just have to be OK with writing code whose order is cyclical and that may not execute exactly the way you think.
A diagram can be helpful, especially when you find you need to explain this behavior to others. You can draw one out, or use a spreadsheet:
JavaScript has a well-earned reputation for being, well, kinda janky. But over the years, it's been slowly updated and changed and now, well, it's kinda nice, sometimes!
JavaScript's compact syntax for passing an object as parameters to a function can be used as a sort of dependency injection.
For reasons I will explain in a future post, I created a very basic test framework for working on Web Components. Each test has a setup function that returns an object of any elements that were setup:
setup = ({subject}) => {
// subject is an HTMLElement representing the
// Web Component being tested
const $input = subject.querySelector("input[type=color]")
const $label = subject.querySelector("label")
return { $input, $label }
}
Pretty straightforward - this locates an input and a label and returns them. The test
method is then passed these parameters, like so:
const setupResults = setup({subject})
runTest(setupResults)
runTest
will call a function declared with test
, like so:
test("Check that the label and input work",
({$input,$label}) => {
// do stuff with $input or $label
}
)
So far, so good, but if a test doesn't need $label
, it doesn't have to declare it:
test("Check that the input has the right attribute",
({$input}) => {
// do stuff with $input
}
)
This won't create an error - the invocation of our function will simply ignore the extra parameters.
This means that we can build up a substantial object of available params, but each function can opt-in to only what it needs. This means functions can be more cohesive and easier to follow since they only declare the parameters they actually need.
Nice!
Unless otherwise noted, my emails were written entirely by me without any assistance from a generative AI.