Design Principles of Software: A Real-World Notification System in Go

go dev.to

By Sergio Colque Ponce — Software Engineering, Universidad Privada de Tacna.
Full source code: github.com/srg-cp/design-principles-go

When people say "this code is well designed", they rarely mean it has clever tricks. They usually mean it is easy to change. New requirements arrive every week, and good design is what lets you absorb them without rewriting half the project.

In this article I take a small, very common requirement — "send a reminder to the user" — and I show how four classic design principles turn a fragile module into one that is open to change and easy to test. Everything is written in Go, and you can run it yourself from the repository linked above.

The requirement

We are building the backend of a bank appointment system. When an appointment is created, the user should get a reminder. Today it goes by email. Next month, product wants SMS too. After that, WhatsApp. The pattern is obvious: the list of channels will keep growing.

A first (bad) attempt

The fastest thing to write is one function that does everything:

func SendReminder(channel, recipient, body string) error {
    if channel == "email" {
        // ... open SMTP, format the email, send it
    } else if channel == "sms" {
        // ... call the SMS provider
    } else if channel == "whatsapp" {
        // ... call the WhatsApp API
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

It works on Monday. But look at what it costs us:

  • Every new channel means editing this function and risking the ones that already work.
  • The function knows about SMTP, SMS providers and HTTP clients all at once: it has many reasons to change.
  • To test the email path you need a real (or faked) SMTP server, because the logic is glued to the transport.

This is the design we want to avoid. Let's fix it one principle at a time.

1. Single Responsibility Principle (SRP)

A piece of code should have one reason to change.

Instead of one function that knows every channel, we give each channel its own type that only knows how to deliver through that channel. Here is the email one:

// EmailNotifier only knows how to format and send an email.
// It does NOT decide who gets notified or when.
type EmailNotifier struct {
    From string
    out  io.Writer // the transport is injected, so tests don't need SMTP
}

func (e *EmailNotifier) Notify(ctx context.Context, msg Message) error {
    if !strings.Contains(msg.Recipient, "@") {
        return fmt.Errorf("invalid email address: %q", msg.Recipient)
    }
    _, err := fmt.Fprintf(e.out, "EMAIL to=%s | %s: %s\n",
        e.From, msg.Subject, msg.Body)
    return err
}
Enter fullscreen mode Exit fullscreen mode

If the email format changes, only this type changes. SMS code stays untouched.

2. Dependency Inversion Principle (DIP)

Depend on abstractions, not on concrete implementations.

We describe what any channel can do with a small interface:

type Notifier interface {
    Notify(ctx context.Context, msg Message) error
    Channel() string
}
Enter fullscreen mode Exit fullscreen mode

Now the high-level code does not care whether it is talking to email, SMS or WhatsApp. It only talks to a Notifier. The dependency arrow is inverted: the concrete channels depend on the abstraction, not the other way around.

3. Open/Closed Principle (OCP)

Software should be open for extension, closed for modification.

The component that fans a message out to all channels is the Dispatcher. Notice that it never mentions a specific channel:

type Dispatcher struct {
    channels []Notifier
}

func NewDispatcher(channels ...Notifier) *Dispatcher {
    return &Dispatcher{channels: channels}
}

func (d *Dispatcher) Broadcast(ctx context.Context, msg Message) error {
    var failures []error
    for _, ch := range d.channels {
        if err := ch.Notify(ctx, msg); err != nil {
            failures = append(failures, fmt.Errorf("%s: %w", ch.Channel(), err))
        }
    }
    if len(failures) > 0 {
        return errors.Join(failures...)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

When WhatsApp arrives, I do not open the Dispatcher. I just add a new type that satisfies Notifier:

// Added later, without changing Dispatcher, EmailNotifier or SMSNotifier.
type WhatsAppNotifier struct{ out io.Writer }

func (w *WhatsAppNotifier) Channel() string { return "whatsapp" }

func (w *WhatsAppNotifier) Notify(ctx context.Context, msg Message) error {
    _, err := fmt.Fprintf(w.out, "WHATS to=%s | %s\n", msg.Recipient, msg.Body)
    return err
}
Enter fullscreen mode Exit fullscreen mode

That is the whole point of OCP: new behavior is added, existing behavior is left alone.

4. Composition over inheritance

Build complex behavior by combining small parts, not by inheriting from a base class.

Go does not even have inheritance, which makes the lesson natural. We assemble the dispatcher from the channels we want:

dispatcher := notify.NewDispatcher(
    notify.NewEmailNotifier("reminders@bank.com", os.Stdout),
    notify.NewSMSNotifier(os.Stdout),
    notify.NewWhatsAppNotifier(os.Stdout),
)

msg := notify.Message{
    Recipient: "sergio@example.com",
    Subject:   "Appointment reminder",
    Body:      "Your bank appointment is tomorrow at 10:00 AM.",
}

_ = dispatcher.Broadcast(context.Background(), msg)
Enter fullscreen mode Exit fullscreen mode

Running it prints:

Enter fullscreen mode Exit fullscreen mode

Want a different mix for unit tests, staging or production? Compose a different dispatcher. No subclasses, no rigid hierarchy.

The payoff: testing becomes trivial

Because the Dispatcher depends on the Notifier interface (DIP), I can test it with a fake channel — no SMTP, no SMS gateway, no network:

type spyNotifier struct {
    name    string
    calls   int
    failErr error
}

func (s *spyNotifier) Channel() string { return s.name }
func (s *spyNotifier) Notify(ctx context.Context, msg Message) error {
    s.calls++
    return s.failErr
}

func TestDispatcherAggregatesFailures(t *testing.T) {
    ok := &spyNotifier{name: "ok"}
    bad := &spyNotifier{name: "bad", failErr: errors.New("boom")}
    d := NewDispatcher(ok, bad)

    err := d.Broadcast(context.Background(), Message{Recipient: "x"})
    if err == nil || !strings.Contains(err.Error(), "bad: boom") {
        t.Fatalf("expected the failing channel to be reported, got: %v", err)
    }
    if ok.calls != 1 {
        t.Fatal("a failing channel must not stop the healthy ones")
    }
}
Enter fullscreen mode Exit fullscreen mode

That fake exists only because we programmed against an interface. The fragile first attempt could never be tested this cleanly.

Keeping it honest with automation

Principles are easy to forget under deadline pressure, so the repository runs GitHub Actions on every push: it checks formatting with gofmt, runs static analysis with go vet, and executes the test suite. If any of them fails, the build goes red.

name: CI
on: [push, pull_request]
jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - run: test -z "$(gofmt -l .)"
      - run: go vet ./...
      - run: go test ./... -v
Enter fullscreen mode Exit fullscreen mode

Conclusion

None of these principles are exotic. SRP, OCP, DIP and composition are really four angles on the same idea: isolate what changes. A naive if channel == "..." ladder forces you to reopen working code for every new feature. A small interface plus a few focused types lets the system grow by addition, and lets you test each part in isolation.

The full, runnable project is here: github.com/srg-cp/design-principles-go. Clone it, add a TelegramNotifier, and notice that you never have to touch the Dispatcher. That feeling — adding without breaking — is what good design buys you.

Thanks for reading!

Source: dev.to

arrow_back Back to Tutorials