The Intro
Here goes another one of those "it finally clicked" posts. This time it's about something you've probably heard a hundred times in code reviews, job interviews and conference talks: loosely coupled modules.
Everyone says it's good. Almost nobody shows you a case where you actually feel the difference.
So today we are going to build a tiny app that sends an email, break it, and then fix it in a way that will make you say "oh... that's why".
The What
First things first. If we ask Wikipedia, this is what we get:
In computing and systems design, a loosely coupled system is one in which components are weakly associated (have breakable relationships) with each other, and thus changes in one component least affect existence or performance of another component.
Crystal clear, right? Great, post over, go enjoy your day...
Nah, I'm messing with you. Definitions never clicked for me either.
Let's make it real instead. Picture this: we are building a web app with a "Sign up" form. When someone signs up, we want to send them a welcome email.
Simple, right?
To send real emails we are going to use Resend, which has a free tier and a very small API. And since we are using TypeScript and Node, the code is going to be as short as it gets.
The How
Here's the first (and most honest) version of our code:
// src/welcome.ts
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY)
export async function sendWelcomeEmail(to: string) {
await resend.emails.send({
from: 'onboarding@resend.dev',
to,
subject: 'Welcome!',
text: 'Thanks for signing up 🤝',
})
}
And our main:
// src/main.ts
import { sendWelcomeEmail } from './welcome.js'
await sendWelcomeEmail('chris@example.com')
console.log('Sent!')
Run it with a valid RESEND_API_KEY and the email shows up in our inbox a second later.
Ship it! 🚀
Now, let's say you keep working on the app. You run main.ts 20 more times while testing the sign up flow.
What happens?
- Every run sends a real email.
- Your coworkers get spammed when they test with their own address.
- Resend's free tier starts counting those emails.
- And if someone forgets to set
RESEND_API_KEY... boom, the app crashes.
Not great, huh? 😅
What we really want is: send real emails only in production. Everywhere else, just let me see the email without it going anywhere.
Turns out there's a tool made exactly for that: Mailpit. It's a fake SMTP server with a little web UI where you can read every email your app "sent". We can run it with Docker in one line:
# docker-compose.yml
services:
mailpit:
image: axllent/mailpit
ports:
- '1025:1025' # SMTP (your app talks to this one)
- '8025:8025' # Web UI (you look at this one)
Run docker compose up -d and open http://localhost:8025. Empty inbox, for now.
Since Mailpit speaks SMTP, we can talk to it with Nodemailer. Let's add that to our function:
// src/welcome.ts
import { Resend } from 'resend'
import nodemailer from 'nodemailer'
const resend = new Resend(process.env.RESEND_API_KEY)
const smtp = nodemailer.createTransport({ host: 'localhost', port: 1025 })
export async function sendWelcomeEmail(to: string) {
const message = {
from: 'onboarding@resend.dev',
to,
subject: 'Welcome!',
text: 'Thanks for signing up 🤝',
}
if (process.env.NODE_ENV === 'production') {
await resend.emails.send(message)
} else {
await smtp.sendMail(message)
}
}
Run main.ts again (without NODE_ENV), refresh Mailpit and... there it is!
Problem solved! 🎉
Now, be honest with me. Did something feel a little bit off while reading that code?
Let me help you out:
-
sendWelcomeEmail()is supposed to be about welcoming people. But now it also knows about Resend, Nodemailer, SMTP ports andNODE_ENV. - The next function (
sendPasswordResetEmail()) will need the exact sameif/else. And the next one. And the next one. - Want to try a third provider some day? Have fun finding every
ifin the project. - And the
Resendclient gets created at import time even when we will never use it. That's where the "forgot the API key" crash comes from.
This, right here, is what tightly coupled code looks like. Our welcome email is glued to two vendors and one environment variable. You cannot touch one without touching the others.
Let's fix it.
Step 1: describe what we need, not who does it
What does our app actually need? To send an email. That's it. It doesn't care who delivers it.
So let's write that down as an interface:
// src/mail/mailer.ts
export interface Email {
to: string
subject: string
text: string
}
export interface Mailer {
send(email: Email): Promise<void>
}
Notice there's no from here. The provider knows what address it is allowed to send from, our app doesn't need to.
Step 2: one class per provider
Now we give each vendor its own little box. Each class is the only file in the whole project that knows that vendor exists:
// src/mail/resend-mailer.ts
import { Resend } from 'resend'
import type { Email, Mailer } from './mailer.js'
export class ResendMailer implements Mailer {
private readonly client: Resend
constructor(apiKey: string) {
this.client = new Resend(apiKey)
}
async send(email: Email) {
const { error } = await this.client.emails.send({
from: 'onboarding@resend.dev',
...email,
})
if (error) throw new Error(error.message)
}
}
// src/mail/mailpit-mailer.ts
import nodemailer, { type Transporter } from 'nodemailer'
import type { Email, Mailer } from './mailer.js'
export class MailpitMailer implements Mailer {
private readonly transport: Transporter
constructor(host = 'localhost', port = 1025) {
this.transport = nodemailer.createTransport({ host, port })
}
async send(email: Email) {
await this.transport.sendMail({ from: 'dev@localhost', ...email })
}
}
(Yes, this is TypeScript. If your thing is Kotlin, C#, Go or whatever else, the same idea maps over almost one to one. Pinky promise.)
Step 3: the welcome email goes back to being boring
// src/welcome.ts
import type { Mailer } from './mail/mailer.js'
export async function sendWelcomeEmail(mailer: Mailer, to: string) {
await mailer.send({
to,
subject: 'Welcome!',
text: 'Thanks for signing up 🤝',
})
}
Look at the imports. No Resend. No Nodemailer. No process.env. Just the shape of a thing that can send emails.
Step 4: decide once, at the front door
There's exactly one place in the app that knows which environment we are in, and that's where the app starts:
// src/main.ts
import { ResendMailer } from './mail/resend-mailer.js'
import { MailpitMailer } from './mail/mailpit-mailer.js'
import { sendWelcomeEmail } from './welcome.js'
const isProduction = process.env.NODE_ENV === 'production'
const mailer = isProduction
? new ResendMailer(process.env.RESEND_API_KEY!)
: new MailpitMailer()
await sendWelcomeEmail(mailer, 'chris@example.com')
console.log('Sent!')
Now let's try it both ways.
Locally:
npx tsx src/main.ts
Refresh http://localhost:8025 and the email is right there. No API key, no internet, no spam.
And "in production":
NODE_ENV=production RESEND_API_KEY=re_123... npx tsx src/main.ts
Check your real inbox. Same code, same sendWelcomeEmail(), different delivery guy.
Quick note on the test account: Resend's free tier lets you send from
onboarding@resend.devonly to the email you signed up with. So use that one as yourtowhile testing and you will get the email without having to verify a domain.
The Why
So, what did we actually win here? Let's count:
-
sendWelcomeEmail()has one reason to change: the content of the welcome email. Not the vendor, not the environment. - The
NODE_ENVcheck exists in exactly one line of the whole project. Add 10 more kinds of emails and that line doesn't move. - Want to try a third provider (SES, Postmark, you name it)? Write one new class, change one line in
main.ts. Nothing else even knows it happened. - The Resend SDK is only imported by
ResendMailer. If you never create one, you never touch it. The "forgot the API key" crash is gone for free. - And testing becomes stupidly easy: pass a
Mailerthat just pushes emails into an array and assert on it. No mocking libraries, no network.
That's what people mean when they say loosely coupled. Our welcome email and Resend used to be glued together. Now they are just two things that happen to agree on a tiny contract called Mailer.
The welcome email doesn't know Resend exists. Resend doesn't know the welcome email exists. And main.ts is the only one who introduces them to each other.
I know it looks like extra ceremony for a two-line email. The day you have to swap a vendor on a Friday afternoon and only one file changes, you will remember this post.
The Goodbye
And that's a wrap. If you've read How I finally understood what Encapsulation is, you might notice this is the same trick at a bigger scale: push the messy details behind a small, dull contract and let everything else lean on the dull thing. Dull is good. Dull doesn't wake you up at 3 AM.
Go send some fake emails. Happy coding 🤝
Top comments (0)