At its core, Nest.js provides a robust dependency injection system for resolving dependencies within your application. While a typical use case might be to inject a database connection into any service classes that need it, where this can really shine is when your application relies on one or more external service(s).

A solution that we often employ in this situation looks like the following:

  1. Create a TypeScript interface that provides an ergonomic surface area for how we want to interact with the external service
  2. Create concrete classes that implement this interface that we can use in both a local development environment and a remote production environment
  3. Create a referenceable service token that can be used to inject the right dependency as needed
  4. Create a service fake that implements the above interface that can be injected for use in our end-to-end (e2e) tests

In Practice

A server-side application will typically need to send email, so let’s think about how that looks in its simplest form. As promised, we’ll tackle the interface first:

type Message = {
  from: string;
  to: string;
  subject: string;
  body: string;
};

interface Mailer {
  send(message: Message): Promise<void>;
}

Now we can introduce some concrete classes that implement this interface. First, we’ll start with a class that will actually send an email through an SMTP relay (using nodemailer):

type SMTPMailerOptions = {
  host: string;
  username: string;
  password: string;
};

class SMTPMailer implements Mailer {
  protected readonly transport: ReturnType<typeof createTransport>;

  constructor(options: SMTPMailerOptions) {
    this.transport = createTransport({
      host: options.host,
      auth: {
        user: options.username,
        pass: options.password,
      },
    });
  }

  async send(message: Message): Promise<void> {
    await this.transport.sendMail({
      from: message.from,
      to: message.to,
      subject: message.subject,
      text: message.body,
    });
  }
}

And a corresponding class that will satisfy this interface but will log the message rather than attempting delivery:

class LoggingMailer implements Mailer {
  send(message: Message): Promise<void> {
    return Promise.resolve(console.info(JSON.stringify(message)));
  }
}

Injecting Our Dependency

In order to use these mailer classes, we now need to make Nest’s dependency injection system aware of them. Because we’re not providing a concrete class (we’ll be using our Mailer interface), we need to specify a service token that can be resolved to an instance of a Mailer class.

The service token can be a string, but I like taking the extra step of creating a TypeScript enum to make things less error-prone:

enum ServiceTokens {
  Mailer = "SERVICE_TOKENS_MAILER",
}

Now, we can register our mailer as a provider – I like the useFactory approach for most cases:

// app.module.ts

@Module({
  providers: [
    {
      provide: ServiceTokens.Mailer,
      useFactory: (): Mailer => {
        const env = process.env.NODE_ENV || "development";

        if (["development", "test"].includes(env)) {
          return new LoggingMailer();
        }

        // Typically, this configuration would come
        // from external environment variables
        return new SMTPMailer({
          host: "smtp.relay",
          username: "admin",
          password: "d34db33f",
        });
      },
    },
  ],
})
export class AppModule {}

So … in our local environments we’ll simply log all email messages, otherwise we will try to send through the configured SMTP server.

The credentials in this example are hard-coded but you’ll definitely want to pull this configuration from your environment. The official Nest documentation is a good place to research approaches.

Putting it All Together

Now that we’ve registered our mailer as a provider, we can inject it anywhere we need to. Let’s put together a simple controller that will send a message to a target recipient:

class Recipient {
  email: string;
}

@Controller("notifications")
@Injectable()
export class NotificationsController {
  constructor(
    @Inject(ServiceTokens.Mailer) protected readonly mailer: Mailer
  ) {}

  @Post()
  @HttpCode(HttpStatus.OK)
  async notify(@Body() recipient: Recipient) {
    await this.mailer.send({
      from: "noreply@host.example",
      to: recipient.email,
      subject: "Notification",
      body: "You have been notified!",
    });

    return { status: "ok" };
  }
}

Make sure to add the controller to your AppModule:

// app.module.ts

@Module({
  controllers: [NotificationsController],
  // ...
})
export class AppModule {}

You should now be able to boot your app with yarn start:dev and use cURL to initiate a message send:

curl -s \
  -H "Content-Type: application/json" \
  -d '{"email":"user@host.example"}' \
  "http://localhost:3000/notifications" | python -m json.tool

You should see a response of {"status": "ok"} and see a message printed to the Nest.js console:

{"from":"noreply@host.example","to":"user@host.example","subject":"Notification","body":"You have been notified!"}

Bonus: Adding a Fake for E2E Testing

We’ve already guarded against accidentally sending emails in our test environment, but what about when we want to assert whether or not an email was sent from our application? An approach that I prefer over using a tool like jest.spyOn() is to introduce a lightweight fake.

We can again rely on the initial interface definition to help define our fake:

class MailerFake implements Mailer {
  lastMessage?: Message;

  send(message: Message): Promise<void> {
    this.lastMessage = message;
    return Promise.resolve();
  }

  clear(): void {
    this.lastMessage = undefined;
  }
}

Swapping out our mailer at test time is as easy as using overrideProvider() when creating our testing module:

// test/notifications.e2e-spec.ts

describe("Notifications (e2e)", () => {
  let app: INestApplication;
  let mailer: MailerFake;

  beforeAll(async () => {
    mailer = new MailerFake();

    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    })
      .overrideProvider(ServiceTokens.Mailer)
      .useValue(mailer)
      .compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  afterAll(async () => await app.close());

  // prevent pollution between tests
  afterEach(() => mailer.clear());

  it("sends an email to the specified recipient", async () => {
    const { body, status } = await request(app.getHttpServer())
      .post("/notifications")
      .send({ email: "user@host.example" });

    expect(body).toEqual({ status: "ok" });
    expect(status).toBe(HttpStatus.OK);

    expect(mailer.lastMessage).toEqual({
      from: "noreply@host.example",
      to: "user@host.example",
      subject: "Notification",
      body: "You have been notified!",
    });
  });
});

Now we have a robust solution that sends mail when we want, doesn’t send mail when we don’t want, and allows us to have confidence that our application was doing the right thing the whole time.