Programs have requirements.
Let’s say you have an application with a function named sendPasswordResetEmail. When a user clicks “Forgot Password”, this function sends them an email with a reset link.
You decide to use Amazon SES (Simple Email Service) to send the email. Here’s what the code might look like:
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
class SendPasswordResetEmailError extends Error { constructor(cause: unknown) { super("Failed to send the password reset email"); this.name = "SendPasswordResetEmailError"; this.cause = cause; }}
const sesClient = new SESClient({ region: "us-east-1", credentials: { accessKeyId: "your-access-key-id", secretAccessKey: "your-secret-access-key", },});
async function sendPasswordResetEmail(to: string, resetLink: string) { try { const command = new SendEmailCommand({ Destination: { ToAddresses: [to] }, Message: { Body: { Html: { Charset: "UTF-8", Data: ` <h1>Password Reset</h1> <p>Click the link below to reset your password:</p> <a href="${resetLink}">Reset Password</a> `, }, }, Subject: { Charset: "UTF-8", Data: "Reset Your Password", }, }, Source: "noreply@yourapp.com", });
await sesClient.send(command); return { success: true }; } catch (error) { return new SendPasswordResetEmailError(error); }}The code works. You call sendPasswordResetEmail("user@example.com", "https://yourapp.com/reset?token=abc123") and the user gets their email.
Now, I have a question for you. Can you tell me what the requirements of the sendPasswordResetEmail function are?
Just by looking at the arguments sendPasswordResetEmail accepts, you might say the function requires a recipient address and a reset link to do its job. But that would be a lie. The function also depends on an email client (sesClient in our case) to do its job. That dependency is invisible from the function’s signature. A developer reading sendPasswordResetEmail(to: string, resetLink: string) has no way to know, just from the arguments, that the function depends on sesClient.
Besides hiding its true requirements, the function has another issue. It is tightly coupled to a specific email client. If your application had other email-sending functions such as sendWelcomeEmail, sendApprovalEmail, sendInvoiceEmail, and you decided to swap AWS SES for Resend, you would need to change the code in every single one of these functions.
Now if you think about it for a moment, why does sendPasswordResetEmail need to know about a specific email client? It only needs an email client to send the email. It does not have to concern itself with whether the email client is SES, Resend, SendGrid, or something else.
Dependency Injection
In the previous section, we discussed how the sendPasswordResetEmail function lies about its dependencies. So how can we solve this problem?
The fix is straightforward. Instead of the function reaching for an email client on its own, we can provide that email client from the outside as an argument. Here is how the code would look:
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
async function sendPasswordResetEmail( emailClient: SESClient, to: string, resetLink: string,) { try { const command = new SendEmailCommand({ Destination: { ToAddresses: [to] }, Message: { Body: { Html: { Charset: "UTF-8", Data: ` <h1>Password Reset</h1> <p>Click the link below to reset your password:</p> <a href="${resetLink}">Reset Password</a> `, }, }, Subject: { Charset: "UTF-8", Data: "Reset Your Password", }, }, Source: "noreply@yourapp.com", });
await emailClient.send(command); return { success: true }; } catch (error) { return new SendPasswordResetEmailError(error); }}Now, just by looking at the function signature, anyone can tell that sendPasswordResetEmail depends on an email client, a recipient address, and a reset link. The function no longer lies about its requirements. This idea of providing dependencies from the outside rather than letting functions create their own is called dependency injection.
Dependency injection made the dependencies of sendPasswordResetEmail visible. But it introduced another problem. What happens when the function needs more dependencies, say a logger and a configuration object?
async function sendPasswordResetEmail( emailClient: SESClient, logger: Logger, config: AppConfig, to: string, resetLink: string,): Promise<void> { // ...}And the function that calls sendPasswordResetEmail also needs its own dependencies:
async function handleForgotPassword( emailClient: SESClient, logger: Logger, config: AppConfig, email: string,) { const resetLink = `${config.baseUrl}/reset?token=agr38b`; return sendPasswordResetEmail(emailClient, email, resetLink);}Every function has to accept its own dependencies plus all the dependencies of every function it calls. The parameter lists grow. The calling code becomes noisy. You end up threading dependencies through five layers of function calls just so some deeply nested function can access a logger.
This is the fundamental tension: coupling is bad, but manually passing dependencies everywhere is also bad.
We need a system that automatically makes dependencies available to any function that needs them, no matter how deep in the call chain, and tracks all of this at the type level so the compiler tells us if we forgot to provide something. This is exactly what Effect Services do.