A Domain-Driven Notification Microservice — Patterns From Production The article describes how notification systems in software projects often evolve from simple functions into complex systems requiring email, SMS, push notifications, and user preferences. The author advocates for applying domain-driven design (DDD) to separate notification concerns into three distinct layers: business code that emits domain events, a notification service that translates events into messages based on user preferences, and a dispatcher that handles delivery logic like quiet hours and channel filtering. This decoupled architecture prevents the business logic from being entangled with notification delivery details, making the system more maintainable and scalable. Notifications start small. "Send the user an email when their order ships." A function. A library. Done. A year later, you have email, SMS, push, in-app, Slack, and Microsoft Teams. You have user preferences per channel. You have quiet hours, batching, throttling, and a "do not disturb" mode. You have unsubscribe links and bounce handling. You have analytics on open rates and template-level metrics. You have multi-language templates and timezone-aware scheduling. What started as a function is now a system. If you keep it as a sprawling collection of sendEmail and sendSlack helpers across your codebase, that system will eat your engineering team alive. This is the shape of the notification microservice I built and have rebuilt twice . The pattern isn't novel — it's domain-driven design applied to notifications — but the specifics matter. The core insight A notification has three distinct concerns: - What happened in the business — "order placed," "user mentioned," "invoice overdue." This is a domain event. - What kind of message to send — "transactional email," "high-urgency push," "Slack mention." This is a delivery policy. - How to actually send it — "render this template, then call the email provider's API." This is a channel adapter. Most codebases collapse all three into one function: async function sendOrderShippedEmail orderId: string, userId: string { const user = await getUser userId const order = await getOrder orderId const html = renderTemplate 'order-shipped', { user, order } await sendgrid.send { to: user.email, subject: 'Your order shipped', html } } This function knows about the domain event, the message type, and the channel. Three concerns. One function. Each one will change for different reasons, and changes will ripple across all the call sites. The DDD-shaped version separates them. Domain events The business code emits an event. It doesn't know or care how the notification gets delivered. // In your order service await eventBus.publish { type: 'order.shipped', occurredAt: new Date .toISOString , data: { orderId: order.id, userId: order.userId, trackingNumber: order.trackingNumber, }, } This is a fire-and-record operation. The order service is done. It moves on. The event lands in your event bus BullMQ, Kafka, NATS, whatever . The notification service consumes events. Its job is to translate "order.shipped" into "a transactional email to the user with this template." Notification preferences The user's preferences live in a separate domain. They might be stored in the notification service's database or in a profile service — doesn't matter, as long as they're queryable: type UserNotificationPreferences = { userId: string channels: { email: { enabled: boolean; address: string } sms: { enabled: boolean; number?: string } push: { enabled: boolean; deviceTokens: string } slack: { enabled: boolean; userId?: string } } perEventType: Record