Most applications need to send emails, and there are a few different ways to set this up with Ash. Let's dive in!
We won’t be going into the details of the what service or tool you use to actually send emails. The most common setup is using Swoosh with something like Postmark or Sendgrid. Phoenix sets you up with this out of the box, or you can look at the Swoosh docs to set it up yourself.
Here, we will be focused on how to architect your resources to get the guarantees you need from sending emails. We’ll start with the simple/least-powerful method and work our way up.
We’ll base this example on a real use case from ElixirForum (my answer to which the content of this post has been adapted from). We want to send users an email when someone comments on their post. Simple, right?
Before we dive in
In all of these examples I’m using “inline changes”. This is not actually best practice, you should prefer to extract your changes to modules that use Ash.Resource.Change, but inline functions are much simpler for demonstration.
It’s very important to keep in mind that CAP theorem limits us here. Specifically, your email sending service and your app together create a distributed system. There is no such thing as “exactly once”, where something in your application can be 100% guaranteed to only send one email. I’ll probably say something along these lines in a lot of my posts…but it bears repeating.
This means you are always choosing between one of two alternatives: at most once delivery, and at least once delivery. Especially when you get retries involved, at most once technically includes “maybe never”, and at least once technically includes “maybe a million times”. A lot of tooling around background jobs involve tools to help curtail this problem to a reasonable set of constraints.
I will detail how these guarantees apply to each of our strategies below.
After Action Hooks (at least once)
After action hooks happen in the transaction with the action. What this means is that you could send an email, and then an error could occur, and you’ll have sent an email for a comment that was not actually created. In some cases you may actually want that, but in this case it makes this strategy less-than-ideal.
Another ramification of this strategy is that errors sending emails could potentially prevent a comment’s creation. This is naturally not something we want interrupting our user’s experience.
The above implementation was simple, and could be good if you wanted to send an email perhaps notifying some owner of a resource every time someone even attempts to perform an action. Not in this use case though.
After Transaction Hooks (at most once)
After transaction hooks happen(you guessed it) after the transaction has been committed. It is also the only hook in Ash that is invoked both on success and failure. In the example below, we get better behavior in the case of errors than in the above example.
If something goes wrong, the user may not get an email. Additionally, if something goes wrong sending an email, the user might experience an errored request, but their comment will still have successfully been created. Better UX, but not perfect.
The above implementation was simple, just like the first one, and has some better properties.
It still has some undesirable properties. If something goes wrong sending an email, the caller could get an error, even though the transaction was submitted. You could handle this with defensive coding.
Even worse, however, is that this doesn’t “compose” with other actions calling it. For example, say you had something like an add_many_comments
action, and that action called this action multiple times in a transaction. When calling that action, you’d get a warning printed that Ash is running an after_transaction
hook in a transaction. This happens because the transaction was already started when this action was called.
Notifiers (at most once)
Ash has a system designed to solve exactly the problem described above. We call these “notifiers”. Specifically, their job is to do some post-transaction work, to notify other parts of your system (or users) of what has transpired. The most common notifier is Ash.Notifier.PubSub. However, writing your own is as simple as defining a notify/3
function!
Notifications are all automatically accumulated by Ash, and send after the final transaction has been committed. This is very useful for live systems! It prevents pub sub messages going out for data that has not yet been created or modified, and it gives you action side-effects that are at least once.
Now, our action is composable! Creating many comments in a single transactional action will send notifications after that transaction has closed. However, you still have to program defensively around errors, as errors in notifiers will still incur errors happening in your app.
For this reason, notifiers typically should just dispatch a message to something else running in your app to handle the work they are meant to do.
All of the above approaches have some flaws
The above approaches are relatively simple, but what about retries? The things that happen in an action are awaited by the user. Do you really want email sending code mixed in with your app code? Do you want users waiting on sending an email for your app to respond? Almost certainly not.
There is a better way, and that is with our wonderful friends Oban! Oban’s free features are more than enough to power this functionality, but their pro plan offers some awesome features. I highly advise checking it out, as it’s a staple tool in the Elixir ecosystem (this is not a sponsored statement).
With Oban (at least once/at most once)
Oban provides all the guarantees we want, and also allows our action to transactionally insert the “request” for an email to be sent. Anything going wrong with the email will not affect our response to the user, and if an error happens after inserting the job, the transaction is rolled back and no email will be sent.
With the above strategy, you’d implement and configure an Oban Worker by hand, and in that Oban worker you’d send your email. The way that you opt into at most once or at least once in this case is entirely dependent on your Oban configuration.
Using AshOban (at least once/at most once)
With this strategy, we leverage data to our advantage. Instead of making an Oban queue the authoritative representation of “emails that will be sent”, we use an Ash.Resource
, configured with an `ash_oban` trigger.
With this setup, in my :create action I would create a NewCommentEmail
instead of directly inserting the Oban job. The above case has “at most once” guarantees because it changes the state to sent, and then sends the email in an after_transaction
hook. This means that the scheduler won’t ever reschedule this particular email, even if something goes wrong while sending.
If I want at least once guarantees, I can change that to a before_transaction
hook. Easy-peasy.
There are other awesome benefits to this data-driven strategy of creating a NewCommentEmail
row in a database table. Want to know how many emails have been sent ever, and how many are waiting to be sent?
Want to cancel a specific email from being sent? You can add a :canceled
state and an action to set that new state. Even if the worker is in the queue to run, it always checks if the record still meets its schedule criteria before calling its update action!
Want to resend a specific email? You can add a `:resend` state, and update your trigger to account for that new state
Wrapping up
As you can see, the Oban-backed strategy is very powerful. However, the other strategies shown can be great starting points, or perhaps they cover the use case that you have well enough on their own.
These strategies apply to any kind of side effect. We’re just using emails here because that is such a common requirement. You should now be armed with the tools to navigate the various options in Ash for adding side effects to your actions.
What do you think?
This is the first hard-core detail-oriented writing I’ve done here on this Substack. I’ll be adapting this into a how-to guide in Ash’s documentation in the future. Do you like this kind of thing? Or do you prefer content on more high level concepts? Let me know in the comments!
More like this, please. The documentation has a lot of high level concepts and not enough on how to accomplish common tasks.
That's pretty cool, I was not aware of AshOban, seems like a win-win for me!
Here is a suggestion for future articles:
What about creating a series of articles going through the process of creating ash extensions, libraries, etc? We do have some simple examples in the documentation already, but I think having something more "in-depth" would help others to consider writing more libraries specifically for Ash.