NestJS Email System Design: A Professional Guide to SendGrid and Mailgun (Part 1)


As a senior full-stack developer with five years of experience, I’ve encountered numerous challenges in managing complex email systems. This series stems from my firsthand experience and the critical need to share the importance of SOLID principles in software architecture.

Throughout my career, I’ve observed how seemingly simple email features can quickly evolve into intricate systems, often resulting in maintenance nightmares. This realization prompted me to create this comprehensive guide, focusing on the practical implementation of SOLID principles in NestJS for building robust, scalable email systems.

This two-part series aims to bridge the gap between theoretical knowledge of SOLID principles and their real-world application. Part 1 will lay the groundwork and explore core concepts, while Part 2 will delve into specific implementation details. By sharing these insights, I hope to equip fellow developers with the tools to create more maintainable and flexible email systems in their projects.

The Progression of Email Functionality

Consider this scenario: Your startup has just released its Minimum Viable Product (MVP). You implement a basic password reset feature using SendGrid’s SDK. Initially, this seems straightforward. However, as marketing requests welcome emails and other departments add their requirements, you soon find yourself managing:

  • Order confirmations
  • Shipping updates
  • Marketing campaigns
  • Newsletter subscriptions
  • User activity notifications

As a result, email-sending logic becomes dispersed throughout your codebase, tightly coupled to your email provider. Individual services directly import SendGrid’s SDK, manage their own templates, and handle errors inconsistently. While this approach may appear practical at first, it can lead to complications in the future.

Practical Implications

Consider these common scenarios:

  • Provider Outages: In the event of an email provider outage, how rapidly can you transition to a backup solution?
  • Cost Optimization: If you identify a more cost-effective provider, how long would the migration process take?
  • A/B Testing: When marketing needs to test different email templates, how many files require modification?
  • Delivery Tracking: If you need to implement email delivery tracking, where would you incorporate this functionality?

Without a well-structured architecture, addressing each of these scenarios necessitates changes across multiple files, increasing the potential for errors and complicating the testing process.

Applying SOLID Principles to Email Systems

Let’s examine how each SOLID principle influences our email architecture:

Single Responsibility Principle: “One Purpose, One Component”

The Single Responsibility Principle (SRP) dictates that each class should have only one reason to change. In our email system, this principle is demonstrated through:

Email Provider: Dedicated solely to sending emails

interface IEmailProvider {
  sendEmail(to: string, subject: string, content: string): Promise<EmailResponse>;
}

@Injectable()
class SendGridProvider implements IEmailProvider {
  constructor(
    private readonly apiKey: string,
    private readonly fromEmail: string
  ) {
    SendGrid.setApiKey(this.apiKey);
  }
  async sendEmail(to: string, subject: string, content: string): Promise<EmailResponse> {
    try {
      const [response] = await SendGrid.send({
        to,
        from: this.fromEmail,
        subject,
        html: content
      });
      return {
        success: true,
        messageId: response.headers['x-message-id']
      };
    } catch (error) {
      return {
        success: false,
        error: error.message
      };
    }
  }
}

Template Engine: Focused exclusively on template rendering

interface ITemplateEngine {
  render(template: string, data: Record<string, any>): Promise<string>;
  renderSubject(template: string, data: Record<string, any>): Promise<string>;
}

@Injectable()
class LiquidTemplateEngine implements ITemplateEngine {
  private readonly engine: Liquid;
  constructor() {
    this.engine = new Liquid({
      strictVariables: true,
      strictFilters: true,
    });
  }
  async render(template: string, data: Record<string, any>): Promise<string> {
    return this.engine.parseAndRender(template, data);
  }
  async renderSubject(template: string, data: Record<string, any>): Promise<string> {
    return this.render(template, data);
  }
}

Template Repository: Devoted to template storage

@Entity()
export class EmailTemplate {
  @PrimaryColumn()
  id: string;
  @Column()
  subject: string;
  @Column('text')
  content: string;
  @Column('jsonb', { nullable: true })
  metadata?: Record<string, any>;
}

@Injectable()
export class EmailTemplateRepository implements ITemplateRepository {
  constructor(
    @InjectRepository(EmailTemplate)
    private readonly repository: Repository<EmailTemplate>
  ) {}
  async findById(id: string): Promise<EmailTemplate> {
    const template = await this.repository.findOne({ where: { id } });
    if (!template) throw new NotFoundException(`Template ${id} not found`);
    return template;
  }
}

Open/Closed Principle: “Open for Extension, Closed for Modification”

The Open/Closed Principle (OCP) suggests that software entities should be open for extension but closed for modification. Our email system exemplifies this through:

Provider Extensions

// Base provider capabilities
interface IEmailProvider {
  sendEmail(to: string, subject: string, content: string): Promise<EmailResponse>;
}

// SendGrid implementation
@Injectable()
export class SendGridProvider implements IEmailProvider {
  // Implementation as shown above
}
// Mailgun implementation - no changes to existing code
@Injectable()
export class MailgunProvider implements IEmailProvider {
  constructor(
    private readonly apiKey: string,
    private readonly fromEmail: string
  ) {}
  async sendEmail(to: string, subject: string, content: string): Promise<EmailResponse> {
    // Mailgun-specific implementation
  }
}

Template Engine Flexibility

@Injectable()
class HandlebarsTemplateEngine implements ITemplateEngine {
  async render(template: string, data: Record<string, any>): Promise<string> {
    return Handlebars.compile(template)(data);
  }
  async renderSubject(template: string, data: Record<string, any>): Promise<string> {
    return this.render(template, data);
  }
}

Liskov Substitution Principle: “Interchangeable Parts”

The Liskov Substitution Principle (LSP) states that objects should be replaceable with their subtypes without affecting program correctness. In our system, this is demonstrated by:

@Injectable()
export class EmailService {
  constructor(
    @Inject(INJECTION_TOKENS.EMAIL_PROVIDER)
    private readonly emailProvider: IEmailProvider,
    @Inject(INJECTION_TOKENS.TEMPLATE_ENGINE)
    private readonly templateEngine: ITemplateEngine,
    @Inject(INJECTION_TOKENS.TEMPLATE_REPOSITORY)
    private readonly templateRepository: ITemplateRepository,
  ) {}
  async sendTemplatedEmail(emailData: EmailData): Promise<EmailResponse> {
    const template = await this.templateRepository.findById(emailData.templateId);
    const [renderedSubject, renderedContent] = await Promise.all([
      this.templateEngine.renderSubject(template.subject, emailData.templateData),
      this.templateEngine.render(template.content, emailData.templateData)
    ]);
    return this.emailProvider.sendEmail(
      emailData.to,
      renderedSubject,
      renderedContent
    );
  }
}

This service is compatible with any implementation of our interfaces, illustrating the LSP in practice.

Interface Segregation Principle: “Focused Interfaces”

The Interface Segregation Principle (ISP) proposes that clients should not be forced to depend on interfaces they do not use. Our system demonstrates this through targeted interfaces:

// Core email functionality
interface IEmailProvider {
  sendEmail(to: string, subject: string, content: string): Promise<EmailResponse>;
}
// Template handling
interface ITemplateEngine {
  render(template: string, data: Record<string, any>): Promise<string>;
  renderSubject(template: string, data: Record<string, any>): Promise<string>;
}
// Template storage
interface ITemplateRepository {
  findById(id: string): Promise<EmailTemplate>;
}

Dependency Inversion Principle: “Depend on Abstractions”

The Dependency Inversion Principle (DIP) requires high-level modules to depend on abstractions rather than concrete implementations. Our module configuration illustrates this:

@Module({})
export class EmailModule {
  static forRoot(): DynamicModule {
    return {
      module: EmailModule,
      imports: [TypeOrmModule.forFeature([EmailTemplate])],
      providers: [
        {
          provide: INJECTION_TOKENS.EMAIL_PROVIDER,
          useFactory: (configService: ConfigService) => {
            const provider = configService.get('EMAIL_PROVIDER');
            const apiKey = configService.get(
              provider === 'sendgrid' ? 'SENDGRID_API_KEY' : 'MAILGUN_API_KEY'
            );
            const fromEmail = configService.get(
              provider === 'sendgrid' ? 'SENDGRID_FROM_EMAIL' : 'MAILGUN_FROM_EMAIL'
            );
      return provider === 'sendgrid'
              ? new SendGridProvider(apiKey, fromEmail)
              : new MailgunProvider(apiKey, fromEmail);
          },
          inject: [ConfigService],
        },
        {
          provide: INJECTION_TOKENS.TEMPLATE_ENGINE,
          useClass: LiquidTemplateEngine,
        },
        EmailTemplateRepository,
        {
          provide: INJECTION_TOKENS.TEMPLATE_REPOSITORY,
          useExisting: EmailTemplateRepository,
        },
        EmailService,
      ],
      exports: [EmailService],
    };
  }
}

Practical Advantages of SOLID in Our Email System

This architecture offers several immediate benefits:

Simplified Testing

// Test provider for development
class TestEmailProvider implements IEmailProvider {
  async sendEmail(to: string, subject: string, content: string): Promise<EmailResponse> {
    console.log(`Email to ${to}: ${subject}`);
    return { success: true, messageId: 'test-123' };
  }
}

Effortless Provider Switching

# In your configuration
EMAIL_PROVIDER=sendgrid
# or
EMAIL_PROVIDER=mailgun

Streamlined Business Logic

@Injectable()
export class UserService {
  constructor(private readonly emailService: EmailService) {}
  async sendWelcomeEmail(user: User) {
    await this.emailService.sendTemplatedEmail({
      to: user.email,
      templateId: 'welcome-email',
      templateData: { name: user.firstName }
    });
  }
}

Forthcoming in Part 2

In the subsequent article, we will explore the implementation details in depth:

  • Developing provider implementations (SendGrid, Mailgun)
  • Constructing a robust template engine
  • Configuring dependency injection
  • Managing errors and implementing retry mechanisms
  • Implementing the comprehensive email module
  • Exploring testing strategies and best practices

We look forward to presenting this information in the next installment.


Ready to implement this architecture? Continue with Building a Scalable Email Architecture with NestJS, SendGrid and Mailgun (Part 2), where we’ll build the complete system step by step. The full source code is available in our GitHub repository.

Tags:

#NestJS #Email #SendGrid #Mailgun #Architecture #SOLID