Adapters
Los adapters son interfaces que abstraen servicios externos y de terceros, permitiendo intercambiar implementaciones sin afectar la lógica de negocio. Su uso se reserva exclusivamente para servicios externos, no para comunicación entre módulos internos.
Concepto Clave
Los adapters NO se usan para comunicación interna. Los módulos se comunican entre sí mediante importación directa:
// ✅ Comunicación interna - Import directo
import { usersService } from '@/modules/users/services';
// ✅ Servicio externo - Adapter
import { emailAdapter } from './adapters/email-adapter';Cuándo Usar Adapters
✅ Casos de Uso Correctos
- Servicios de Email: SendGrid, Resend, Mailgun, AWS SES
- Servicios de Pago: Stripe, PayPal, MercadoPago
- Servicios de Almacenamiento: AWS S3, Cloudinary, Supabase Storage
- Servicios de Autenticación: Auth0, Firebase Auth, Supabase Auth
- APIs de Terceros: Google Maps, Twilio, Slack
- Bases de Datos: PostgreSQL, MongoDB, Redis
❌ Casos Incorrectos
- Comunicación entre módulos internos
- Abstraer lógica de negocio propia
- Crear capas innecesarias de abstracción
Estructura de un Adapter
1. Definir la Interfaz
// src/modules/notifications/adapters/email-adapter.ts
export interface EmailAdapter {
sendEmail(params: SendEmailParams): Promise<EmailResult>;
sendBulkEmail(params: BulkEmailParams): Promise<BulkEmailResult>;
validateEmail(email: string): Promise<boolean>;
}
export interface SendEmailParams {
to: string;
subject: string;
html: string;
text?: string;
attachments?: Attachment[];
}
export interface EmailResult {
success: boolean;
messageId?: string;
error?: string;
}2. Implementaciones Específicas
SendGrid Adapter
// src/modules/notifications/adapters/sendgrid-adapter.ts
import sgMail from '@sendgrid/mail';
import { EmailAdapter, SendEmailParams, EmailResult } from './email-adapter';
export class SendGridAdapter implements EmailAdapter {
constructor(private apiKey: string) {
sgMail.setApiKey(apiKey);
}
async sendEmail(params: SendEmailParams): Promise<EmailResult> {
try {
const msg = {
to: params.to,
from: process.env.FROM_EMAIL!,
subject: params.subject,
html: params.html,
text: params.text,
};
const [response] = await sgMail.send(msg);
return {
success: true,
messageId: response.headers['x-message-id'],
};
} catch (error) {
return {
success: false,
error: error.message,
};
}
}
async sendBulkEmail(params: BulkEmailParams): Promise<BulkEmailResult> {
// Implementación específica de SendGrid para emails masivos
}
async validateEmail(email: string): Promise<boolean> {
// Usar API de validación de SendGrid
return true;
}
}Resend Adapter
// src/modules/notifications/adapters/resend-adapter.ts
import { Resend } from 'resend';
import { EmailAdapter, SendEmailParams, EmailResult } from './email-adapter';
export class ResendAdapter implements EmailAdapter {
private resend: Resend;
constructor(apiKey: string) {
this.resend = new Resend(apiKey);
}
async sendEmail(params: SendEmailParams): Promise<EmailResult> {
try {
const { data, error } = await this.resend.emails.send({
from: process.env.FROM_EMAIL!,
to: params.to,
subject: params.subject,
html: params.html,
text: params.text,
});
if (error) {
return {
success: false,
error: error.message,
};
}
return {
success: true,
messageId: data?.id,
};
} catch (error) {
return {
success: false,
error: error.message,
};
}
}
async sendBulkEmail(params: BulkEmailParams): Promise<BulkEmailResult> {
// Implementación específica de Resend
}
async validateEmail(email: string): Promise<boolean> {
// Validación básica o usar servicio de Resend
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
}3. Configuración del Adapter
// src/modules/notifications/config.ts
import { EmailAdapter } from './adapters/email-adapter';
import { SendGridAdapter } from './adapters/sendgrid-adapter';
import { ResendAdapter } from './adapters/resend-adapter';
function createEmailAdapter(): EmailAdapter {
const provider = process.env.EMAIL_PROVIDER;
switch (provider) {
case 'sendgrid':
return new SendGridAdapter(process.env.SENDGRID_API_KEY!);
case 'resend':
return new ResendAdapter(process.env.RESEND_API_KEY!);
default:
throw new Error(`Email provider ${provider} not supported`);
}
}
export const emailAdapter = createEmailAdapter();4. Uso en el Servicio
Los adapters deben inyectarse en los servicios para mantener la flexibilidad y la testabilidad.
// src/modules/notifications/services.ts
import { EmailAdapter } from './adapters/email-adapter';
import { emailAdapter } from './config'; // Importamos la instancia configurada del adapter
export class NotificationService {
constructor(private emailAdapter: EmailAdapter) {}
async sendWelcomeEmail(userEmail: string, userName: string) {
const result = await this.emailAdapter.sendEmail({
to: userEmail,
subject: '¡Bienvenido a Creative Minds!',
html: `<h1>Hola ${userName}</h1><p>Gracias por registrarte.</p>`,
text: `Hola ${userName}, gracias por registrarte.`,
});
if (!result.success) {
throw new Error(`Failed to send welcome email: ${result.error}`);
}
return result;
}
async sendPasswordResetEmail(userEmail: string, resetToken: string) {
const resetUrl = `${process.env.APP_URL}/reset-password?token=${resetToken}`;
const result = await this.emailAdapter.sendEmail({
to: userEmail,
subject: 'Restablecer contraseña',
html: `<p>Haz clic <a href="${resetUrl}">aquí</a> para restablecer tu contraseña.</p>`,
text: `Visita este enlace para restablecer tu contraseña: ${resetUrl}`,
});
return result;
}
}
// Exportamos una instancia del servicio con el adapter inyectado
export const notificationService = new NotificationService(emailAdapter);Ejemplo Completo: Payment Adapter
1. Interfaz del Payment Adapter
// src/modules/payments/adapters/payment-adapter.ts
export interface PaymentAdapter {
processPayment(params: ProcessPaymentParams): Promise<PaymentResult>;
refundPayment(paymentId: string, amount?: number): Promise<RefundResult>;
createCustomer(customerData: CustomerData): Promise<Customer>;
getPaymentStatus(paymentId: string): Promise<PaymentStatus>;
}
export interface ProcessPaymentParams {
amount: number;
currency: string;
customerId: string;
paymentMethodId: string;
description?: string;
metadata?: Record<string, string>;
}
export interface PaymentResult {
success: boolean;
paymentId?: string;
status?: 'succeeded' | 'pending' | 'failed';
error?: string;
}2. Stripe Adapter
// src/modules/payments/adapters/stripe-adapter.ts
import Stripe from 'stripe';
import { PaymentAdapter, ProcessPaymentParams, PaymentResult } from './payment-adapter';
export class StripeAdapter implements PaymentAdapter {
private stripe: Stripe;
constructor(secretKey: string) {
this.stripe = new Stripe(secretKey, {
apiVersion: '2023-10-16',
});
}
async processPayment(params: ProcessPaymentParams): Promise<PaymentResult> {
try {
const paymentIntent = await this.stripe.paymentIntents.create({
amount: params.amount * 100, // Stripe usa centavos
currency: params.currency,
customer: params.customerId,
payment_method: params.paymentMethodId,
description: params.description,
metadata: params.metadata,
confirm: true,
return_url: `${process.env.APP_URL}/payment/success`,
});
return {
success: paymentIntent.status === 'succeeded',
paymentId: paymentIntent.id,
status: paymentIntent.status as any,
};
} catch (error) {
return {
success: false,
error: error.message,
};
}
}
async refundPayment(paymentId: string, amount?: number): Promise<RefundResult> {
try {
const refund = await this.stripe.refunds.create({
payment_intent: paymentId,
amount: amount ? amount * 100 : undefined,
});
return {
success: refund.status === 'succeeded',
refundId: refund.id,
amount: refund.amount / 100,
};
} catch (error) {
return {
success: false,
error: error.message,
};
}
}
async createCustomer(customerData: CustomerData): Promise<Customer> {
const customer = await this.stripe.customers.create({
email: customerData.email,
name: customerData.name,
metadata: customerData.metadata,
});
return {
id: customer.id,
email: customer.email!,
name: customer.name!,
};
}
async getPaymentStatus(paymentId: string): Promise<PaymentStatus> {
const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentId);
return {
id: paymentIntent.id,
status: paymentIntent.status as any,
amount: paymentIntent.amount / 100,
currency: paymentIntent.currency,
};
}
}3. PayPal Adapter
// src/modules/payments/adapters/paypal-adapter.ts
import { PaymentAdapter, ProcessPaymentParams, PaymentResult } from './payment-adapter';
export class PayPalAdapter implements PaymentAdapter {
constructor(
private clientId: string,
private clientSecret: string,
private environment: 'sandbox' | 'production'
) {}
async processPayment(params: ProcessPaymentParams): Promise<PaymentResult> {
try {
// Implementación específica de PayPal
const accessToken = await this.getAccessToken();
const payment = await fetch(`${this.getBaseUrl()}/v2/checkout/orders`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
intent: 'CAPTURE',
purchase_units: [{
amount: {
currency_code: params.currency.toUpperCase(),
value: params.amount.toString(),
},
description: params.description,
}],
}),
});
const result = await payment.json();
return {
success: result.status === 'CREATED',
paymentId: result.id,
status: result.status === 'CREATED' ? 'pending' : 'failed',
};
} catch (error) {
return {
success: false,
error: error.message,
};
}
}
private async getAccessToken(): string {
// Implementación para obtener token de PayPal
const auth = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');
const response = await fetch(`${this.getBaseUrl()}/v1/oauth2/token`, {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'grant_type=client_credentials',
});
const data = await response.json();
return data.access_token;
}
private getBaseUrl(): string {
return this.environment === 'sandbox'
? 'https://api-m.sandbox.paypal.com'
: 'https://api-m.paypal.com';
}
// Implementar otros métodos...
}3. Configuración y Uso
// src/modules/payments/config.ts
import { PaymentAdapter } from './adapters/payment-adapter';
import { StripeAdapter } from './adapters/stripe-adapter';
import { PayPalAdapter } from './adapters/paypal-adapter';
function createPaymentAdapter(): PaymentAdapter {
const provider = process.env.PAYMENT_PROVIDER;
switch (provider) {
case 'stripe':
return new StripeAdapter(process.env.STRIPE_SECRET_KEY!);
case 'paypal':
return new PayPalAdapter(
process.env.PAYPAL_CLIENT_ID!,
process.env.PAYPAL_CLIENT_SECRET!,
process.env.NODE_ENV === 'production' ? 'production' : 'sandbox'
);
default:
throw new Error(`Payment provider ${provider} not supported`);
}
}
export const paymentAdapter = createPaymentAdapter();// src/modules/payments/services.ts
import { paymentAdapter } from './config';
export const paymentsService = {
async processPayment(paymentData: ProcessPaymentData) {
// Validaciones de negocio
if (paymentData.amount <= 0) {
throw new Error('Amount must be greater than 0');
}
// Usar el adapter
const result = await paymentAdapter.processPayment({
amount: paymentData.amount,
currency: paymentData.currency,
customerId: paymentData.customerId,
paymentMethodId: paymentData.paymentMethodId,
description: `Payment for order ${paymentData.orderId}`,
metadata: {
orderId: paymentData.orderId,
userId: paymentData.userId,
},
});
if (!result.success) {
throw new Error(`Payment failed: ${result.error}`);
}
return result;
},
};Beneficios de los Adapters
1. Intercambiabilidad
Cambiar de proveedor sin tocar la lógica de negocio:
// Solo cambiar la configuración
// De Stripe a PayPal sin tocar servicios
PAYMENT_PROVIDER=paypal # Era stripe2. Testabilidad
Crear mocks fácilmente:
// src/modules/payments/adapters/mock-payment-adapter.ts
export class MockPaymentAdapter implements PaymentAdapter {
async processPayment(params: ProcessPaymentParams): Promise<PaymentResult> {
return {
success: true,
paymentId: 'mock-payment-id',
status: 'succeeded',
};
}
// ... otros métodos mock
}3. Flexibilidad
Diferentes implementaciones para diferentes entornos:
function createPaymentAdapter(): PaymentAdapter {
if (process.env.NODE_ENV === 'test') {
return new MockPaymentAdapter();
}
if (process.env.NODE_ENV === 'development') {
return new StripeAdapter(process.env.STRIPE_TEST_KEY!);
}
return new StripeAdapter(process.env.STRIPE_LIVE_KEY!);
}Principios de los Adapters
1. Solo para Servicios Externos
No crear adapters para lógica interna de la aplicación.
2. Interfaces Claras
Definir contratos bien documentados y tipados.
3. Configuración Centralizada
Un solo lugar para cambiar implementaciones.
4. Manejo de Errores Consistente
Todos los adapters deben manejar errores de forma similar.
5. Abstracción Apropiada
No sobre-abstraer. La interfaz debe ser útil, no genérica en exceso.
Los adapters son una herramienta poderosa cuando se usan correctamente: para abstraer servicios externos y permitir flexibilidad en las implementaciones sin comprometer la simplicidad de la arquitectura interna.