User notifications are the lifeblood of a modern application. From critical password reset links to engaging welcome emails and timely SMS alerts, a reliable notification system is non-negotiable. But building one that is robust, scalable, and easy to maintain is a significant challenge. A single bug in your SMS logic could crash your email sender, and adding a new channel like push notifications might require a full-system rewrite.
What if there was a better way? What if you could build complex services not as a single, fragile monolith, but as a collection of strong, independent, single-purpose components?
This is the power of atomic actions. By breaking down a complex business process into its smallest, indivisible units of work, you can create systems that are more resilient, flexible, and easier to understand. Today, we'll walk you through how to build a bulletproof, multi-channel notification service using the atomic action model pioneered by action.do.
Traditionally, a notification service is built as a single application. It contains all the logic: connecting to an email provider, integrating with an SMS gateway, looking up user preferences, and formatting message templates.
This monolithic approach has serious drawbacks:
This is where we need to rethink our approach, starting from the ground up.
At action.do, we see a different path forward. Instead of a monolith, we build with atomic actions.
An atomic action is the smallest, indivisible unit of work in a business process. Think of it as a single, self-contained task like 'send an email,' 'update a CRM record,' or 'verify user identity.'
Each action is like a super-powered function: it accepts structured inputs, performs one job reliably, and returns a structured output. It knows nothing about the larger workflow it's a part of, which is its greatest strength.
Let's break down our "notification service" into its fundamental, atomic parts. For a multi-channel system, we can identify a few distinct tasks.
This action's only job is to send a single email. It takes an address, subject, and body, and uses an email API to send it. It doesn't care who the user is or why the email is being sent.
Using the @do-inc/sdk, defining this action is beautifully simple:
import { Do } from '@do-inc/sdk';
const platform = new Do({ apiKey: 'YOUR_API_KEY' });
// Define an atomic action to send an email
const sendEmail = platform.action('send-email', {
description: 'Sends a single email via our provider.',
handler: async (inputs: { to: string, subject: string, body: string }) => {
// Logic to call SendGrid, Mailgun, etc.
console.log(`Sending email to ${inputs.to}`);
// const messageId = await emailProvider.send(...);
return { success: true, messageId: 'xyz-123' };
},
});
Similarly, this action is responsible for one thing: sending an SMS message. It's completely isolated from the send-email action.
// Define an atomic action to send an SMS
const sendSms = platform.action('send-sms', {
description: 'Sends a single SMS message via our provider.',
handler: async (inputs: { phoneNumber: string, message: string }) => {
// Logic to call Twilio, Vonage, etc.
console.log(`Sending SMS to ${inputs.phoneNumber}`);
// const messageSid = await smsProvider.send(...);
return { success: true, messageSid: 'abc-456' };
},
});
This action queries a database or CRM to find a user's notification settings. It's a read-only task that provides critical data for the workflow.
// Define an atomic action to get user data
const lookupUserPreferences = platform.action('lookup-user-preferences', {
description: 'Finds a user and their notification preferences.',
handler: async (inputs: { userId: string }) => {
// Logic to query your database
// const user = await db.users.find({ id: inputs.userId });
return {
email: 'alex@example.com',
name: 'Alex',
prefersChannel: 'email' // or 'sms', 'both'
};
},
});
We now have three independent, testable, and reusable building blocks.
With our atomic actions defined, we can now orchestrate them to perform a complete business process. This is where action.do moves from simple task execution to powerful workflow automation.
Let's model a "Welcome New User" workflow:
This orchestration layer connects your simple actions to create sophisticated, agentic workflows without complicating the actions themselves.
// Example of executing the workflow after a user signs up
async function welcomeNewUser(userId: string) {
const prefs = await lookupUserPreferences.run({ userId });
if (prefs.prefersChannel === 'email' || prefs.prefersChannel === 'both') {
await sendEmail.run({
to: prefs.email,
subject: `Welcome, ${prefs.name}!`,
body: 'We are so glad to have you join us.',
});
}
if (prefs.prefersChannel === 'sms' || prefs.prefersChannel === 'both') {
// ... execute sendSms.run()
}
}
By building your notification service this way, you gain incredible benefits:
Stop building fragile monoliths. Start composing powerful, resilient services from fundamental building blocks. By embracing the power of atomic actions, you can turn complex processes into simple, manageable, and bulletproof workflows.
Ready to start building with atomic actions? Explore what action.do can do for your services.