Repository: favonia/cloudflare-ddns
Pull request #911 (2024-08-30 15:32:52)

refactor: rewrite code using Go 1.23 iterators

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/911?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) Report
Attention: Patch coverage is `70.00000%` with `24 lines` in your changes missing coverage. Please review.
> Project coverage is 94.98%. Comparing base [(`29fe605`)](https://app.codecov.io/gh/favonia/cloudflare-ddns/commit/29fe605461986508729dee0cc55a520c0d796a1b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) to head [(`284ceaf`)](https://app.codecov.io/gh/favonia/cloudflare-ddns/commit/284ceaf0b94e9efaf74bbdcb051411b2ed5ee2bb?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).
> Report is 1 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/911?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | Patch % | Lines |
|---|---|---|
| [cmd/ddns/ddns.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/911?src=pr&el=tree&filepath=cmd%2Fddns%2Fddns.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-Y21kL2RkbnMvZGRucy5nbw==) | 0.00% | [10 Missing :warning: ](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/911?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) |
| [internal/notifier/shoutrrr.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/911?src=pr&el=tree&filepath=internal%2Fnotifier%2Fshoutrrr.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvbm90aWZpZXIvc2hvdXRycnIuZ28=) | 79.06% | [6 Missing and 3 partials :warning: ](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/911?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) |
| [internal/message/monitor.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/911?src=pr&el=tree&filepath=internal%2Fmessage%2Fmonitor.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvbWVzc2FnZS9tb25pdG9yLmdv) | 40.00% | [3 Missing :warning: ](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/911?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) |
| [internal/monitor/composite.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/911?src=pr&el=tree&filepath=internal%2Fmonitor%2Fcomposite.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvbW9uaXRvci9jb21wb3NpdGUuZ28=) | 60.00% | [1 Missing and 1 partial :warning: ](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/911?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #911      +/-   ##
==========================================
- Coverage   95.59%   94.98%   -0.61%     
==========================================
  Files          61       59       -2     
  Lines        2588     2594       +6     
==========================================
- Hits         2474     2464      -10     
- Misses        104      116      +12     
- Partials       10       14       +4     
```

| [Flag](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/911/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/911/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | `94.98% <70.00%> (-0.61%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/911?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).


Patch:
diff --git a/.golangci.yaml b/.golangci.yaml
index 5d00032..7d6ada5 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -10,6 +10,8 @@ linters-settings:
     settings:
       printf:
         funcs:
+          - github.com/favonia/cloudflare-ddns/internal/message.NewMonitorMessagef
+          - github.com/favonia/cloudflare-ddns/internal/message.NewNotifierMessagef
           - (github.com/favonia/cloudflare-ddns/internal/pp.PP).Infof
           - (github.com/favonia/cloudflare-ddns/internal/pp.PP).Noticef
           - (github.com/favonia/cloudflare-ddns/internal/pp.PP).Hintf
@@ -39,6 +41,7 @@ linters:
   disable:
     - execinquery # deprecated
     - gomnd # deprecated
+    - exportloopref # deprecated
 
     - cyclop # can detect complicated code, but never leads to actual code changes
     - funlen # can detect complicated code, but never leads to actual code changes
diff --git a/cmd/ddns/ddns.go b/cmd/ddns/ddns.go
index d412dbf..612d24e 100644
--- a/cmd/ddns/ddns.go
+++ b/cmd/ddns/ddns.go
@@ -57,8 +57,8 @@ func initConfig(ppfmt pp.PP) (*config.Config, setter.Setter, bool) {
 func stopUpdating(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter) {
 	if c.DeleteOnStop {
 		msg := updater.DeleteIPs(ctx, ppfmt, c, s)
-		monitor.LogMessageAll(ctx, ppfmt, c.Monitors, msg.MonitorMessage)
-		notifier.SendMessageAll(ctx, ppfmt, c.Notifiers, msg.NotifierMessage)
+		c.Monitor.Log(ctx, ppfmt, msg.MonitorMessage)
+		c.Notifier.Send(ctx, ppfmt, msg.NotifierMessage)
 	}
 }
 
@@ -89,18 +89,18 @@ func realMain() int { //nolint:funlen
 	// Read the config and get the handler and the setter
 	c, s, configOK := initConfig(ppfmt)
 	// Ping monitors regardless of whether initConfig succeeded
-	monitor.StartAll(ctx, ppfmt, c.Monitors, formatName())
+	c.Monitor.Start(ctx, ppfmt, formatName())
 	// Bail out now if initConfig failed
 	if !configOK {
-		monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 1, "Configuration errors")
-		notifier.SendAll(ctx, ppfmt, c.Notifiers,
-			"Cloudflare DDNS was misconfigured and could not start. Please check the logging for details.")
+		c.Monitor.Ping(ctx, ppfmt, monitor.NewMessagef(false, "Configuration errors"))
+		c.Notifier.Send(ctx, ppfmt, notifier.NewMessagef(
+			"Cloudflare DDNS was misconfigured and could not start. Please check the logging for details."))
 		ppfmt.Infof(pp.EmojiBye, "Bye!")
 		return 1
 	}
 	// If UPDATE_CRON is not `@once` (not single-run mode), then send a notification to signal the start.
 	if c.UpdateCron != nil {
-		notifier.SendAll(ctx, ppfmt, c.Notifiers, "Started running Cloudflare DDNS.")
+		c.Notifier.Send(ctx, ppfmt, notifier.NewMessagef("Started running Cloudflare DDNS."))
 	}
 
 	// Without the following line, the quiet mode can be too quiet, and some system (Portainer)
@@ -122,11 +122,11 @@ func realMain() int { //nolint:funlen
 
 		// Update the IP addresses
 		if first && !c.UpdateOnStart {
-			monitor.SuccessAll(ctx, ppfmt, c.Monitors, "Started (no action)")
+			c.Monitor.Ping(ctx, ppfmt, monitor.NewMessagef(true, "Started (no action)"))
 		} else {
 			msg := updater.UpdateIPs(ctxWithSignals, ppfmt, c, s)
-			monitor.PingMessageAll(ctx, ppfmt, c.Monitors, msg.MonitorMessage)
-			notifier.SendMessageAll(ctx, ppfmt, c.Notifiers, msg.NotifierMessage)
+			c.Monitor.Ping(ctx, ppfmt, msg.MonitorMessage)
+			c.Notifier.Send(ctx, ppfmt, msg.NotifierMessage)
 		}
 
 		if ctxWithSignals.Err() != nil {
@@ -148,9 +148,9 @@ func realMain() int { //nolint:funlen
 				cron.DescribeSchedule(c.UpdateCron),
 			)
 			stopUpdating(ctx, ppfmt, c, s)
-			monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 1, "No scheduled updates")
-			notifier.SendAll(ctx, ppfmt, c.Notifiers,
-				fmt.Sprintf(
+			c.Monitor.Ping(ctx, ppfmt, monitor.NewMessagef(false, "No scheduled updates"))
+			c.Notifier.Send(ctx, ppfmt,
+				notifier.NewMessagef(
 					"Cloudflare DDNS stopped because there are no scheduled updates in near future. "+
 						"Consider changing the value of UPDATE_CRON (%s).",
 					cron.DescribeSchedule(c.UpdateCron),
@@ -167,9 +167,9 @@ func realMain() int { //nolint:funlen
 		// Wait for the next signal or the alarm, whichever comes first
 		if sig.WaitForSignalsUntil(ppfmt, next) {
 			stopUpdating(ctx, ppfmt, c, s)
-			monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 0, "Stopped")
+			c.Monitor.Exit(ctx, ppfmt, "Stopped")
 			if c.UpdateCron != nil {
-				notifier.SendAll(ctx, ppfmt, c.Notifiers, "Stopped running Cloudflare DDNS.")
+				c.Notifier.Send(ctx, ppfmt, notifier.NewMessagef("Stopped running Cloudflare DDNS."))
 			}
 			ppfmt.Infof(pp.EmojiBye, "Bye!")
 			return 0
diff --git a/go.mod b/go.mod
index 1106fd9..e8e86e9 100644
--- a/go.mod
+++ b/go.mod
@@ -12,6 +12,7 @@ require (
 	github.com/stretchr/testify v1.9.0
 	go.uber.org/mock v0.4.0
 	golang.org/x/net v0.28.0
+	golang.org/x/text v0.17.0
 )
 
 require (
@@ -24,7 +25,6 @@ require (
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	golang.org/x/sync v0.8.0 // indirect
 	golang.org/x/sys v0.23.0 // indirect
-	golang.org/x/text v0.17.0 // indirect
 	golang.org/x/time v0.5.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/internal/config/config.go b/internal/config/config.go
index ab26a8e..0b22110 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -31,8 +31,8 @@ type Config struct {
 	WAFListDescription string
 	DetectionTimeout   time.Duration
 	UpdateTimeout      time.Duration
-	Monitors           []monitor.Monitor
-	Notifiers          []notifier.Notifier
+	Monitor            monitor.Monitor
+	Notifier           notifier.Notifier
 }
 
 // Default gives the default configuration.
@@ -59,7 +59,7 @@ func Default() *Config {
 		WAFListDescription: "",
 		DetectionTimeout:   time.Second * 5,  //nolint:mnd
 		UpdateTimeout:      time.Second * 30, //nolint:mnd
-		Monitors:           nil,
-		Notifiers:          nil,
+		Monitor:            nil,
+		Notifier:           nil,
 	}
 }
diff --git a/internal/config/config_print.go b/internal/config/config_print.go
index 955534e..5274097 100644
--- a/internal/config/config_print.go
+++ b/internal/config/config_print.go
@@ -10,8 +10,6 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/cron"
 	"github.com/favonia/cloudflare-ddns/internal/domain"
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
-	"github.com/favonia/cloudflare-ddns/internal/monitor"
-	"github.com/favonia/cloudflare-ddns/internal/notifier"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 	"github.com/favonia/cloudflare-ddns/internal/provider"
 )
@@ -88,17 +86,17 @@ func (c *Config) Print(ppfmt pp.PP) {
 	item("IP detection:", "%v", c.DetectionTimeout)
 	item("Record/list updating:", "%v", c.UpdateTimeout)
 
-	if len(c.Monitors) > 0 {
+	if c.Monitor != nil {
 		section("Monitors:")
-		monitor.DescribeAll(func(service, params string) {
-			item(service+":", "%s", params)
-		}, c.Monitors)
+		for name, params := range c.Monitor.Describe {
+			item(name+":", "%s", params)
+		}
 	}
 
-	if len(c.Notifiers) > 0 {
+	if c.Notifier != nil {
 		section("Notification services (via shoutrrr):")
-		notifier.DescribeAll(func(service, params string) {
-			item(service+":", "%s", params)
-		}, c.Notifiers)
+		for name, params := range c.Notifier.Describe {
+			item(name+":", "%s", params)
+		}
 	}
 }
diff --git a/internal/config/config_read.go b/internal/config/config_read.go
index 54870e5..d0f40e0 100644
--- a/internal/config/config_read.go
+++ b/internal/config/config_read.go
@@ -34,9 +34,9 @@ func (c *Config) ReadEnv(ppfmt pp.PP) bool {
 		!ReadString(ppfmt, "WAF_LIST_DESCRIPTION", &c.WAFListDescription) ||
 		!ReadNonnegDuration(ppfmt, "DETECTION_TIMEOUT", &c.DetectionTimeout) ||
 		!ReadNonnegDuration(ppfmt, "UPDATE_TIMEOUT", &c.UpdateTimeout) ||
-		!ReadAndAppendHealthchecksURL(ppfmt, "HEALTHCHECKS", &c.Monitors) ||
-		!ReadAndAppendUptimeKumaURL(ppfmt, "UPTIMEKUMA", &c.Monitors) ||
-		!ReadAndAppendShoutrrrURL(ppfmt, "SHOUTRRR", &c.Notifiers) {
+		!ReadAndAppendHealthchecksURL(ppfmt, "HEALTHCHECKS", &c.Monitor) ||
+		!ReadAndAppendUptimeKumaURL(ppfmt, "UPTIMEKUMA", &c.Monitor) ||
+		!ReadAndAppendShoutrrrURL(ppfmt, "SHOUTRRR", &c.Notifier) {
 		return false
 	}
 
diff --git a/internal/config/env_monitor.go b/internal/config/env_monitor.go
index d8643e7..ea547ba 100644
--- a/internal/config/env_monitor.go
+++ b/internal/config/env_monitor.go
@@ -6,7 +6,7 @@ import (
 )
 
 // ReadAndAppendHealthchecksURL reads the base URL of a Healthchecks endpoint.
-func ReadAndAppendHealthchecksURL(ppfmt pp.PP, key string, field *[]monitor.Monitor) bool {
+func ReadAndAppendHealthchecksURL(ppfmt pp.PP, key string, field *monitor.Monitor) bool {
 	val := Getenv(key)
 
 	if val == "" {
@@ -19,12 +19,12 @@ func ReadAndAppendHealthchecksURL(ppfmt pp.PP, key string, field *[]monitor.Moni
 	}
 
 	// Append the new monitor to the existing list
-	*field = append(*field, h)
+	*field = monitor.NewComposed(*field, h)
 	return true
 }
 
 // ReadAndAppendUptimeKumaURL reads the URL of a Push Monitor of an Uptime Kuma server.
-func ReadAndAppendUptimeKumaURL(ppfmt pp.PP, key string, field *[]monitor.Monitor) bool {
+func ReadAndAppendUptimeKumaURL(ppfmt pp.PP, key string, field *monitor.Monitor) bool {
 	val := Getenv(key)
 
 	if val == "" {
@@ -37,6 +37,6 @@ func ReadAndAppendUptimeKumaURL(ppfmt pp.PP, key string, field *[]monitor.Monito
 	}
 
 	// Append the new monitor to the existing list
-	*field = append(*field, h)
+	*field = monitor.NewComposed(*field, h)
 	return true
 }
diff --git a/internal/config/env_notifier.go b/internal/config/env_notifier.go
index 89b91b0..0da43da 100644
--- a/internal/config/env_notifier.go
+++ b/internal/config/env_notifier.go
@@ -6,7 +6,7 @@ import (
 )
 
 // ReadAndAppendShoutrrrURL reads the URLs separated by the newline.
-func ReadAndAppendShoutrrrURL(ppfmt pp.PP, key string, field *[]notifier.Notifier) bool {
+func ReadAndAppendShoutrrrURL(ppfmt pp.PP, key string, field *notifier.Notifier) bool {
 	vals := GetenvAsList(key, "\n")
 	if len(vals) == 0 {
 		return true
@@ -18,6 +18,6 @@ func ReadAndAppendShoutrrrURL(ppfmt pp.PP, key string, field *[]notifier.Notifie
 	}
 
 	// Append the new monitor to the existing list
-	*field = append(*field, s)
+	*field = s
 	return true
 }
diff --git a/internal/message/message.go b/internal/message/message.go
index 41f30f9..61e63f2 100644
--- a/internal/message/message.go
+++ b/internal/message/message.go
@@ -2,24 +2,29 @@
 // monitors and notifiers.
 package message
 
+import (
+	"github.com/favonia/cloudflare-ddns/internal/monitor"
+	"github.com/favonia/cloudflare-ddns/internal/notifier"
+)
+
 // Message encapsulates the messages to both monitiors and notifiers.
 type Message struct {
-	NotifierMessage
-	MonitorMessage
+	MonitorMessage  monitor.Message
+	NotifierMessage notifier.Message
 }
 
 // New creates a new, empty message.
 func New() Message {
 	return Message{
-		MonitorMessage:  NewMonitorMessage(),
-		NotifierMessage: NewNotifierMessage(),
+		MonitorMessage:  monitor.NewMessage(),
+		NotifierMessage: notifier.NewMessage(),
 	}
 }
 
 // Merge combines multiple compound messages.
 func Merge(msgs ...Message) Message {
-	mms := make([]MonitorMessage, len(msgs))
-	nms := make([]NotifierMessage, len(msgs))
+	mms := make([]monitor.Message, len(msgs))
+	nms := make([]notifier.Message, len(msgs))
 
 	for i := range msgs {
 		mms[i] = msgs[i].MonitorMessage
@@ -27,7 +32,7 @@ func Merge(msgs ...Message) Message {
 	}
 
 	return Message{
-		MonitorMessage:  MergeMonitorMessages(mms...),
-		NotifierMessage: MergeNotifierMessages(nms...),
+		MonitorMessage:  monitor.MergeMessages(mms...),
+		NotifierMessage: notifier.MergeMessages(nms...),
 	}
 }
diff --git a/internal/message/monitor.go b/internal/message/monitor.go
deleted file mode 100644
index 5b350cb..0000000
--- a/internal/message/monitor.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package message
-
-import (
-	"slices"
-	"strings"
-)
-
-// MonitorMessage holds the messages and success/failure status for monitors.
-type MonitorMessage struct {
-	OK    bool
-	Lines []string
-}
-
-// NewMonitorMessage creates a new empty MonitorMessage.
-func NewMonitorMessage() MonitorMessage {
-	return MonitorMessage{
-		OK:    true,
-		Lines: nil,
-	}
-}
-
-// Format turns the message into a single string.
-func (m MonitorMessage) Format() string {
-	return strings.Join(m.Lines, "\n")
-}
-
-// MergeMonitorMessages keeps only the ones with highest severity.
-func MergeMonitorMessages(msgs ...MonitorMessage) MonitorMessage {
-	var (
-		OK    = true
-		Lines = map[bool][][]string{}
-	)
-
-	for _, msg := range msgs {
-		OK = OK && msg.OK
-		Lines[msg.OK] = append(Lines[msg.OK], msg.Lines)
-	}
-
-	return MonitorMessage{
-		OK:    OK,
-		Lines: slices.Concat(Lines[OK]...),
-	}
-}
diff --git a/internal/message/notifier.go b/internal/message/notifier.go
deleted file mode 100644
index 72aac10..0000000
--- a/internal/message/notifier.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package message
-
-import (
-	"slices"
-	"strings"
-)
-
-// NotifierMessage holds the messages and success/failure status for notifiers.
-type NotifierMessage []string
-
-// NewNotifierMessage creates a new empty NotifierMessage.
-func NewNotifierMessage() NotifierMessage { return nil }
-
-// MergeNotifierMessages keeps only the ones with highest severity.
-func MergeNotifierMessages(msgs ...NotifierMessage) NotifierMessage {
-	return slices.Concat[NotifierMessage, string](msgs...)
-}
-
-// Format turns the message into a single string.
-func (m NotifierMessage) Format() string {
-	return strings.Join(m, " ")
-}
diff --git a/internal/mocks/mock_monitor.go b/internal/mocks/mock_monitor.go
index dde6290..573d591 100644
--- a/internal/mocks/mock_monitor.go
+++ b/internal/mocks/mock_monitor.go
@@ -1,9 +1,9 @@
 // Code generated by MockGen. DO NOT EDIT.
-// Source: github.com/favonia/cloudflare-ddns/internal/monitor (interfaces: Monitor)
+// Source: github.com/favonia/cloudflare-ddns/internal/monitor (interfaces: BasicMonitor,Monitor)
 //
 // Generated by this command:
 //
-//	mockgen -typed -destination=../mocks/mock_monitor.go -package=mocks . Monitor
+//	mockgen -typed -destination=../mocks/mock_monitor.go -package=mocks . BasicMonitor,Monitor
 //
 // Package mocks is a generated GoMock package.
 package mocks
@@ -12,147 +12,207 @@ import (
 	context "context"
 	reflect "reflect"
 
+	monitor "github.com/favonia/cloudflare-ddns/internal/monitor"
 	pp "github.com/favonia/cloudflare-ddns/internal/pp"
 	gomock "go.uber.org/mock/gomock"
 )
 
-// MockMonitor is a mock of Monitor interface.
-type MockMonitor struct {
+// MockBasicMonitor is a mock of BasicMonitor interface.
+type MockBasicMonitor struct {
 	ctrl     *gomock.Controller
-	recorder *MockMonitorMockRecorder
+	recorder *MockBasicMonitorMockRecorder
 }
 
-// MockMonitorMockRecorder is the mock recorder for MockMonitor.
-type MockMonitorMockRecorder struct {
-	mock *MockMonitor
+// MockBasicMonitorMockRecorder is the mock recorder for MockBasicMonitor.
+type MockBasicMonitorMockRecorder struct {
+	mock *MockBasicMonitor
 }
 
-// NewMockMonitor creates a new mock instance.
-func NewMockMonitor(ctrl *gomock.Controller) *MockMonitor {
-	mock := &MockMonitor{ctrl: ctrl}
-	mock.recorder = &MockMonitorMockRecorder{mock}
+// NewMockBasicMonitor creates a new mock instance.
+func NewMockBasicMonitor(ctrl *gomock.Controller) *MockBasicMonitor {
+	mock := &MockBasicMonitor{ctrl: ctrl}
+	mock.recorder = &MockBasicMonitorMockRecorder{mock}
 	return mock
 }
 
 // EXPECT returns an object that allows the caller to indicate expected use.
-func (m *MockMonitor) EXPECT() *MockMonitorMockRecorder {
+func (m *MockBasicMonitor) EXPECT() *MockBasicMonitorMockRecorder {
 	return m.recorder
 }
 
 // Describe mocks base method.
-func (m *MockMonitor) Describe(arg0 func(string, string)) {
+func (m *MockBasicMonitor) Describe(arg0 func(string, string) bool) {
 	m.ctrl.T.Helper()
 	m.ctrl.Call(m, "Describe", arg0)
 }
 
 // Describe indicates an expected call of Describe.
-func (mr *MockMonitorMockRecorder) Describe(arg0 any) *MonitorDescribeCall {
+func (mr *MockBasicMonitorMockRecorder) Describe(arg0 any) *BasicMonitorDescribeCall {
 	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Describe", reflect.TypeOf((*MockMonitor)(nil).Describe), arg0)
-	return &MonitorDescribeCall{Call: call}
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Describe", reflect.TypeOf((*MockBasicMonitor)(nil).Describe), arg0)
+	return &BasicMonitorDescribeCall{Call: call}
 }
 
-// MonitorDescribeCall wrap *gomock.Call
-type MonitorDescribeCall struct {
+// BasicMonitorDescribeCall wrap *gomock.Call
+type BasicMonitorDescribeCall struct {
 	*gomock.Call
 }
 
 // Return rewrite *gomock.Call.Return
-func (c *MonitorDescribeCall) Return() *MonitorDescribeCall {
+func (c *BasicMonitorDescribeCall) Return() *BasicMonitorDescribeCall {
 	c.Call = c.Call.Return()
 	return c
 }
 
 // Do rewrite *gomock.Call.Do
-func (c *MonitorDescribeCall) Do(f func(func(string, string))) *MonitorDescribeCall {
+func (c *BasicMonitorDescribeCall) Do(f func(func(string, string) bool)) *BasicMonitorDescribeCall {
 	c.Call = c.Call.Do(f)
 	return c
 }
 
 // DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MonitorDescribeCall) DoAndReturn(f func(func(string, string))) *MonitorDescribeCall {
+func (c *BasicMonitorDescribeCall) DoAndReturn(f func(func(string, string) bool)) *BasicMonitorDescribeCall {
 	c.Call = c.Call.DoAndReturn(f)
 	return c
 }
 
-// ExitStatus mocks base method.
-func (m *MockMonitor) ExitStatus(arg0 context.Context, arg1 pp.PP, arg2 int, arg3 string) bool {
+// Ping mocks base method.
+func (m *MockBasicMonitor) Ping(arg0 context.Context, arg1 pp.PP, arg2 monitor.Message) bool {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "ExitStatus", arg0, arg1, arg2, arg3)
+	ret := m.ctrl.Call(m, "Ping", arg0, arg1, arg2)
 	ret0, _ := ret[0].(bool)
 	return ret0
 }
 
-// ExitStatus indicates an expected call of ExitStatus.
-func (mr *MockMonitorMockRecorder) ExitStatus(arg0, arg1, arg2, arg3 any) *MonitorExitStatusCall {
+// Ping indicates an expected call of Ping.
+func (mr *MockBasicMonitorMockRecorder) Ping(arg0, arg1, arg2 any) *BasicMonitorPingCall {
 	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExitStatus", reflect.TypeOf((*MockMonitor)(nil).ExitStatus), arg0, arg1, arg2, arg3)
-	return &MonitorExitStatusCall{Call: call}
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ping", reflect.TypeOf((*MockBasicMonitor)(nil).Ping), arg0, arg1, arg2)
+	return &BasicMonitorPingCall{Call: call}
 }
 
-// MonitorExitStatusCall wrap *gomock.Call
-type MonitorExitStatusCall struct {
+// BasicMonitorPingCall wrap *gomock.Call
+type BasicMonitorPingCall struct {
 	*gomock.Call
 }
 
 // Return rewrite *gomock.Call.Return
-func (c *MonitorExitStatusCall) Return(arg0 bool) *MonitorExitStatusCall {
+func (c *BasicMonitorPingCall) Return(arg0 bool) *BasicMonitorPingCall {
 	c.Call = c.Call.Return(arg0)
 	return c
 }
 
 // Do rewrite *gomock.Call.Do
-func (c *MonitorExitStatusCall) Do(f func(context.Context, pp.PP, int, string) bool) *MonitorExitStatusCall {
+func (c *BasicMonitorPingCall) Do(f func(context.Context, pp.PP, monitor.Message) bool) *BasicMonitorPingCall {
 	c.Call = c.Call.Do(f)
 	return c
 }
 
 // DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MonitorExitStatusCall) DoAndReturn(f func(context.Context, pp.PP, int, string) bool) *MonitorExitStatusCall {
+func (c *BasicMonitorPingCall) DoAndReturn(f func(context.Context, pp.PP, monitor.Message) bool) *BasicMonitorPingCall {
 	c.Call = c.Call.DoAndReturn(f)
 	return c
 }
 
-// Failure mocks base method.
-func (m *MockMonitor) Failure(arg0 context.Context, arg1 pp.PP, arg2 string) bool {
+// MockMonitor is a mock of Monitor interface.
+type MockMonitor struct {
+	ctrl     *gomock.Controller
+	recorder *MockMonitorMockRecorder
+}
+
+// MockMonitorMockRecorder is the mock recorder for MockMonitor.
+type MockMonitorMockRecorder struct {
+	mock *MockMonitor
+}
+
+// NewMockMonitor creates a new mock instance.
+func NewMockMonitor(ctrl *gomock.Controller) *MockMonitor {
+	mock := &MockMonitor{ctrl: ctrl}
+	mock.recorder = &MockMonitorMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockMonitor) EXPECT() *MockMonitorMockRecorder {
+	return m.recorder
+}
+
+// Describe mocks base method.
+func (m *MockMonitor) Describe(arg0 func(string, string) bool) {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "Failure", arg0, arg1, arg2)
+	m.ctrl.Call(m, "Describe", arg0)
+}
+
+// Describe indicates an expected call of Describe.
+func (mr *MockMonitorMockRecorder) Describe(arg0 any) *MonitorDescribeCall {
+	mr.mock.ctrl.T.Helper()
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Describe", reflect.TypeOf((*MockMonitor)(nil).Describe), arg0)
+	return &MonitorDescribeCall{Call: call}
+}
+
+// MonitorDescribeCall wrap *gomock.Call
+type MonitorDescribeCall struct {
+	*gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MonitorDescribeCall) Return() *MonitorDescribeCall {
+	c.Call = c.Call.Return()
+	return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MonitorDescribeCall) Do(f func(func(string, string) bool)) *MonitorDescribeCall {
+	c.Call = c.Call.Do(f)
+	return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MonitorDescribeCall) DoAndReturn(f func(func(string, string) bool)) *MonitorDescribeCall {
+	c.Call = c.Call.DoAndReturn(f)
+	return c
+}
+
+// Exit mocks base method.
+func (m *MockMonitor) Exit(arg0 context.Context, arg1 pp.PP, arg2 string) bool {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Exit", arg0, arg1, arg2)
 	ret0, _ := ret[0].(bool)
 	return ret0
 }
 
-// Failure indicates an expected call of Failure.
-func (mr *MockMonitorMockRecorder) Failure(arg0, arg1, arg2 any) *MonitorFailureCall {
+// Exit indicates an expected call of Exit.
+func (mr *MockMonitorMockRecorder) Exit(arg0, arg1, arg2 any) *MonitorExitCall {
 	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Failure", reflect.TypeOf((*MockMonitor)(nil).Failure), arg0, arg1, arg2)
-	return &MonitorFailureCall{Call: call}
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exit", reflect.TypeOf((*MockMonitor)(nil).Exit), arg0, arg1, arg2)
+	return &MonitorExitCall{Call: call}
 }
 
-// MonitorFailureCall wrap *gomock.Call
-type MonitorFailureCall struct {
+// MonitorExitCall wrap *gomock.Call
+type MonitorExitCall struct {
 	*gomock.Call
 }
 
 // Return rewrite *gomock.Call.Return
-func (c *MonitorFailureCall) Return(arg0 bool) *MonitorFailureCall {
+func (c *MonitorExitCall) Return(arg0 bool) *MonitorExitCall {
 	c.Call = c.Call.Return(arg0)
 	return c
 }
 
 // Do rewrite *gomock.Call.Do
-func (c *MonitorFailureCall) Do(f func(context.Context, pp.PP, string) bool) *MonitorFailureCall {
+func (c *MonitorExitCall) Do(f func(context.Context, pp.PP, string) bool) *MonitorExitCall {
 	c.Call = c.Call.Do(f)
 	return c
 }
 
 // DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MonitorFailureCall) DoAndReturn(f func(context.Context, pp.PP, string) bool) *MonitorFailureCall {
+func (c *MonitorExitCall) DoAndReturn(f func(context.Context, pp.PP, string) bool) *MonitorExitCall {
 	c.Call = c.Call.DoAndReturn(f)
 	return c
 }
 
 // Log mocks base method.
-func (m *MockMonitor) Log(arg0 context.Context, arg1 pp.PP, arg2 string) bool {
+func (m *MockMonitor) Log(arg0 context.Context, arg1 pp.PP, arg2 monitor.Message) bool {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "Log", arg0, arg1, arg2)
 	ret0, _ := ret[0].(bool)
@@ -178,89 +238,89 @@ func (c *MonitorLogCall) Return(arg0 bool) *MonitorLogCall {
 }
 
 // Do rewrite *gomock.Call.Do
-func (c *MonitorLogCall) Do(f func(context.Context, pp.PP, string) bool) *MonitorLogCall {
+func (c *MonitorLogCall) Do(f func(context.Context, pp.PP, monitor.Message) bool) *MonitorLogCall {
 	c.Call = c.Call.Do(f)
 	return c
 }
 
 // DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MonitorLogCall) DoAndReturn(f func(context.Context, pp.PP, string) bool) *MonitorLogCall {
+func (c *MonitorLogCall) DoAndReturn(f func(context.Context, pp.PP, monitor.Message) bool) *MonitorLogCall {
 	c.Call = c.Call.DoAndReturn(f)
 	return c
 }
 
-// Start mocks base method.
-func (m *MockMonitor) Start(arg0 context.Context, arg1 pp.PP, arg2 string) bool {
+// Ping mocks base method.
+func (m *MockMonitor) Ping(arg0 context.Context, arg1 pp.PP, arg2 monitor.Message) bool {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "Start", arg0, arg1, arg2)
+	ret := m.ctrl.Call(m, "Ping", arg0, arg1, arg2)
 	ret0, _ := ret[0].(bool)
 	return ret0
 }
 
-// Start indicates an expected call of Start.
-func (mr *MockMonitorMockRecorder) Start(arg0, arg1, arg2 any) *MonitorStartCall {
+// Ping indicates an expected call of Ping.
+func (mr *MockMonitorMockRecorder) Ping(arg0, arg1, arg2 any) *MonitorPingCall {
 	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockMonitor)(nil).Start), arg0, arg1, arg2)
-	return &MonitorStartCall{Call: call}
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ping", reflect.TypeOf((*MockMonitor)(nil).Ping), arg0, arg1, arg2)
+	return &MonitorPingCall{Call: call}
 }
 
-// MonitorStartCall wrap *gomock.Call
-type MonitorStartCall struct {
+// MonitorPingCall wrap *gomock.Call
+type MonitorPingCall struct {
 	*gomock.Call
 }
 
 // Return rewrite *gomock.Call.Return
-func (c *MonitorStartCall) Return(arg0 bool) *MonitorStartCall {
+func (c *MonitorPingCall) Return(arg0 bool) *MonitorPingCall {
 	c.Call = c.Call.Return(arg0)
 	return c
 }
 
 // Do rewrite *gomock.Call.Do
-func (c *MonitorStartCall) Do(f func(context.Context, pp.PP, string) bool) *MonitorStartCall {
+func (c *MonitorPingCall) Do(f func(context.Context, pp.PP, monitor.Message) bool) *MonitorPingCall {
 	c.Call = c.Call.Do(f)
 	return c
 }
 
 // DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MonitorStartCall) DoAndReturn(f func(context.Context, pp.PP, string) bool) *MonitorStartCall {
+func (c *MonitorPingCall) DoAndReturn(f func(context.Context, pp.PP, monitor.Message) bool) *MonitorPingCall {
 	c.Call = c.Call.DoAndReturn(f)
 	return c
 }
 
-// Success mocks base method.
-func (m *MockMonitor) Success(arg0 context.Context, arg1 pp.PP, arg2 string) bool {
+// Start mocks base method.
+func (m *MockMonitor) Start(arg0 context.Context, arg1 pp.PP, arg2 string) bool {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "Success", arg0, arg1, arg2)
+	ret := m.ctrl.Call(m, "Start", arg0, arg1, arg2)
 	ret0, _ := ret[0].(bool)
 	return ret0
 }
 
-// Success indicates an expected call of Success.
-func (mr *MockMonitorMockRecorder) Success(arg0, arg1, arg2 any) *MonitorSuccessCall {
+// Start indicates an expected call of Start.
+func (mr *MockMonitorMockRecorder) Start(arg0, arg1, arg2 any) *MonitorStartCall {
 	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Success", reflect.TypeOf((*MockMonitor)(nil).Success), arg0, arg1, arg2)
-	return &MonitorSuccessCall{Call: call}
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockMonitor)(nil).Start), arg0, arg1, arg2)
+	return &MonitorStartCall{Call: call}
 }
 
-// MonitorSuccessCall wrap *gomock.Call
-type MonitorSuccessCall struct {
+// MonitorStartCall wrap *gomock.Call
+type MonitorStartCall struct {
 	*gomock.Call
 }
 
 // Return rewrite *gomock.Call.Return
-func (c *MonitorSuccessCall) Return(arg0 bool) *MonitorSuccessCall {
+func (c *MonitorStartCall) Return(arg0 bool) *MonitorStartCall {
 	c.Call = c.Call.Return(arg0)
 	return c
 }
 
 // Do rewrite *gomock.Call.Do
-func (c *MonitorSuccessCall) Do(f func(context.Context, pp.PP, string) bool) *MonitorSuccessCall {
+func (c *MonitorStartCall) Do(f func(context.Context, pp.PP, string) bool) *MonitorStartCall {
 	c.Call = c.Call.Do(f)
 	return c
 }
 
 // DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MonitorSuccessCall) DoAndReturn(f func(context.Context, pp.PP, string) bool) *MonitorSuccessCall {
+func (c *MonitorStartCall) DoAndReturn(f func(context.Context, pp.PP, string) bool) *MonitorStartCall {
 	c.Call = c.Call.DoAndReturn(f)
 	return c
 }
diff --git a/internal/mocks/mock_notifier.go b/internal/mocks/mock_notifier.go
index 74585b2..8186c81 100644
--- a/internal/mocks/mock_notifier.go
+++ b/internal/mocks/mock_notifier.go
@@ -12,6 +12,7 @@ import (
 	context "context"
 	reflect "reflect"
 
+	notifier "github.com/favonia/cloudflare-ddns/internal/notifier"
 	pp "github.com/favonia/cloudflare-ddns/internal/pp"
 	gomock "go.uber.org/mock/gomock"
 )
@@ -40,7 +41,7 @@ func (m *MockNotifier) EXPECT() *MockNotifierMockRecorder {
 }
 
 // Describe mocks base method.
-func (m *MockNotifier) Describe(arg0 func(string, string)) {
+func (m *MockNotifier) Describe(arg0 func(string, string) bool) {
 	m.ctrl.T.Helper()
 	m.ctrl.Call(m, "Describe", arg0)
 }
@@ -64,19 +65,19 @@ func (c *NotifierDescribeCall) Return() *NotifierDescribeCall {
 }
 
 // Do rewrite *gomock.Call.Do
-func (c *NotifierDescribeCall) Do(f func(func(string, string))) *NotifierDescribeCall {
+func (c *NotifierDescribeCall) Do(f func(func(string, string) bool)) *NotifierDescribeCall {
 	c.Call = c.Call.Do(f)
 	return c
 }
 
 // DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *NotifierDescribeCall) DoAndReturn(f func(func(string, string))) *NotifierDescribeCall {
+func (c *NotifierDescribeCall) DoAndReturn(f func(func(string, string) bool)) *NotifierDescribeCall {
 	c.Call = c.Call.DoAndReturn(f)
 	return c
 }
 
 // Send mocks base method.
-func (m *MockNotifier) Send(arg0 context.Context, arg1 pp.PP, arg2 string) bool {
+func (m *MockNotifier) Send(arg0 context.Context, arg1 pp.PP, arg2 notifier.Message) bool {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "Send", arg0, arg1, arg2)
 	ret0, _ := ret[0].(bool)
@@ -102,13 +103,13 @@ func (c *NotifierSendCall) Return(arg0 bool) *NotifierSendCall {
 }
 
 // Do rewrite *gomock.Call.Do
-func (c *NotifierSendCall) Do(f func(context.Context, pp.PP, string) bool) *NotifierSendCall {
+func (c *NotifierSendCall) Do(f func(context.Context, pp.PP, notifier.Message) bool) *NotifierSendCall {
 	c.Call = c.Call.Do(f)
 	return c
 }
 
 // DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *NotifierSendCall) DoAndReturn(f func(context.Context, pp.PP, string) bool) *NotifierSendCall {
+func (c *NotifierSendCall) DoAndReturn(f func(context.Context, pp.PP, notifier.Message) bool) *NotifierSendCall {
 	c.Call = c.Call.DoAndReturn(f)
 	return c
 }
diff --git a/internal/monitor/base.go b/internal/monitor/base.go
index 0e45635..e7d4f18 100644
--- a/internal/monitor/base.go
+++ b/internal/monitor/base.go
@@ -4,54 +4,36 @@ package monitor
 import (
 	"context"
 
-	"github.com/favonia/cloudflare-ddns/internal/message"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
-//go:generate mockgen -typed -destination=../mocks/mock_monitor.go -package=mocks . Monitor
+//go:generate mockgen -typed -destination=../mocks/mock_monitor.go -package=mocks . BasicMonitor,Monitor
 
 // maxReadLength is the maximum number of bytes read from an HTTP response.
 const maxReadLength int64 = 102400
 
-// Monitor is a dead man's switch, meaning that the user will be notified when the updater fails to
+// BasicMonitor is a dead man's switch, meaning that the user will be notified when the updater fails to
 // detect and update the public IP address. No notifications for IP changes.
-type Monitor interface {
-	// Describe a monitor in a human-readable format by calling callback with service names and params.
-	Describe(callback func(service, params string))
+type BasicMonitor interface {
+	// Describe a monitor as a service name and its parameters.
+	Describe(yield func(service, params string) bool)
 
-	// Success pings the monitor to prevent notifications.
-	Success(ctx context.Context, ppfmt pp.PP, message string) bool
+	// Ping with OK=true prevent notifications.
+	// Ping with OK=false immediately notifies the user.
+	Ping(ctx context.Context, ppfmt pp.PP, msg Message) bool
+}
+
+// Monitor provides more advanced features.
+type Monitor interface {
+	BasicMonitor
 
 	// Start pings the monitor with the start signal.
 	Start(ctx context.Context, ppfmt pp.PP, message string) bool
 
-	// Failure immediately signals the monitor to notify the user.
-	Failure(ctx context.Context, ppfmt pp.PP, message string) bool
-
-	// Log provides additional inforamion without changing the state.
-	Log(ctx context.Context, ppfmt pp.PP, message string) bool
-
-	// ExitStatus records the exit status (as an integer in the POSIX style).
-	ExitStatus(ctx context.Context, ppfmt pp.PP, code int, message string) bool
-}
-
-// PingMessage formats and pings with a [message.Message].
-func PingMessage(ctx context.Context, ppfmt pp.PP, m Monitor, msg message.MonitorMessage) bool {
-	if msg.OK {
-		return m.Success(ctx, ppfmt, msg.Format())
-	} else {
-		return m.Failure(ctx, ppfmt, msg.Format())
-	}
-}
+	// Exit pings the monitor with the successful exiting signal.
+	Exit(ctx context.Context, ppfmt pp.PP, message string) bool
 
-// LogMessage formats and logs a [message.Message].
-func LogMessage(ctx context.Context, ppfmt pp.PP, m Monitor, msg message.MonitorMessage) bool {
-	switch {
-	case !msg.OK:
-		return m.Failure(ctx, ppfmt, msg.Format())
-	case len(msg.Lines) > 0:
-		return m.Log(ctx, ppfmt, msg.Format())
-	default:
-		return true
-	}
+	// Log with OK=true provides additional information without changing the state.
+	// Log with OK=false immediately notifies the user.
+	Log(ctx context.Context, ppfmt pp.PP, msg Message) bool
 }
diff --git a/internal/monitor/composite.go b/internal/monitor/composite.go
index c4a4ec9..afb20b0 100644
--- a/internal/monitor/composite.go
+++ b/internal/monitor/composite.go
@@ -3,89 +3,79 @@ package monitor
 import (
 	"context"
 
-	"github.com/favonia/cloudflare-ddns/internal/message"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
-// DescribeAll calls [Monitor.Describe] for each monitor in the group with the callback.
-func DescribeAll(callback func(service, params string), ms []Monitor) {
-	for _, m := range ms {
-		m.Describe(callback)
-	}
-}
+type monitors []BasicMonitor
 
-// SuccessAll calls [Monitor.Success] for each monitor in the group.
-func SuccessAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, message string) bool {
-	ok := true
-	for _, m := range ms {
-		if !m.Success(ctx, ppfmt, message) {
-			ok = false
-		}
-	}
-	return ok
-}
+var _ Monitor = monitors{}
 
-// StartAll calls [Monitor.Start] for each monitor in ms.
-func StartAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, message string) bool {
-	ok := true
-	for _, m := range ms {
-		if !m.Start(ctx, ppfmt, message) {
-			ok = false
+// NewComposed creates a new composed monitor.
+func NewComposed(mons ...BasicMonitor) monitors {
+	ms := make([]BasicMonitor, 0, len(mons))
+	for _, m := range mons {
+		if m == nil {
+			continue
+		}
+		if list, composed := m.(monitors); composed {
+			ms = append(ms, list...)
+		} else {
+			ms = append(ms, m)
 		}
 	}
-	return ok
+	return monitors(ms)
 }
 
-// FailureAll calls [Monitor.Failure] for each monitor in ms.
-func FailureAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, message string) bool {
-	ok := true
+// Describe calls [Monitor.Describe] for each monitor in the group with the callback.
+func (ms monitors) Describe(yield func(name string, params string) bool) {
 	for _, m := range ms {
-		if !m.Failure(ctx, ppfmt, message) {
-			ok = false
+		for name, params := range m.Describe {
+			if !yield(name, params) {
+				return
+			}
 		}
 	}
-	return ok
 }
 
-// LogAll calls [Monitor.Log] for each monitor in ms.
-func LogAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, message string) bool {
+// Ping calls [Monitor.Ping] for each monitor in the group.
+func (ms monitors) Ping(ctx context.Context, ppfmt pp.PP, message Message) bool {
 	ok := true
 	for _, m := range ms {
-		if !m.Log(ctx, ppfmt, message) {
-			ok = false
-		}
+		ok = ok && m.Ping(ctx, ppfmt, message)
 	}
 	return ok
 }
 
-// ExitStatusAll calls [Monitor.ExitStatus] for each monitor in ms.
-func ExitStatusAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, code int, message string) bool {
+// Start calls [Monitor.Start] for each monitor in ms.
+func (ms monitors) Start(ctx context.Context, ppfmt pp.PP, message string) bool {
 	ok := true
 	for _, m := range ms {
-		if !m.ExitStatus(ctx, ppfmt, code, message) {
-			ok = false
+		if em, extended := m.(Monitor); extended {
+			ok = ok && em.Start(ctx, ppfmt, message)
 		}
 	}
 	return ok
 }
 
-// PingMessageAll calls [SendMessage] for each monitor in ms.
-func PingMessageAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, msg message.MonitorMessage) bool {
+// Exit calls [Monitor.Exit] for each monitor in ms.
+func (ms monitors) Exit(ctx context.Context, ppfmt pp.PP, message string) bool {
 	ok := true
 	for _, m := range ms {
-		if !PingMessage(ctx, ppfmt, m, msg) {
-			ok = false
+		if em, extended := m.(Monitor); extended {
+			ok = ok && em.Exit(ctx, ppfmt, message)
 		}
 	}
 	return ok
 }
 
-// LogMessageAll calls [SendMessage] for each monitor in ms.
-func LogMessageAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, msg message.MonitorMessage) bool {
+// Log calls [Monitor.Log] for each monitor in the group.
+func (ms monitors) Log(ctx context.Context, ppfmt pp.PP, msg Message) bool {
 	ok := true
 	for _, m := range ms {
-		if !LogMessage(ctx, ppfmt, m, msg) {
-			ok = false
+		if em, extended := m.(Monitor); extended {
+			ok = ok && em.Log(ctx, ppfmt, msg)
+		} else if !msg.OK {
+			ok = ok && m.Ping(ctx, ppfmt, msg)
 		}
 	}
 	return ok
diff --git a/internal/monitor/healthchecks.go b/internal/monitor/healthchecks.go
index ec4a474..5bc9fe0 100644
--- a/internal/monitor/healthchecks.go
+++ b/internal/monitor/healthchecks.go
@@ -2,7 +2,6 @@ package monitor
 
 import (
 	"context"
-	"fmt"
 	"io"
 	"net/http"
 	"net/url"
@@ -69,8 +68,8 @@ func NewHealthchecks(ppfmt pp.PP, rawURL string) (Healthchecks, bool) {
 }
 
 // Describe calls the callback with the service name "Healthchecks".
-func (h Healthchecks) Describe(callback func(service, params string)) {
-	callback("Healthchecks", "(URL redacted)")
+func (h Healthchecks) Describe(yield func(service, params string) bool) {
+	yield("Healthchecks", "(URL redacted)")
 }
 
 /*
@@ -153,9 +152,13 @@ func (h Healthchecks) ping(ctx context.Context, ppfmt pp.PP, endpoint string, me
 	return true
 }
 
-// Success pings the root endpoint.
-func (h Healthchecks) Success(ctx context.Context, ppfmt pp.PP, message string) bool {
-	return h.ping(ctx, ppfmt, "", message)
+// Ping formats and pings with a [Message].
+func (h Healthchecks) Ping(ctx context.Context, ppfmt pp.PP, msg Message) bool {
+	if msg.OK {
+		return h.ping(ctx, ppfmt, "", msg.Format())
+	} else {
+		return h.ping(ctx, ppfmt, "/fail", msg.Format())
+	}
 }
 
 // Start pings the /start endpoint.
@@ -163,22 +166,19 @@ func (h Healthchecks) Start(ctx context.Context, ppfmt pp.PP, message string) bo
 	return h.ping(ctx, ppfmt, "/start", message)
 }
 
-// Failure pings the /fail endpoint.
-func (h Healthchecks) Failure(ctx context.Context, ppfmt pp.PP, message string) bool {
-	return h.ping(ctx, ppfmt, "/fail", message)
-}
-
-// Log pings the /log endpoint.
-func (h Healthchecks) Log(ctx context.Context, ppfmt pp.PP, message string) bool {
-	return h.ping(ctx, ppfmt, "/log", message)
+// Exit pings the /0 endpoint.
+func (h Healthchecks) Exit(ctx context.Context, ppfmt pp.PP, message string) bool {
+	return h.ping(ctx, ppfmt, "/0", message)
 }
 
-// ExitStatus pings the /number endpoint where number is the exit status.
-func (h Healthchecks) ExitStatus(ctx context.Context, ppfmt pp.PP, code int, message string) bool {
-	if code < 0 || code > 255 {
-		ppfmt.Noticef(pp.EmojiImpossible, "Exit code (%d) not within the range 0-255", code)
-		return false
+// Log formats and logs a [Message].
+func (h Healthchecks) Log(ctx context.Context, ppfmt pp.PP, msg Message) bool {
+	switch {
+	case !msg.OK:
+		return h.ping(ctx, ppfmt, "/fail", msg.Format())
+	case !msg.IsEmpty():
+		return h.ping(ctx, ppfmt, "/log", msg.Format())
+	default:
+		return true
 	}
-
-	return h.ping(ctx, ppfmt, fmt.Sprintf("/%d", code), message)
 }
diff --git a/internal/monitor/message.go b/internal/monitor/message.go
new file mode 100644
index 0000000..4717d98
--- /dev/null
+++ b/internal/monitor/message.go
@@ -0,0 +1,45 @@
+package monitor
+
+import (
+	"fmt"
+	"slices"
+	"strings"
+)
+
+// Message holds the messages and success/failure status for monitors.
+type Message struct {
+	OK    bool
+	Lines []string
+}
+
+// NewMessage creates a new empty Message.
+func NewMessage() Message { return Message{OK: true, Lines: nil} }
+
+// NewMessagef creates a new Message containing one formatted line.
+func NewMessagef(ok bool, format string, args ...any) Message {
+	return Message{OK: ok, Lines: []string{fmt.Sprintf(format, args...)}}
+}
+
+// Format turns the message into a single string.
+func (m Message) Format() string { return strings.Join(m.Lines, "\n") }
+
+// MergeMessages keeps only the ones with highest severity.
+func MergeMessages(msgs ...Message) Message {
+	var (
+		OK    = true
+		Lines = map[bool][][]string{}
+	)
+
+	for _, msg := range msgs {
+		OK = OK && msg.OK
+		Lines[msg.OK] = append(Lines[msg.OK], msg.Lines)
+	}
+
+	return Message{
+		OK:    OK,
+		Lines: slices.Concat(Lines[OK]...),
+	}
+}
+
+// IsEmpty checks if the message is empty.
+func (m Message) IsEmpty() bool { return len(m.Lines) == 0 }
diff --git a/internal/monitor/uptimekuma.go b/internal/monitor/uptimekuma.go
index c18edc0..511ac61 100644
--- a/internal/monitor/uptimekuma.go
+++ b/internal/monitor/uptimekuma.go
@@ -30,7 +30,7 @@ type UptimeKuma struct {
 	Timeout time.Duration
 }
 
-var _ Monitor = UptimeKuma{} //nolint:exhaustruct
+var _ BasicMonitor = UptimeKuma{} //nolint:exhaustruct
 
 const (
 	// UptimeKumaDefaultTimeout is the default timeout for a UptimeKuma ping.
@@ -100,8 +100,8 @@ func NewUptimeKuma(ppfmt pp.PP, rawURL string) (UptimeKuma, bool) {
 }
 
 // Describe calls the callback with the service name "Uptime Kuma".
-func (h UptimeKuma) Describe(callback func(service, params string)) {
-	callback("Uptime Kuma", "(URL redacted)")
+func (h UptimeKuma) Describe(yield func(name, params string) bool) {
+	yield("Uptime Kuma", "(URL redacted)")
 }
 
 // UptimeKumaResponse is for parsing the response from Uptime Kuma.
@@ -155,40 +155,23 @@ func (h UptimeKuma) ping(ctx context.Context, ppfmt pp.PP, param UptimeKumaReque
 	return true
 }
 
-// Success pings the server with status=up. Messages are ignored and "OK" is used instead.
-// The reason is that Uptime Kuma seems to show only the first success message
-// and it could be misleading if an outdated message stays in the UI.
-func (h UptimeKuma) Success(ctx context.Context, ppfmt pp.PP, _message string) bool {
-	return h.ping(ctx, ppfmt, UptimeKumaRequest{Status: "up", Msg: "OK", Ping: ""})
-}
-
-// Start does nothing.
-func (h UptimeKuma) Start(_ctx context.Context, _ppfmt pp.PP, _message string) bool {
-	return true
-}
+// Ping pings the server with status=up/down depending on Message.OK.
+func (h UptimeKuma) Ping(ctx context.Context, ppfmt pp.PP, msg Message) bool {
+	if msg.OK {
+		// Pings the server with status=up. Messages are ignored and "OK" is used instead.
+		// The reason is that Uptime Kuma seems to show only the first success message
+		// and it could be misleading if an outdated message stays in the UI.
+		return h.ping(ctx, ppfmt, UptimeKumaRequest{Status: "up", Msg: "OK", Ping: ""})
+	}
 
-// Failure pings the server with status=down.
-func (h UptimeKuma) Failure(ctx context.Context, ppfmt pp.PP, message string) bool {
-	if message == "" {
+	formatted := msg.Format()
+	if formatted == "" {
 		// If we do not send a non-empty message to Uptime Kuma, it seems to
 		// either keep the previous message (even if it was for success) or
 		// assume the message is "OK". Either is bad.
 		//
 		// We can send a non-empty message to overwrite it.
-		message = "Failing"
-	}
-	return h.ping(ctx, ppfmt, UptimeKumaRequest{Status: "down", Msg: message, Ping: ""})
-}
-
-// Log does nothing.
-func (h UptimeKuma) Log(_ctx context.Context, _ppfmt pp.PP, _message string) bool {
-	return true
-}
-
-// ExitStatus with non-zero triggers [Failure]. Otherwise, it does nothing.
-func (h UptimeKuma) ExitStatus(ctx context.Context, ppfmt pp.PP, code int, message string) bool {
-	if code != 0 {
-		return h.Failure(ctx, ppfmt, message)
+		formatted = "Failing"
 	}
-	return true
+	return h.ping(ctx, ppfmt, UptimeKumaRequest{Status: "down", Msg: formatted, Ping: ""})
 }
diff --git a/internal/notifier/base.go b/internal/notifier/base.go
index 3be1339..4b78301 100644
--- a/internal/notifier/base.go
+++ b/internal/notifier/base.go
@@ -4,7 +4,6 @@ package notifier
 import (
 	"context"
 
-	"github.com/favonia/cloudflare-ddns/internal/message"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
@@ -13,16 +12,8 @@ import (
 // Notifier is an abstract service for push notifications.
 type Notifier interface {
 	// Describe a notifier in a human-readable format by calling callback with service names and params.
-	Describe(callback func(service, params string))
+	Describe(yield func(name, params string) bool)
 
 	// Send out a message.
-	Send(ctx context.Context, ppfmt pp.PP, msg string) bool
-}
-
-// SendMessage formats and sends a [message.Message].
-func SendMessage(ctx context.Context, ppfmt pp.PP, n Notifier, msg message.NotifierMessage) bool {
-	if len(msg) == 0 {
-		return true
-	}
-	return n.Send(ctx, ppfmt, msg.Format())
+	Send(ctx context.Context, ppfmt pp.PP, msg Message) bool
 }
diff --git a/internal/notifier/composite.go b/internal/notifier/composite.go
deleted file mode 100644
index 8ed97e5..0000000
--- a/internal/notifier/composite.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package notifier
-
-import (
-	"context"
-
-	"github.com/favonia/cloudflare-ddns/internal/message"
-	"github.com/favonia/cloudflare-ddns/internal/pp"
-)
-
-// DescribeAll calls [Notifier.Describe] for each monitor in the group with the callback.
-func DescribeAll(callback func(service, params string), ns []Notifier) {
-	for _, n := range ns {
-		n.Describe(callback)
-	}
-}
-
-// SendAll calls [Notifier.Send] for each monitor in the group.
-func SendAll(ctx context.Context, ppfmt pp.PP, ns []Notifier, message string) bool {
-	ok := true
-	for _, n := range ns {
-		if !n.Send(ctx, ppfmt, message) {
-			ok = false
-		}
-	}
-	return ok
-}
-
-// SendMessageAll calls [SendMessage] for each monitor in the group.
-func SendMessageAll(ctx context.Context, ppfmt pp.PP, ns []Notifier, msg message.NotifierMessage) bool {
-	ok := true
-	for _, n := range ns {
-		if !SendMessage(ctx, ppfmt, n, msg) {
-			ok = false
-		}
-	}
-	return ok
-}
diff --git a/internal/notifier/message.go b/internal/notifier/message.go
new file mode 100644
index 0000000..e250bf1
--- /dev/null
+++ b/internal/notifier/message.go
@@ -0,0 +1,29 @@
+package notifier
+
+import (
+	"fmt"
+	"slices"
+	"strings"
+)
+
+// Message holds the messages and success/failure status for notifiers.
+type Message []string
+
+// NewMessage creates a new empty Message.
+func NewMessage() Message { return nil }
+
+// NewMessagef creates a new MonitorMessage containing one formatted string.
+func NewMessagef(format string, args ...any) Message {
+	return Message{fmt.Sprintf(format, args...)}
+}
+
+// MergeMessages keeps only the ones with highest severity.
+func MergeMessages(msgs ...Message) Message {
+	return slices.Concat[Message, string](msgs...)
+}
+
+// Format turns the message into a single string.
+func (m Message) Format() string { return strings.Join(m, " ") }
+
+// IsEmpty checks if the message is empty.
+func (m Message) IsEmpty() bool { return len(m) == 0 }
diff --git a/internal/notifier/shoutrrr.go b/internal/notifier/shoutrrr.go
index fe5debf..d806ef4 100644
--- a/internal/notifier/shoutrrr.go
+++ b/internal/notifier/shoutrrr.go
@@ -2,12 +2,13 @@ package notifier
 
 import (
 	"context"
-	"strconv"
 	"time"
 
 	"github.com/containrrr/shoutrrr"
 	"github.com/containrrr/shoutrrr/pkg/router"
 	"github.com/containrrr/shoutrrr/pkg/types"
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
 
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
@@ -18,7 +19,7 @@ type Shoutrrr struct {
 	Router *router.ServiceRouter
 
 	// The services
-	ServiceNames []string
+	ServiceDescriptions []string
 }
 
 var _ Notifier = Shoutrrr{} //nolint:exhaustruct
@@ -28,6 +29,40 @@ const (
 	ShoutrrrDefaultTimeout = 10 * time.Second
 )
 
+// DescribeShoutrrrService gives a human-readable description for a service.
+func DescribeShoutrrrService(ppfmt pp.PP, proto string) string {
+	name, known := map[string]string{
+		"bark":       "Bark",
+		"discord":    "Discord",
+		"smtp":       "Email",
+		"gotify":     "Gotify",
+		"googlechat": "Google Chat",
+		"ifttt":      "IFTTT",
+		"join":       "Join",
+		"mattermost": "Mattermost",
+		"matrix":     "Matrix",
+		"ntfy":       "Ntfy",
+		"opsgenie":   "OpsGenie",
+		"pushbullet": "Pushbullet",
+		"pushover":   "Pushover",
+		"rocketchat": "Rocketchat",
+		"slack":      "Slack",
+		"teams":      "Teams",
+		"telegram":   "Telegram",
+		"zulip":      "Zulip Chat",
+		"generic":    "Generic",
+	}[proto]
+
+	if known {
+		return name
+	} else {
+		ppfmt.Noticef(pp.EmojiImpossible,
+			"Unknown shoutrrr service name %q; please report it at %s",
+			name, pp.IssueReportingURL)
+		return cases.Title(language.English).String(proto)
+	}
+}
+
 // NewShoutrrr creates a new shoutrrr notifier.
 func NewShoutrrr(ppfmt pp.PP, rawURLs []string) (Shoutrrr, bool) {
 	r, err := shoutrrr.CreateSender(rawURLs...)
@@ -38,25 +73,31 @@ func NewShoutrrr(ppfmt pp.PP, rawURLs []string) (Shoutrrr, bool) {
 
 	r.Timeout = ShoutrrrDefaultTimeout
 
-	serviceNames := make([]string, 0, len(rawURLs))
+	serviceDescriptions := make([]string, 0, len(rawURLs))
 	for _, u := range rawURLs {
 		s, _, _ := r.ExtractServiceName(u)
-		serviceNames = append(serviceNames, s)
+		serviceDescriptions = append(serviceDescriptions, DescribeShoutrrrService(ppfmt, s))
 	}
 
-	return Shoutrrr{Router: r, ServiceNames: serviceNames}, true
+	return Shoutrrr{Router: r, ServiceDescriptions: serviceDescriptions}, true
 }
 
 // Describe calls callback on each registered notification service.
-func (s Shoutrrr) Describe(callback func(service, params string)) {
-	for _, n := range s.ServiceNames {
-		callback(n, "(URL redacted)")
+func (s Shoutrrr) Describe(yield func(string, string) bool) {
+	for _, n := range s.ServiceDescriptions {
+		if !yield(n, "(URL redacted)") {
+			return
+		}
 	}
 }
 
 // Send sents the message msg.
-func (s Shoutrrr) Send(_ context.Context, ppfmt pp.PP, msg string) bool {
-	errs := s.Router.Send(msg, &types.Params{})
+func (s Shoutrrr) Send(_ context.Context, ppfmt pp.PP, msg Message) bool {
+	if msg.IsEmpty() {
+		return true
+	}
+
+	errs := s.Router.Send(msg.Format(), &types.Params{})
 	allOK := true
 	for _, err := range errs {
 		if err != nil {
@@ -65,7 +106,9 @@ func (s Shoutrrr) Send(_ context.Context, ppfmt pp.PP, msg string) bool {
 		}
 	}
 	if allOK {
-		ppfmt.Infof(pp.EmojiNotify, "Notified %s via shoutrrr", pp.EnglishJoinMap(strconv.Quote, s.ServiceNames))
+		ppfmt.Infof(pp.EmojiNotify,
+			"Notified %s via shoutrrr",
+			pp.EnglishJoin(s.ServiceDescriptions))
 	}
 	return allOK
 }
diff --git a/internal/updater/message.go b/internal/updater/message.go
index ec8db34..19cbbc0 100644
--- a/internal/updater/message.go
+++ b/internal/updater/message.go
@@ -8,6 +8,8 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/domain"
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
 	"github.com/favonia/cloudflare-ddns/internal/message"
+	"github.com/favonia/cloudflare-ddns/internal/monitor"
+	"github.com/favonia/cloudflare-ddns/internal/notifier"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 	"github.com/favonia/cloudflare-ddns/internal/setter"
 )
@@ -28,20 +30,20 @@ func generateDetectMessage(ipNet ipnet.Type, ok bool) message.Message {
 		return message.New()
 	case !ok:
 		return message.Message{
-			MonitorMessage: message.MonitorMessage{
+			MonitorMessage: monitor.Message{
 				OK:    false,
 				Lines: []string{fmt.Sprintf("Failed to detect %s address", ipNet.Describe())},
 			},
-			NotifierMessage: message.NotifierMessage{
+			NotifierMessage: notifier.Message{
 				fmt.Sprintf("Failed to detect the %s address.", ipNet.Describe()),
 			},
 		}
 	}
 }
 
-func generateUpdateMonitorMessage(ipNet ipnet.Type, ip netip.Addr, s setterResponses) message.MonitorMessage {
+func generateUpdateMonitorMessage(ipNet ipnet.Type, ip netip.Addr, s setterResponses) monitor.Message {
 	if domains := s[setter.ResponseFailed]; len(domains) > 0 {
-		return message.MonitorMessage{
+		return monitor.Message{
 			OK: false,
 			Lines: []string{fmt.Sprintf(
 				"Failed to set %s (%s) of %s",
@@ -66,10 +68,10 @@ func generateUpdateMonitorMessage(ipNet ipnet.Type, ip netip.Addr, s setterRespo
 		))
 	}
 
-	return message.MonitorMessage{OK: true, Lines: successLines}
+	return monitor.Message{OK: true, Lines: successLines}
 }
 
-func generateUpdateNotifierMessage(ipNet ipnet.Type, ip netip.Addr, s setterResponses) message.NotifierMessage {
+func generateUpdateNotifierMessage(ipNet ipnet.Type, ip netip.Addr, s setterResponses) notifier.Message {
 	var fragments []string
 
 	if domains := s[setter.ResponseFailed]; len(domains) > 0 {
@@ -106,7 +108,7 @@ func generateUpdateNotifierMessage(ipNet ipnet.Type, ip netip.Addr, s setterResp
 		return nil
 	} else {
 		fragments = append(fragments, ".")
-		return message.NotifierMessage{strings.Join(fragments, "")}
+		return notifier.Message{strings.Join(fragments, "")}
 	}
 }
 
@@ -117,9 +119,9 @@ func generateUpdateMessage(ipNet ipnet.Type, ip netip.Addr, s setterResponses) m
 	}
 }
 
-func generateDeleteMonitorMessage(ipNet ipnet.Type, s setterResponses) message.MonitorMessage {
+func generateDeleteMonitorMessage(ipNet ipnet.Type, s setterResponses) monitor.Message {
 	if domains := s[setter.ResponseFailed]; len(domains) > 0 {
-		return message.MonitorMessage{
+		return monitor.Message{
 			OK: false,
 			Lines: []string{fmt.Sprintf(
 				"Failed to delete %s of %s",
@@ -144,10 +146,10 @@ func generateDeleteMonitorMessage(ipNet ipnet.Type, s setterResponses) message.M
 		))
 	}
 
-	return message.MonitorMessage{OK: true, Lines: successLines}
+	return monitor.Message{OK: true, Lines: successLines}
 }
 
-func generateDeleteNotifierMessage(ipNet ipnet.Type, s setterResponses) message.NotifierMessage {
+func generateDeleteNotifierMessage(ipNet ipnet.Type, s setterResponses) notifier.Message {
 	var fragments []string
 
 	if domains := s[setter.ResponseFailed]; len(domains) > 0 {
@@ -184,7 +186,7 @@ func generateDeleteNotifierMessage(ipNet ipnet.Type, s setterResponses) message.
 		return nil
 	} else {
 		fragments = append(fragments, ".")
-		return message.NotifierMessage{strings.Join(fragments, "")}
+		return notifier.Message{strings.Join(fragments, "")}
 	}
 }
 
diff --git a/internal/updater/message_waf.go b/internal/updater/message_waf.go
index 31e92d5..1808137 100644
--- a/internal/updater/message_waf.go
+++ b/internal/updater/message_waf.go
@@ -4,6 +4,8 @@ import (
 	"strings"
 
 	"github.com/favonia/cloudflare-ddns/internal/message"
+	"github.com/favonia/cloudflare-ddns/internal/monitor"
+	"github.com/favonia/cloudflare-ddns/internal/notifier"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 	"github.com/favonia/cloudflare-ddns/internal/setter"
 )
@@ -18,9 +20,9 @@ func (s setterWAFListResponses) register(name string, code setter.ResponseCode)
 	s[code] = append(s[code], name)
 }
 
-func generateUpdateWAFListsMonitorMessage(s setterWAFListResponses) message.MonitorMessage {
+func generateUpdateWAFListsMonitorMessage(s setterWAFListResponses) monitor.Message {
 	if domains := s[setter.ResponseFailed]; len(domains) > 0 {
-		return message.MonitorMessage{
+		return monitor.Message{
 			OK:    false,
 			Lines: []string{"Failed to set list(s) " + pp.Join(domains)},
 		}
@@ -36,10 +38,10 @@ func generateUpdateWAFListsMonitorMessage(s setterWAFListResponses) message.Moni
 		successLines = append(successLines, "Set list(s) "+pp.Join(domains))
 	}
 
-	return message.MonitorMessage{OK: true, Lines: successLines}
+	return monitor.Message{OK: true, Lines: successLines}
 }
 
-func generateUpdateWAFListsNotifierMessage(s setterWAFListResponses) message.NotifierMessage {
+func generateUpdateWAFListsNotifierMessage(s setterWAFListResponses) notifier.Message {
 	var fragments []string
 
 	if domains := s[setter.ResponseFailed]; len(domains) > 0 {
@@ -66,7 +68,7 @@ func generateUpdateWAFListsNotifierMessage(s setterWAFListResponses) message.Not
 		return nil
 	} else {
 		fragments = append(fragments, ".")
-		return message.NotifierMessage{strings.Join(fragments, "")}
+		return notifier.Message{strings.Join(fragments, "")}
 	}
 }
 
@@ -77,9 +79,9 @@ func generateUpdateWAFListsMessage(s setterWAFListResponses) message.Message {
 	}
 }
 
-func generateClearWAFListsMonitorMessage(s setterWAFListResponses) message.MonitorMessage {
+func generateClearWAFListsMonitorMessage(s setterWAFListResponses) monitor.Message {
 	if domains := s[setter.ResponseFailed]; len(domains) > 0 {
-		return message.MonitorMessage{
+		return monitor.Message{
 			OK:    false,
 			Lines: []string{"Failed to clear list(s) " + pp.Join(domains)},
 		}
@@ -95,10 +97,10 @@ func generateClearWAFListsMonitorMessage(s setterWAFListResponses) message.Monit
 		successLines = append(successLines, "Cleared list(s) "+pp.Join(domains))
 	}
 
-	return message.MonitorMessage{OK: true, Lines: successLines}
+	return monitor.Message{OK: true, Lines: successLines}
 }
 
-func generateClearWAFListsNotifierMessage(s setterWAFListResponses) message.NotifierMessage {
+func generateClearWAFListsNotifierMessage(s setterWAFListResponses) notifier.Message {
 	var fragments []string
 
 	if domains := s[setter.ResponseFailed]; len(domains) > 0 {
@@ -125,7 +127,7 @@ func generateClearWAFListsNotifierMessage(s setterWAFListResponses) message.Noti
 		return nil
 	} else {
 		fragments = append(fragments, ".")
-		return message.NotifierMessage{strings.Join(fragments, "")}
+		return notifier.Message{strings.Join(fragments, "")}
 	}
 }
 
diff --git a/internal/updater/updater.go b/internal/updater/updater.go
index 40e80d6..e02fb7a 100644
--- a/internal/updater/updater.go
+++ b/internal/updater/updater.go
@@ -164,7 +164,7 @@ func UpdateIPs(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Sett
 
 			// Note: If we can't detect the new IP address,
 			// it's probably better to leave existing records alone.
-			if msg.OK {
+			if msg.MonitorMessage.OK {
 				numValidIPs++
 				msgs = append(msgs, setIP(ctx, ppfmt, c, s, ipNet, ip))
 			}


Test patch:
diff --git a/internal/config/config_print_test.go b/internal/config/config_print_test.go
index d3ae178..a703b52 100644
--- a/internal/config/config_print_test.go
+++ b/internal/config/config_print_test.go
@@ -9,8 +9,6 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/domain"
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
 	"github.com/favonia/cloudflare-ddns/internal/mocks"
-	"github.com/favonia/cloudflare-ddns/internal/monitor"
-	"github.com/favonia/cloudflare-ddns/internal/notifier"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
@@ -114,17 +112,17 @@ func TestPrintValues(t *testing.T) {
 
 	m := mocks.NewMockMonitor(mockCtrl)
 	m.EXPECT().Describe(gomock.Any()).
-		DoAndReturn(func(f func(string, string)) {
+		DoAndReturn(func(f func(string, string) bool) {
 			f("Meow", "purrrr")
 		}).AnyTimes()
-	c.Monitors = []monitor.Monitor{m}
+	c.Monitor = m
 
 	n := mocks.NewMockNotifier(mockCtrl)
 	n.EXPECT().Describe(gomock.Any()).
-		DoAndReturn(func(f func(string, string)) {
+		DoAndReturn(func(f func(string, string) bool) {
 			f("Snake", "hissss")
 		}).AnyTimes()
-	c.Notifiers = []notifier.Notifier{n}
+	c.Notifier = n
 
 	c.Print(mockPP)
 }
diff --git a/internal/config/env_monitor_test.go b/internal/config/env_monitor_test.go
index c47e974..16e9cd5 100644
--- a/internal/config/env_monitor_test.go
+++ b/internal/config/env_monitor_test.go
@@ -21,8 +21,8 @@ func TestReadAndAppendHealthchecksURL(t *testing.T) {
 	for name, tc := range map[string]struct {
 		set           bool
 		val           string
-		oldField      []mon
-		newField      []mon
+		oldField      mon
+		newField      mon
 		ok            bool
 		prepareMockPP func(*mocks.MockPP)
 	}{
@@ -35,30 +35,30 @@ func TestReadAndAppendHealthchecksURL(t *testing.T) {
 		"example": {
 			true, "https://hi.org/1234",
 			nil,
-			[]mon{monitor.Healthchecks{
+			monitor.NewComposed(monitor.Healthchecks{
 				BaseURL: urlMustParse(t, "https://hi.org/1234"),
 				Timeout: monitor.HealthchecksDefaultTimeout,
-			}},
+			}),
 			true,
 			nil,
 		},
 		"password": {
 			true, "https://me:pass@hi.org/1234",
 			nil,
-			[]mon{monitor.Healthchecks{
+			monitor.NewComposed(monitor.Healthchecks{
 				BaseURL: urlMustParse(t, "https://me:pass@hi.org/1234"),
 				Timeout: monitor.HealthchecksDefaultTimeout,
-			}},
+			}),
 			true,
 			nil,
 		},
 		"fragment": {
 			true, "https://hi.org/1234#fragment",
 			nil,
-			[]mon{monitor.Healthchecks{
+			monitor.NewComposed(monitor.Healthchecks{
 				BaseURL: urlMustParse(t, "https://hi.org/1234#fragment"),
 				Timeout: monitor.HealthchecksDefaultTimeout,
-			}},
+			}),
 			true,
 			nil,
 		},
@@ -118,8 +118,8 @@ func TestReadAndAppendUptimeKumaURL(t *testing.T) {
 	for name, tc := range map[string]struct {
 		set           bool
 		val           string
-		oldField      []mon
-		newField      []mon
+		oldField      mon
+		newField      mon
 		ok            bool
 		prepareMockPP func(*mocks.MockPP)
 	}{
@@ -132,40 +132,40 @@ func TestReadAndAppendUptimeKumaURL(t *testing.T) {
 		"example": {
 			true, "https://hi.org/1234",
 			nil,
-			[]mon{monitor.UptimeKuma{
+			monitor.NewComposed(monitor.UptimeKuma{
 				BaseURL: urlMustParse(t, "https://hi.org/1234"),
 				Timeout: monitor.UptimeKumaDefaultTimeout,
-			}},
+			}),
 			true,
 			nil,
 		},
 		"password": {
 			true, "https://me:pass@hi.org/1234",
 			nil,
-			[]mon{monitor.UptimeKuma{
+			monitor.NewComposed(monitor.UptimeKuma{
 				BaseURL: urlMustParse(t, "https://me:pass@hi.org/1234"),
 				Timeout: monitor.UptimeKumaDefaultTimeout,
-			}},
+			}),
 			true,
 			nil,
 		},
 		"fragment": {
 			true, "https://hi.org/1234#fragment",
 			nil,
-			[]mon{monitor.UptimeKuma{
+			monitor.NewComposed(monitor.UptimeKuma{
 				BaseURL: urlMustParse(t, "https://hi.org/1234#fragment"),
 				Timeout: monitor.UptimeKumaDefaultTimeout,
-			}},
+			}),
 			true,
 			nil,
 		},
 		"query": {
 			true, "https://hi.org/1234?hello=123",
 			nil,
-			[]mon{monitor.UptimeKuma{
+			monitor.NewComposed(monitor.UptimeKuma{
 				BaseURL: urlMustParse(t, "https://hi.org/1234"),
 				Timeout: monitor.UptimeKumaDefaultTimeout,
-			}},
+			}),
 			true,
 			func(m *mocks.MockPP) {
 				m.EXPECT().Noticef(pp.EmojiUserError,
diff --git a/internal/config/env_notifier_test.go b/internal/config/env_notifier_test.go
index 751634b..6002c90 100644
--- a/internal/config/env_notifier_test.go
+++ b/internal/config/env_notifier_test.go
@@ -21,37 +21,35 @@ func TestReadAndAppendShoutrrrURL(t *testing.T) {
 	for name, tc := range map[string]struct {
 		set           bool
 		val           string
-		oldField      []not
-		newField      func(*testing.T, []not)
+		oldField      not
+		newField      func(*testing.T, not)
 		ok            bool
 		prepareMockPP func(*mocks.MockPP)
 	}{
 		"unset": {
 			false, "", nil,
-			func(t *testing.T, ns []not) {
+			func(t *testing.T, n not) {
 				t.Helper()
-				require.Nil(t, ns)
+				require.Nil(t, n)
 			},
 			true, nil,
 		},
 		"empty": {
 			true, "", nil,
-			func(t *testing.T, ns []not) {
+			func(t *testing.T, n not) {
 				t.Helper()
-				require.Nil(t, ns)
+				require.Nil(t, n)
 			},
 			true, nil,
 		},
 		"generic": {
 			true, "generic+https://example.com/api/v1/postStuff",
 			nil,
-			func(t *testing.T, ns []not) {
+			func(t *testing.T, n not) {
 				t.Helper()
-				require.Len(t, ns, 1)
-				m := ns[0]
-				s, ok := m.(notifier.Shoutrrr)
+				s, ok := n.(notifier.Shoutrrr)
 				require.True(t, ok)
-				require.Equal(t, []string{"generic"}, s.ServiceNames)
+				require.Equal(t, []string{"Generic"}, s.ServiceDescriptions)
 			},
 			true,
 			nil,
@@ -59,9 +57,9 @@ func TestReadAndAppendShoutrrrURL(t *testing.T) {
 		"ill-formed": {
 			true, "meow-meow-meow://cute",
 			nil,
-			func(t *testing.T, ns []not) {
+			func(t *testing.T, n not) {
 				t.Helper()
-				require.Nil(t, ns)
+				require.Nil(t, n)
 			},
 			false,
 			func(m *mocks.MockPP) {
@@ -71,13 +69,11 @@ func TestReadAndAppendShoutrrrURL(t *testing.T) {
 		"multiple": {
 			true, "generic+https://example.com/api/v1/postStuff\npushover://shoutrrr:token@userKey",
 			nil,
-			func(t *testing.T, ns []not) {
+			func(t *testing.T, n not) {
 				t.Helper()
-				require.Len(t, ns, 1)
-				m := ns[0]
-				s, ok := m.(notifier.Shoutrrr)
+				s, ok := n.(notifier.Shoutrrr)
 				require.True(t, ok)
-				require.Equal(t, []string{"generic", "pushover"}, s.ServiceNames)
+				require.Equal(t, []string{"Generic", "Pushover"}, s.ServiceDescriptions)
 			},
 			true,
 			nil,
diff --git a/internal/monitor/composite_test.go b/internal/monitor/composite_test.go
index a35069d..5432c60 100644
--- a/internal/monitor/composite_test.go
+++ b/internal/monitor/composite_test.go
@@ -3,113 +3,113 @@ package monitor_test
 import (
 	"context"
 	"fmt"
-	"strings"
 	"testing"
 
+	"github.com/stretchr/testify/require"
 	"go.uber.org/mock/gomock"
 
-	"github.com/favonia/cloudflare-ddns/internal/message"
 	"github.com/favonia/cloudflare-ddns/internal/mocks"
 	"github.com/favonia/cloudflare-ddns/internal/monitor"
 )
 
-func TestDescribeAll(t *testing.T) {
+func TestComposedDescribe(t *testing.T) {
 	t.Parallel()
 
-	ms := make([]monitor.Monitor, 0, 5)
-
 	mockCtrl := gomock.NewController(t)
 
-	for range 5 {
+	ms1 := make([]monitor.BasicMonitor, 0, 5)
+	for range 3 {
 		m := mocks.NewMockMonitor(mockCtrl)
-		m.EXPECT().Describe(gomock.Any())
-		ms = append(ms, m)
+		m.EXPECT().Describe(gomock.Any()).DoAndReturn(
+			func(yield func(string, string) bool) {
+				yield("name", "params")
+			},
+		)
+		ms1 = append(ms1, m)
 	}
-
-	callback := func(_service, _params string) { /* the callback content is not relevant here. */ }
-	monitor.DescribeAll(callback, ms)
-}
-
-func TestSuccessAll(t *testing.T) {
-	t.Parallel()
-
-	ms := make([]monitor.Monitor, 0, 5)
-
-	mockCtrl := gomock.NewController(t)
-	mockPP := mocks.NewMockPP(mockCtrl)
-
-	message := "aloha"
-
-	for range 5 {
+	ms2 := make([]monitor.BasicMonitor, 0, 5)
+	for range 2 {
 		m := mocks.NewMockMonitor(mockCtrl)
-		m.EXPECT().Success(context.Background(), mockPP, message)
-		ms = append(ms, m)
+		ms2 = append(ms2, m)
 	}
+	c := monitor.NewComposed(monitor.NewComposed(ms1...), monitor.NewComposed(ms2...))
 
-	monitor.SuccessAll(context.Background(), mockPP, ms, message)
-}
-
-func TestStartAll(t *testing.T) {
-	t.Parallel()
-
-	ms := make([]monitor.Monitor, 0, 5)
-
-	mockCtrl := gomock.NewController(t)
-	mockPP := mocks.NewMockPP(mockCtrl)
-
-	message := "你好"
-
-	for range 5 {
-		m := mocks.NewMockMonitor(mockCtrl)
-		m.EXPECT().Start(context.Background(), mockPP, message)
-		ms = append(ms, m)
+	count := 0
+	for range c.Describe {
+		count++
+		if count >= 3 {
+			break
+		}
 	}
-
-	monitor.StartAll(context.Background(), mockPP, ms, message)
+	require.Equal(t, 3, count)
 }
 
-func TestFailureAll(t *testing.T) {
+func TestComposedPing(t *testing.T) {
 	t.Parallel()
 
-	ms := make([]monitor.Monitor, 0, 5)
+	for name1, tc1 := range map[string]struct {
+		lines []string
+	}{
+		"nil":   {nil},
+		"empty": {[]string{}},
+		"one":   {[]string{"hi"}},
+		"two":   {[]string{"hi", "hey"}},
+	} {
+		for name2, tc2 := range map[string]struct {
+			ok bool
+		}{
+			"ok":     {true},
+			"not-ok": {false},
+		} {
+			t.Run(fmt.Sprintf("%s/%s", name1, name2), func(t *testing.T) {
+				t.Parallel()
 
-	mockCtrl := gomock.NewController(t)
-	mockPP := mocks.NewMockPP(mockCtrl)
+				ms := make([]monitor.BasicMonitor, 0, 5)
+				mockCtrl := gomock.NewController(t)
+				mockPP := mocks.NewMockPP(mockCtrl)
 
-	message := "secret"
+				msg := monitor.Message{
+					OK:    tc2.ok,
+					Lines: tc1.lines,
+				}
 
-	for range 5 {
-		m := mocks.NewMockMonitor(mockCtrl)
-		m.EXPECT().Failure(context.Background(), mockPP, message)
-		ms = append(ms, m)
-	}
+				for range 5 {
+					m := mocks.NewMockMonitor(mockCtrl)
+					m.EXPECT().Ping(context.Background(), mockPP, msg).Return(true)
+					ms = append(ms, m)
+				}
 
-	monitor.FailureAll(context.Background(), mockPP, ms, message)
+				ok := monitor.NewComposed(ms...).Ping(context.Background(), mockPP, msg)
+				require.True(t, ok)
+			})
+		}
+	}
 }
 
-func TestLogAll(t *testing.T) {
+func TestComposedStart(t *testing.T) {
 	t.Parallel()
 
-	ms := make([]monitor.Monitor, 0, 5)
+	ms := make([]monitor.BasicMonitor, 0, 5)
 
 	mockCtrl := gomock.NewController(t)
 	mockPP := mocks.NewMockPP(mockCtrl)
 
-	message := "forest"
+	message := "你好"
 
 	for range 5 {
 		m := mocks.NewMockMonitor(mockCtrl)
-		m.EXPECT().Log(context.Background(), mockPP, message)
+		m.EXPECT().Start(context.Background(), mockPP, message).Return(true)
 		ms = append(ms, m)
 	}
 
-	monitor.LogAll(context.Background(), mockPP, ms, message)
+	ok := monitor.NewComposed(ms...).Start(context.Background(), mockPP, message)
+	require.True(t, ok)
 }
 
-func TestExitStatusAll(t *testing.T) {
+func TestComposedExit(t *testing.T) {
 	t.Parallel()
 
-	ms := make([]monitor.Monitor, 0, 5)
+	ms := make([]monitor.BasicMonitor, 0, 5)
 
 	mockCtrl := gomock.NewController(t)
 	mockPP := mocks.NewMockPP(mockCtrl)
@@ -118,14 +118,15 @@ func TestExitStatusAll(t *testing.T) {
 
 	for range 5 {
 		m := mocks.NewMockMonitor(mockCtrl)
-		m.EXPECT().ExitStatus(context.Background(), mockPP, 42, message)
+		m.EXPECT().Exit(context.Background(), mockPP, message).Return(true)
 		ms = append(ms, m)
 	}
 
-	monitor.ExitStatusAll(context.Background(), mockPP, ms, 42, message)
+	ok := monitor.NewComposed(ms...).Exit(context.Background(), mockPP, message)
+	require.True(t, ok)
 }
 
-func TestPingMessageAll(t *testing.T) {
+func TestComposedLog(t *testing.T) {
 	t.Parallel()
 
 	for name1, tc1 := range map[string]struct {
@@ -136,84 +137,39 @@ func TestPingMessageAll(t *testing.T) {
 		"one":   {[]string{"hi"}},
 		"two":   {[]string{"hi", "hey"}},
 	} {
-		monitorMessage := strings.Join(tc1.lines, "\n")
-
 		for name2, tc2 := range map[string]struct {
 			ok bool
 		}{
-			"ok":    {true},
-			"notok": {false},
+			"ok":     {true},
+			"not-ok": {false},
 		} {
 			t.Run(fmt.Sprintf("%s/%s", name1, name2), func(t *testing.T) {
 				t.Parallel()
 
-				ms := make([]monitor.Monitor, 0, 5)
+				ms := make([]monitor.BasicMonitor, 0, 5)
 				mockCtrl := gomock.NewController(t)
 				mockPP := mocks.NewMockPP(mockCtrl)
 
-				for range 5 {
-					m := mocks.NewMockMonitor(mockCtrl)
-					if tc2.ok {
-						m.EXPECT().Success(context.Background(), mockPP, monitorMessage)
-					} else {
-						m.EXPECT().Failure(context.Background(), mockPP, monitorMessage)
-					}
-					ms = append(ms, m)
-				}
-
-				msg := message.MonitorMessage{
+				msg := monitor.Message{
 					OK:    tc2.ok,
 					Lines: tc1.lines,
 				}
-				monitor.PingMessageAll(context.Background(), mockPP, ms, msg)
-			})
-		}
-	}
-}
-
-func TestLogMessageAll(t *testing.T) {
-	t.Parallel()
 
-	for name1, tc1 := range map[string]struct {
-		lines []string
-	}{
-		"nil":   {nil},
-		"empty": {[]string{}},
-		"one":   {[]string{"hi"}},
-		"two":   {[]string{"hi", "hey"}},
-	} {
-		monitorMessage := strings.Join(tc1.lines, "\n")
-
-		for name2, tc2 := range map[string]struct {
-			ok bool
-		}{
-			"ok":    {true},
-			"notok": {false},
-		} {
-			t.Run(fmt.Sprintf("%s/%s", name1, name2), func(t *testing.T) {
-				t.Parallel()
-
-				ms := make([]monitor.Monitor, 0, 5)
-				mockCtrl := gomock.NewController(t)
-				mockPP := mocks.NewMockPP(mockCtrl)
-
-				for range 5 {
+				for range 3 {
 					m := mocks.NewMockMonitor(mockCtrl)
-					switch {
-					case tc2.ok && len(monitorMessage) > 0:
-						m.EXPECT().Log(context.Background(), mockPP, monitorMessage)
-					case tc2.ok:
-					default: // !tc.ok
-						m.EXPECT().Failure(context.Background(), mockPP, monitorMessage)
+					m.EXPECT().Log(context.Background(), mockPP, msg).Return(true)
+					ms = append(ms, m)
+				}
+				for range 2 {
+					m := mocks.NewMockBasicMonitor(mockCtrl)
+					if !tc2.ok {
+						m.EXPECT().Ping(context.Background(), mockPP, msg).Return(true)
 					}
 					ms = append(ms, m)
 				}
 
-				msg := message.MonitorMessage{
-					OK:    tc2.ok,
-					Lines: tc1.lines,
-				}
-				monitor.LogMessageAll(context.Background(), mockPP, ms, msg)
+				ok := monitor.NewComposed(ms...).Log(context.Background(), mockPP, msg)
+				require.True(t, ok)
 			})
 		}
 	}
diff --git a/internal/monitor/healthchecks_test.go b/internal/monitor/healthchecks_test.go
index 8ec7761..ad17581 100644
--- a/internal/monitor/healthchecks_test.go
+++ b/internal/monitor/healthchecks_test.go
@@ -34,7 +34,7 @@ func TestNewHealthchecks(t *testing.T) {
 	require.True(t, ok)
 }
 
-func TestHealthchecksNewHealthchecksFail1(t *testing.T) {
+func TestNewHealthchecksFail1(t *testing.T) {
 	t.Parallel()
 
 	mockCtrl := gomock.NewController(t)
@@ -47,7 +47,7 @@ func TestHealthchecksNewHealthchecksFail1(t *testing.T) {
 	require.False(t, ok)
 }
 
-func TestHealthchecksNewHealthchecksFail2(t *testing.T) {
+func TestNewHealthchecksFail2(t *testing.T) {
 	t.Parallel()
 
 	mockCtrl := gomock.NewController(t)
@@ -60,7 +60,7 @@ func TestHealthchecksNewHealthchecksFail2(t *testing.T) {
 	require.False(t, ok)
 }
 
-func TestHealthchecksNewHealthchecksFail3(t *testing.T) {
+func TestNewHealthchecksFail3(t *testing.T) {
 	t.Parallel()
 
 	mockCtrl := gomock.NewController(t)
@@ -70,16 +70,21 @@ func TestHealthchecksNewHealthchecksFail3(t *testing.T) {
 	require.False(t, ok)
 }
 
-func TestHealthchecksDescripbe(t *testing.T) {
+func TestHealthchecksDescribe(t *testing.T) {
 	t.Parallel()
 
 	mockCtrl := gomock.NewController(t)
 	mockPP := mocks.NewMockPP(mockCtrl)
+
 	m, ok := monitor.NewHealthchecks(mockPP, "https://user:pass@host/path")
 	require.True(t, ok)
-	m.Describe(func(service, _params string) {
-		require.Equal(t, "Healthchecks", service)
-	})
+
+	count := 0
+	for name := range m.Describe {
+		count++
+		require.Equal(t, "Healthchecks", name)
+	}
+	require.Equal(t, 1, count)
 }
 
 //nolint:funlen
@@ -100,18 +105,18 @@ func TestHealthchecksEndPoints(t *testing.T) {
 		message       string
 		actions       []action
 		defaultAction action
-		pinged        bool
+		pinged        int
 		ok            bool
 		prepareMockPP func(*mocks.MockPP)
 	}{
 		"success": {
 			func(ppfmt pp.PP, m monitor.Monitor) bool {
-				return m.Success(context.Background(), ppfmt, "hello")
+				return m.Ping(context.Background(), ppfmt, monitor.NewMessagef(true, "hello"))
 			},
 			"/", "hello",
 			[]action{ActionAbort, ActionAbort, ActionOK},
-			ActionAbort,
-			true, true,
+			ActionAbort, 1,
+			true,
 			func(m *mocks.MockPP) {
 				gomock.InOrder(
 					m.EXPECT().Noticef(pp.EmojiUserWarning, "The Healthchecks URL (redacted) uses HTTP; please consider using HTTPS"),
@@ -119,14 +124,14 @@ func TestHealthchecksEndPoints(t *testing.T) {
 				)
 			},
 		},
-		"success/notok": {
+		"success/not-ok": {
 			func(ppfmt pp.PP, m monitor.Monitor) bool {
-				return m.Success(context.Background(), ppfmt, "aloha")
+				return m.Ping(context.Background(), ppfmt, monitor.NewMessagef(true, "aloha"))
 			},
 			"/", "aloha",
 			[]action{ActionAbort, ActionAbort, ActionNotOK},
-			ActionAbort,
-			false, false,
+			ActionAbort, 0,
+			false,
 			func(m *mocks.MockPP) {
 				gomock.InOrder(
 					m.EXPECT().Noticef(pp.EmojiUserWarning, "The Healthchecks URL (redacted) uses HTTP; please consider using HTTPS"),
@@ -136,12 +141,11 @@ func TestHealthchecksEndPoints(t *testing.T) {
 		},
 		"success/abort/all": {
 			func(ppfmt pp.PP, m monitor.Monitor) bool {
-				return m.Success(context.Background(), ppfmt, "stop now")
+				return m.Ping(context.Background(), ppfmt, monitor.NewMessagef(true, "stop now"))
 			},
 			"/", "stop now",
-			nil,
-			ActionAbort,
-			false, false,
+			nil, ActionAbort, 0,
+			false,
 			func(m *mocks.MockPP) {
 				gomock.InOrder(
 					m.EXPECT().Noticef(pp.EmojiUserWarning, "The Healthchecks URL (redacted) uses HTTP; please consider using HTTPS"),
@@ -149,29 +153,14 @@ func TestHealthchecksEndPoints(t *testing.T) {
 				)
 			},
 		},
-		"start": {
-			func(ppfmt pp.PP, m monitor.Monitor) bool {
-				return m.Start(context.Background(), ppfmt, "starting now!")
-			},
-			"/start", "starting now!",
-			[]action{ActionAbort, ActionAbort, ActionOK},
-			ActionAbort,
-			true, true,
-			func(m *mocks.MockPP) {
-				gomock.InOrder(
-					m.EXPECT().Noticef(pp.EmojiUserWarning, "The Healthchecks URL (redacted) uses HTTP; please consider using HTTPS"),
-					m.EXPECT().Infof(pp.EmojiPing, "Pinged the %s endpoint of Healthchecks", `"/start"`),
-				)
-			},
-		},
 		"failure": {
 			func(ppfmt pp.PP, m monitor.Monitor) bool {
-				return m.Failure(context.Background(), ppfmt, "something's wrong")
+				return m.Ping(context.Background(), ppfmt, monitor.NewMessagef(false, "something's wrong"))
 			},
 			"/fail", "something's wrong",
 			[]action{ActionAbort, ActionAbort, ActionOK},
-			ActionAbort,
-			true, true,
+			ActionAbort, 1,
+			true,
 			func(m *mocks.MockPP) {
 				gomock.InOrder(
 					m.EXPECT().Noticef(pp.EmojiUserWarning, "The Healthchecks URL (redacted) uses HTTP; please consider using HTTPS"),
@@ -179,29 +168,29 @@ func TestHealthchecksEndPoints(t *testing.T) {
 				)
 			},
 		},
-		"log": {
+		"start": {
 			func(ppfmt pp.PP, m monitor.Monitor) bool {
-				return m.Log(context.Background(), ppfmt, "message")
+				return m.Start(context.Background(), ppfmt, "starting now!")
 			},
-			"/log", "message",
+			"/start", "starting now!",
 			[]action{ActionAbort, ActionAbort, ActionOK},
-			ActionAbort,
-			true, true,
+			ActionAbort, 1,
+			true,
 			func(m *mocks.MockPP) {
 				gomock.InOrder(
 					m.EXPECT().Noticef(pp.EmojiUserWarning, "The Healthchecks URL (redacted) uses HTTP; please consider using HTTPS"),
-					m.EXPECT().Infof(pp.EmojiPing, "Pinged the %s endpoint of Healthchecks", `"/log"`),
+					m.EXPECT().Infof(pp.EmojiPing, "Pinged the %s endpoint of Healthchecks", `"/start"`),
 				)
 			},
 		},
-		"exitstatus/0": {
+		"exits": {
 			func(ppfmt pp.PP, m monitor.Monitor) bool {
-				return m.ExitStatus(context.Background(), ppfmt, 0, "bye!")
+				return m.Exit(context.Background(), ppfmt, "bye!")
 			},
 			"/0", "bye!",
 			[]action{ActionAbort, ActionAbort, ActionOK},
-			ActionAbort,
-			true, true,
+			ActionAbort, 1,
+			true,
 			func(m *mocks.MockPP) {
 				gomock.InOrder(
 					m.EXPECT().Noticef(pp.EmojiUserWarning, "The Healthchecks URL (redacted) uses HTTP; please consider using HTTPS"),
@@ -209,35 +198,48 @@ func TestHealthchecksEndPoints(t *testing.T) {
 				)
 			},
 		},
-		"exitstatus/1": {
+		"log": {
 			func(ppfmt pp.PP, m monitor.Monitor) bool {
-				return m.ExitStatus(context.Background(), ppfmt, 1, "did exit(1)")
+				return m.Log(context.Background(), ppfmt, monitor.NewMessagef(true, "message"))
 			},
-			"/1", "did exit(1)",
+			"/log", "message",
 			[]action{ActionAbort, ActionAbort, ActionOK},
-			ActionAbort,
-			true, true,
+			ActionAbort, 1,
+			true,
 			func(m *mocks.MockPP) {
 				gomock.InOrder(
 					m.EXPECT().Noticef(pp.EmojiUserWarning, "The Healthchecks URL (redacted) uses HTTP; please consider using HTTPS"),
-					m.EXPECT().Infof(pp.EmojiPing, "Pinged the %s endpoint of Healthchecks", `"/1"`),
+					m.EXPECT().Infof(pp.EmojiPing, "Pinged the %s endpoint of Healthchecks", `"/log"`),
 				)
 			},
 		},
-		"exitstatus/-1": {
+		"log/not-ok": {
 			func(ppfmt pp.PP, m monitor.Monitor) bool {
-				return m.ExitStatus(context.Background(), ppfmt, -1, "feeling negative")
+				return m.Log(context.Background(), ppfmt, monitor.NewMessagef(false, "oops!"))
 			},
-			"", "feeling negative",
-			nil, ActionAbort,
-			false, false,
+			"/fail", "oops!",
+			[]action{ActionOK},
+			ActionAbort, 1,
+			true,
 			func(m *mocks.MockPP) {
 				gomock.InOrder(
 					m.EXPECT().Noticef(pp.EmojiUserWarning, "The Healthchecks URL (redacted) uses HTTP; please consider using HTTPS"),
-					m.EXPECT().Noticef(pp.EmojiImpossible, "Exit code (%d) not within the range 0-255", -1),
+					m.EXPECT().Infof(pp.EmojiPing, "Pinged the %s endpoint of Healthchecks", `"/fail"`),
 				)
 			},
 		},
+		"log/empty": {
+			func(ppfmt pp.PP, m monitor.Monitor) bool {
+				return m.Log(context.Background(), ppfmt, monitor.NewMessage())
+			},
+			"/log", "message",
+			[]action{},
+			ActionAbort, 0,
+			true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(pp.EmojiUserWarning, "The Healthchecks URL (redacted) uses HTTP; please consider using HTTPS")
+			},
+		},
 	} {
 		t.Run(name, func(t *testing.T) {
 			t.Parallel()
@@ -248,7 +250,7 @@ func TestHealthchecksEndPoints(t *testing.T) {
 			}
 
 			visited := 0
-			pinged := false
+			pinged := 0
 			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 				if !assert.Equal(t, http.MethodPost, r.Method) ||
 					!assert.Equal(t, tc.url, r.URL.EscapedPath()) {
@@ -267,7 +269,7 @@ func TestHealthchecksEndPoints(t *testing.T) {
 				}
 				switch action {
 				case ActionOK:
-					pinged = true
+					pinged++
 					if _, err := io.WriteString(w, "OK"); !assert.NoError(t, err) {
 						panic(http.ErrAbortHandler)
 					}
diff --git a/internal/monitor/uptimekuma_test.go b/internal/monitor/uptimekuma_test.go
index dd50fb7..71d4293 100644
--- a/internal/monitor/uptimekuma_test.go
+++ b/internal/monitor/uptimekuma_test.go
@@ -74,16 +74,21 @@ func TestNewUptimeKuma(t *testing.T) {
 	}
 }
 
-func TestUptimeKumaDescripbe(t *testing.T) {
+func TestUptimeKumaDescribe(t *testing.T) {
 	t.Parallel()
 
 	mockCtrl := gomock.NewController(t)
 	mockPP := mocks.NewMockPP(mockCtrl)
+
 	m, ok := monitor.NewUptimeKuma(mockPP, "https://user:pass@host/path")
 	require.True(t, ok)
-	m.Describe(func(service, _params string) {
-		require.Equal(t, "Uptime Kuma", service)
-	})
+
+	count := 0
+	for name := range m.Describe {
+		count++
+		require.Equal(t, "Uptime Kuma", name)
+	}
+	require.Equal(t, 1, count)
 }
 
 //nolint:funlen
@@ -107,7 +112,7 @@ func TestUptimeKumaEndPoints(t *testing.T) {
 	}
 
 	for name, tc := range map[string]struct {
-		endpoint      func(pp.PP, monitor.Monitor) bool
+		endpoint      func(pp.PP, monitor.BasicMonitor) bool
 		url           string
 		status        string
 		msg           string
@@ -119,8 +124,8 @@ func TestUptimeKumaEndPoints(t *testing.T) {
 		prepareMockPP func(*mocks.MockPP)
 	}{
 		"success": {
-			func(ppfmt pp.PP, m monitor.Monitor) bool {
-				return m.Success(context.Background(), ppfmt, "hello")
+			func(ppfmt pp.PP, m monitor.BasicMonitor) bool {
+				return m.Ping(context.Background(), ppfmt, monitor.NewMessagef(true, "hello"))
 			},
 			"/", "up", "OK", "",
 			[]action{ActionOK},
@@ -129,8 +134,8 @@ func TestUptimeKumaEndPoints(t *testing.T) {
 			successPP,
 		},
 		"success/not-ok": {
-			func(ppfmt pp.PP, m monitor.Monitor) bool {
-				return m.Success(context.Background(), ppfmt, "aloha")
+			func(ppfmt pp.PP, m monitor.BasicMonitor) bool {
+				return m.Ping(context.Background(), ppfmt, monitor.NewMessagef(true, "aloha"))
 			},
 			"/", "up", "OK", "",
 			[]action{ActionNotOK},
@@ -144,8 +149,8 @@ func TestUptimeKumaEndPoints(t *testing.T) {
 			},
 		},
 		"success/garbage-response": {
-			func(ppfmt pp.PP, m monitor.Monitor) bool {
-				return m.Success(context.Background(), ppfmt, "aloha")
+			func(ppfmt pp.PP, m monitor.BasicMonitor) bool {
+				return m.Ping(context.Background(), ppfmt, monitor.NewMessagef(true, "aloha"))
 			},
 			"/", "up", "OK", "",
 			[]action{ActionGarbage},
@@ -159,8 +164,8 @@ func TestUptimeKumaEndPoints(t *testing.T) {
 			},
 		},
 		"success/abort/all": {
-			func(ppfmt pp.PP, m monitor.Monitor) bool {
-				return m.Success(context.Background(), ppfmt, "stop now")
+			func(ppfmt pp.PP, m monitor.BasicMonitor) bool {
+				return m.Ping(context.Background(), ppfmt, monitor.NewMessagef(true, "stop now"))
 			},
 			"/", "up", "OK", "",
 			nil, ActionAbort, false,
@@ -172,21 +177,9 @@ func TestUptimeKumaEndPoints(t *testing.T) {
 				)
 			},
 		},
-		"start": {
-			func(ppfmt pp.PP, m monitor.Monitor) bool {
-				return m.Start(context.Background(), ppfmt, "starting now!")
-			},
-			"/", "", "", "",
-			[]action{},
-			ActionAbort, false,
-			true,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Noticef(pp.EmojiUserWarning, httpUnsafeMsg)
-			},
-		},
 		"failure": {
-			func(ppfmt pp.PP, m monitor.Monitor) bool {
-				return m.Failure(context.Background(), ppfmt, "something's wrong")
+			func(ppfmt pp.PP, m monitor.BasicMonitor) bool {
+				return m.Ping(context.Background(), ppfmt, monitor.NewMessagef(false, "something's wrong"))
 			},
 			"/", "down", "something's wrong", "",
 			[]action{ActionOK},
@@ -195,8 +188,8 @@ func TestUptimeKumaEndPoints(t *testing.T) {
 			successPP,
 		},
 		"failure/empty": {
-			func(ppfmt pp.PP, m monitor.Monitor) bool {
-				return m.Failure(context.Background(), ppfmt, "")
+			func(ppfmt pp.PP, m monitor.BasicMonitor) bool {
+				return m.Ping(context.Background(), ppfmt, monitor.NewMessagef(false, ""))
 			},
 			"/", "down", "Failing", "",
 			[]action{ActionOK},
@@ -204,50 +197,6 @@ func TestUptimeKumaEndPoints(t *testing.T) {
 			true,
 			successPP,
 		},
-		"log": {
-			func(ppfmt pp.PP, m monitor.Monitor) bool {
-				return m.Log(context.Background(), ppfmt, "message")
-			},
-			"/", "", "", "",
-			[]action{},
-			ActionAbort, false,
-			true,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Noticef(pp.EmojiUserWarning, httpUnsafeMsg)
-			},
-		},
-		"exitstatus/0": {
-			func(ppfmt pp.PP, m monitor.Monitor) bool {
-				return m.ExitStatus(context.Background(), ppfmt, 0, "bye!")
-			},
-			"/", "", "", "",
-			[]action{},
-			ActionAbort, false,
-			true,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Noticef(pp.EmojiUserWarning, httpUnsafeMsg)
-			},
-		},
-		"exitstatus/1": {
-			func(ppfmt pp.PP, m monitor.Monitor) bool {
-				return m.ExitStatus(context.Background(), ppfmt, 1, "did exit(1)")
-			},
-			"/", "down", "did exit(1)", "",
-			[]action{ActionOK},
-			ActionAbort, true,
-			true,
-			successPP,
-		},
-		"exitstatus/-1": {
-			func(ppfmt pp.PP, m monitor.Monitor) bool {
-				return m.ExitStatus(context.Background(), ppfmt, -1, "feeling negative")
-			},
-			"/", "down", "feeling negative", "",
-			[]action{ActionOK},
-			ActionAbort, true,
-			true,
-			successPP,
-		},
 	} {
 		t.Run(name, func(t *testing.T) {
 			t.Parallel()
diff --git a/internal/notifier/composite_test.go b/internal/notifier/composite_test.go
deleted file mode 100644
index 40cb87a..0000000
--- a/internal/notifier/composite_test.go
+++ /dev/null
@@ -1,83 +0,0 @@
-package notifier_test
-
-import (
-	"context"
-	"strings"
-	"testing"
-
-	"go.uber.org/mock/gomock"
-
-	"github.com/favonia/cloudflare-ddns/internal/message"
-	"github.com/favonia/cloudflare-ddns/internal/mocks"
-	"github.com/favonia/cloudflare-ddns/internal/notifier"
-)
-
-func TestDescribeAll(t *testing.T) {
-	t.Parallel()
-
-	ms := make([]notifier.Notifier, 0, 5)
-
-	mockCtrl := gomock.NewController(t)
-
-	for range 5 {
-		m := mocks.NewMockNotifier(mockCtrl)
-		m.EXPECT().Describe(gomock.Any())
-		ms = append(ms, m)
-	}
-
-	callback := func(_service, _params string) { /* the callback content is not relevant here. */ }
-	notifier.DescribeAll(callback, ms)
-}
-
-func TestSendAll(t *testing.T) {
-	t.Parallel()
-
-	ms := make([]notifier.Notifier, 0, 5)
-
-	mockCtrl := gomock.NewController(t)
-	mockPP := mocks.NewMockPP(mockCtrl)
-
-	message := "aloha"
-
-	for range 5 {
-		m := mocks.NewMockNotifier(mockCtrl)
-		m.EXPECT().Send(context.Background(), mockPP, message)
-		ms = append(ms, m)
-	}
-
-	notifier.SendAll(context.Background(), mockPP, ms, message)
-}
-
-func TestSendMessageAll(t *testing.T) {
-	t.Parallel()
-
-	for name1, tc1 := range map[string]struct {
-		notifierMessages []string
-	}{
-		"nil":   {nil},
-		"empty": {[]string{}},
-		"one":   {[]string{"hi"}},
-		"two":   {[]string{"hi", "hey"}},
-	} {
-		notifierMessage := strings.Join(tc1.notifierMessages, " ")
-
-		t.Run(name1, func(t *testing.T) {
-			t.Parallel()
-
-			ns := make([]notifier.Notifier, 0, 5)
-			mockCtrl := gomock.NewController(t)
-			mockPP := mocks.NewMockPP(mockCtrl)
-
-			for range 5 {
-				n := mocks.NewMockNotifier(mockCtrl)
-				if len(tc1.notifierMessages) > 0 {
-					n.EXPECT().Send(context.Background(), mockPP, notifierMessage)
-				}
-				ns = append(ns, n)
-			}
-
-			msg := message.NotifierMessage(tc1.notifierMessages)
-			notifier.SendMessageAll(context.Background(), mockPP, ns, msg)
-		})
-	}
-}
diff --git a/internal/notifier/shoutrrr_test.go b/internal/notifier/shoutrrr_test.go
index 5b306f4..fb59f1b 100644
--- a/internal/notifier/shoutrrr_test.go
+++ b/internal/notifier/shoutrrr_test.go
@@ -16,16 +16,66 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
-func TestShoutrrrDescripbe(t *testing.T) {
+func TestDescribeShoutrrrService(t *testing.T) {
+	t.Parallel()
+
+	for name, tc := range map[string]struct {
+		input        string
+		output       string
+		prepareMocks func(*mocks.MockPP)
+	}{
+		"ifttt": {"ifttt", "IFTTT", nil},
+		"zulip": {"zulip", "Zulip Chat", nil},
+		"empty": {
+			"", "",
+			func(ppfmt *mocks.MockPP) {
+				ppfmt.EXPECT().Noticef(pp.EmojiImpossible,
+					"Unknown shoutrrr service name %q; please report it at %s",
+					"", pp.IssueReportingURL)
+			},
+		},
+	} {
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			mockCtrl := gomock.NewController(t)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMocks != nil {
+				tc.prepareMocks(mockPP)
+			}
+
+			output := notifier.DescribeShoutrrrService(mockPP, tc.input)
+			require.Equal(t, tc.output, output)
+		})
+	}
+}
+
+func TestShoutrrrDescribe(t *testing.T) {
 	t.Parallel()
 
 	mockCtrl := gomock.NewController(t)
 	mockPP := mocks.NewMockPP(mockCtrl)
-	m, ok := notifier.NewShoutrrr(mockPP, []string{"generic://localhost/"})
-	require.True(t, ok)
-	m.Describe(func(service, _params string) {
-		require.Equal(t, "generic", service)
+	m, ok := notifier.NewShoutrrr(mockPP, []string{
+		"generic://localhost/",
+		"gotify://host:80/path/tokentoken",
+		"ifttt://hey/?events=1",
 	})
+	require.True(t, ok)
+
+	count := 0
+outer:
+	for name := range m.Describe {
+		count++
+		switch count {
+		case 1:
+			require.Equal(t, "Generic", name)
+		case 2:
+			require.Equal(t, "Gotify", name)
+			break outer
+		default:
+		}
+	}
+	require.Equal(t, 2, count)
 }
 
 func TestShoutrrrSend(t *testing.T) {
@@ -33,30 +83,37 @@ func TestShoutrrrSend(t *testing.T) {
 
 	for name, tc := range map[string]struct {
 		path          string
+		pinged        int
 		service       func(serverURL string) string
-		message       string
-		pinged        bool
+		message       notifier.Message
 		ok            bool
 		prepareMockPP func(*mocks.MockPP)
 	}{
 		"success": {
-			"/greeting",
+			"/greeting", 1,
 			func(serverURL string) string { return "generic+" + serverURL + "/greeting" },
-			"hello",
-			true, true,
+			notifier.NewMessagef("hello"),
+			true,
 			func(m *mocks.MockPP) {
-				m.EXPECT().Infof(pp.EmojiNotify, "Notified %s via shoutrrr", `"generic"`)
+				m.EXPECT().Infof(pp.EmojiNotify, "Notified %s via shoutrrr", "Generic")
 			},
 		},
 		"ill-formed url": {
-			"",
+			"", 0,
 			func(_serverURL string) string { return "generic+https://0.0.0.0" },
-			"hello",
-			false, false,
+			notifier.NewMessagef("hello"),
+			false,
 			func(m *mocks.MockPP) {
 				m.EXPECT().Noticef(pp.EmojiError, "Failed to notify shoutrrr service(s): %v", gomock.Any())
 			},
 		},
+		"empty": {
+			"/greeting", 0,
+			func(serverURL string) string { return "generic+" + serverURL + "/greeting" },
+			notifier.NewMessage(),
+			true,
+			nil,
+		},
 	} {
 		t.Run(name, func(t *testing.T) {
 			t.Parallel()
@@ -66,7 +123,7 @@ func TestShoutrrrSend(t *testing.T) {
 				tc.prepareMockPP(mockPP)
 			}
 
-			pinged := false
+			pinged := 0
 			server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
 				if !assert.Equal(t, http.MethodPost, r.Method) ||
 					!assert.Equal(t, tc.path, r.URL.EscapedPath()) {
@@ -74,11 +131,11 @@ func TestShoutrrrSend(t *testing.T) {
 				}
 
 				if reqBody, err := io.ReadAll(r.Body); !assert.NoError(t, err) ||
-					!assert.Equal(t, tc.message, string(reqBody)) {
+					!assert.Equal(t, tc.message.Format(), string(reqBody)) {
 					panic(http.ErrAbortHandler)
 				}
 
-				pinged = true
+				pinged++
 			}))
 
 			s, ok := notifier.NewShoutrrr(mockPP, []string{tc.service(server.URL)})
diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go
index 37b5b45..b56480c 100644
--- a/internal/updater/updater_test.go
+++ b/internal/updater/updater_test.go
@@ -15,6 +15,8 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
 	"github.com/favonia/cloudflare-ddns/internal/message"
 	"github.com/favonia/cloudflare-ddns/internal/mocks"
+	"github.com/favonia/cloudflare-ddns/internal/monitor"
+	"github.com/favonia/cloudflare-ddns/internal/notifier"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 	"github.com/favonia/cloudflare-ddns/internal/provider"
 	"github.com/favonia/cloudflare-ddns/internal/provider/protocol"
@@ -177,11 +179,11 @@ func TestUpdateIPsMultiple(t *testing.T) {
 
 			resp := updater.UpdateIPs(ctx, mockPP, conf, mockSetter)
 			require.Equal(t, message.Message{
-				MonitorMessage: message.MonitorMessage{
+				MonitorMessage: monitor.Message{
 					OK:    tc.ok,
 					Lines: tc.monitorMessages,
 				},
-				NotifierMessage: message.NotifierMessage(tc.notifierMessages),
+				NotifierMessage: notifier.Message(tc.notifierMessages),
 			}, resp)
 		})
 	}
@@ -278,11 +280,11 @@ func TestDeleteIPsMultiple(t *testing.T) {
 			}
 			resp := updater.DeleteIPs(ctx, mockPP, conf, mockSetter)
 			require.Equal(t, message.Message{
-				MonitorMessage: message.MonitorMessage{
+				MonitorMessage: monitor.Message{
 					OK:    tc.ok,
 					Lines: tc.monitorMessages,
 				},
-				NotifierMessage: message.NotifierMessage(tc.notifierMessages),
+				NotifierMessage: notifier.Message(tc.notifierMessages),
 			}, resp)
 		})
 	}
@@ -649,11 +651,11 @@ func TestUpdateIPs(t *testing.T) {
 			}
 			resp := updater.UpdateIPs(ctx, mockPP, conf, mockSetter)
 			require.Equal(t, message.Message{
-				MonitorMessage: message.MonitorMessage{
+				MonitorMessage: monitor.Message{
 					OK:    tc.ok,
 					Lines: tc.monitorMessages,
 				},
-				NotifierMessage: message.NotifierMessage(tc.notifierMessages),
+				NotifierMessage: notifier.Message(tc.notifierMessages),
 			}, resp)
 		})
 	}
@@ -874,11 +876,11 @@ func TestDeleteIPs(t *testing.T) {
 
 			resp := updater.DeleteIPs(ctx, mockPP, conf, mockSetter)
 			require.Equal(t, message.Message{
-				MonitorMessage: message.MonitorMessage{
+				MonitorMessage: monitor.Message{
 					OK:    tc.ok,
 					Lines: tc.monitorMessages,
 				},
-				NotifierMessage: message.NotifierMessage(tc.notifierMessages),
+				NotifierMessage: notifier.Message(tc.notifierMessages),
 			}, resp)
 		})
 	}