SwadhyAI Notes

All Low-Level Design notes

Machine coding · not HLD

Notification service

Checkout just placed an order and needs to tell the customer. The first draft calls JavaMail (the Java email SDK) from OrderService. Next week product wants SMS, then WhatsApp, and every new channel edits checkout.

This page is factory plus in one process. The seam is simple: the use case asks a for a channel and calls send. It never imports JavaMail. Queues, fan-out, and delivery at scale belong on the related System Design note: Notification service .

Safe send path

1

NotifyUser asks forType from a preference; it never constructs EmailChannel.

2

The returned channel delivers the message. SMTP (the email protocol) lives in an adapter.

3

Tests record the last notification. They do not open a real mail socket.

4

If you cannot inject the factory, the design is already wrong.

What actually happens when checkout sends mail

A notification is a value: recipient, template, and payload. A is the thing that delivers it. A maps a type or user preference to that channel. The controller asks the factory, then calls send(). That is the whole in-process design.

The domain should not import an SMTP SDK. Channel implementations live outside the core, behind the interface. That is a dependency boundary, not just a pattern name. Tests use a that records the last notification. You do not send real mail in a unit test.

Templates can be another strategy later. Retry and fallback (email failed, try SMS) can be a chain. Do not start there. Start with one send path and a factory. See Factory and Strategy. A bus from in-process pub/sub can call NotifyUser without OrderService knowing.

How to add a channel without touching checkout

The hero story fails when OrderService names the mail SDK. The fix is a closed use case plus an open registry, and three questions tell you whether you have it.

1. Can NotifyUser stay closed? A new channel should be a class that implements send, plus one map.put. If checkout still has switch (channel), every WhatsApp request edits OrderService.

2. Can you inject the factory? If the use case constructs new JavaMail(), you cannot swap a . That is already a failed : policy imported the detail.

3. Does an unknown type fail loudly? A silent no-op hides a missing registry entry. Throw, or use a you chose on purpose.

Simple rule of thumb

Checkout never imports mail SDKs. , tests use a fake, and SMTP never sits beside placeOrder. If you cannot inject the factory, stop and fix the seam before you talk about fallback chains.

From problem to mechanism

Here is the checkout story again, slowed down so you can see where the naive import fails and what the factory changes.

Problem. OrderPlaced needs a message on a chosen channel (email, SMS, or push). Product will add WhatsApp. Checkout must not become the switchboard.

Naive system. The use case constructs the SDK (see the rule of thumb above), or a hides new JavaMail() with no interface. There is no inject point.

Break it. Every new channel edits checkout. Unit tests cannot fake send. An unknown ChannelType silently no-ops.

Mechanism. NotifyUser asks a , then send. Channel adapters live outside the core. The sequence below is that hop: the use case never names JavaMail.

Controller never names EmailChannel

The mechanism above is one hop: ask the factory, then send. This sequence is that hop drawn out.

Diagram text: the exact Mermaid definition below preserves the nodes, labels, and connections. Enable JavaScript to view the rendered diagram.
Read the diagram definition
sequenceDiagram
    participant U as NotifyUser
    participant F as ChannelFactory
    participant C as NotificationChannel
    U->>F: forType(user.preferred)
    F-->>U: EmailChannel
    U->>C: send(notification)
    Note over U: use case never names JavaMail

Responsibility walk

Those four names in the diagram each own one job. If a type starts doing two of them, the seam is already leaking.

TypeJob
NotifyUserUse-case controller: ask the factory for a channel, then send.
ChannelFactoryRegistry that maps ChannelType to a NotificationChannel implementation.
NotificationChannelDefines send. SMTP SDK calls live in an adapter, not on this interface.
NotificationImmutable value carrying recipient, template id, and payload.

Java API sketch

The same four types, written as Java. MapChannelFactory throws on an unknown type; FakeChannel records last so a test can assert without SMTP.

java
interface NotificationChannel {
  void send(Notification notification);
}

interface ChannelFactory {
  NotificationChannel forType(ChannelType type);
}

class MapChannelFactory implements ChannelFactory {
  private final Map<ChannelType, NotificationChannel> registry;
  public NotificationChannel forType(ChannelType type) {
    NotificationChannel ch = registry.get(type);
    if (ch == null) throw new UnknownChannel(type);
    return ch;
  }
}

class NotifyUser {
  private final ChannelFactory channels;
  void execute(User user, Notification notification) {
    channels.forType(user.preferredChannel()).send(notification);
  }
}

class FakeChannel implements NotificationChannel {
  Notification last;
  public void send(Notification n) { last = n; }
}

Checkout walkthrough, and a forty-five minute plan

The sketch above is the API. This is the grade path: place an order, assert on a fake, then swap the factory in a thin main without editing NotifyUser.

OrderPlaced happens. NotifyUser.execute(user, notification). The factory returns in tests. Assert last recipient and template id. Swap the factory to a MapChannelFactory with EmailChannel in a thin main. The use case did not change.

  1. 1. Interface plus FakeChannel test. No SMTP socket in the unit suite.
  2. 2. Build a factory registry so NotifyUser asks forType, then send.
  3. 3. Optional : a PaymentSucceeded subscriber calls NotifyUser. OrderService still does not name EmailChannel.

Tests that prove the seams

java
@Test
void notifyRecordsOnFake() {
  FakeChannel fake = new FakeChannel();
  ChannelFactory factory = type -> fake;
  new NotifyUser(factory).execute(user, n);
  assertEquals(user.email(), fake.last.recipient());
}

Forty-five minutes

  1. 0–8Write NotificationChannel plus a FakeChannel test that never opens SMTP.
  2. 8–20Wire NotifyUser to MapChannelFactory so the use case only asks forType.
  3. 20–32Read the user's preferred channel; an unknown type must throw.
  4. 32–40Add WhatsApp as a new class plus one registry put.
  5. 40–45Name the scale note from the hero; keep SMTP out of checkout.

Invalid cases, concurrency, and what the follow-up changes

The walkthrough assumes a clean seam. These are the shapes that destroy it, the in-process concurrency rules that still apply, and the follow-ups that stay behind the same factory.

Invalid cases

  • switch (channel) in OrderService

    Every new channel force-edits checkout, so the use case is not closed.

  • new JavaMail() inside the strategy with no interface

    You cannot fake send in a unit test; inject the channel.

  • Unknown ChannelType silently no-ops

    Throw, or use a Null Object you chose on purpose.

  • send(null)

    Reject at the command edge because Notification is a required value.

  • Calling the real mail SDK in tests

    FakeChannel records last send, which proves the seam works.

Concurrency

NotifyUser is usually stateless. If you add a rate limit per user, that is an in-process limiter, still not a notification cluster (see the System Design note in the hero). Fallback chains should not double-send if two threads retry the same order id: put an key on the notification. A processed-ids set on the controller, or the channel adapter, is enough in process.

What the follow-up changes

  • WhatsApp. Add a new channel class and registry entry; NotifyUser stays closed.
  • Email failed, try SMS. Chain channels behind the factory (or a composite channel), still not inside checkout.
  • Fan-out at scale. Open the related System Design note; keep SMTP out of checkout.

Wrap-up

You have the seam, the sketch, and the cases that break it. Checkout is not an SMTP client. Related notes below are the patterns this page used; the takeaway is the grade bar.

You're done when

Checkout still does not import mail SDKs. NotifyUser asks a factory, and tests never open a socket. Adding a channel does not edit the use case. SMTP never sits beside placeOrder. is the habit: depend on ChannelFactory, not JavaMail.