12.4 Design Patterns in Edge Computing

Introduction to Design Patterns in Edge Computing
Section titled “Introduction to Design Patterns in Edge Computing”Edge computing presents a unique set of challenges for software developers, including resource constraints (CPU, memory, power), intermittent network connectivity, and the need for low-latency, real-time processing. This section explores modern design patterns tailored for this demanding environment, providing a comparative analysis with practical examples in both C++ and Rust. It moves beyond traditional Gang of Four (GoF) patterns to address edge-specific concerns, demonstrating how to build robust, efficient, and scalable applications. The focus is on patterns that manage state, handle asynchronous events, and facilitate resilient communication, showcasing how the distinct features of C++ and Rust can be leveraged for optimal edge solutions.
In edge programming, design patterns are not just about code organisation; they are critical tools for survival. An inappropriate pattern can lead to memory leaks, excessive power consumption, or catastrophic failure in the face of network instability. The right pattern, however, ensures resilience, efficiency, and maintainability.
Unlike server-side development where resources are abundant, edge devices operate under tight constraints. Therefore, edge design patterns must prioritise:
- Resource Efficiency: Minimising memory footprint, CPU cycles, and power draw.
- Asynchronicity & Concurrency: Handling multiple simultaneous I/O operations (e.g., sensor readings, network requests) without blocking the main thread.
- State Management: Safely managing device state, especially when it must persist across power cycles or reboots.
- Resilience & Fault Tolerance: Gracefully handling network dropouts, sensor failures, and other common edge environment issues.
- Decoupling: Separating components to allow for independent updates and testing, which is crucial for maintaining complex embedded systems.
This section focuses on three powerful patterns that directly address these challenges. These three patterns provide complementary approaches to structuring edge applications: Publisher–Subscriber is ideal for event-driven and loosely coupled systems; the Command Pattern helps coordinate actions and queued operations; and the State Pattern gives clarity to devices with well-defined behavioural modes. Used together with Rust’s safe concurrency and async features, they offer powerful tools for building robust, maintainable embedded software for real-world edge applications.
The Publisher-Subscriber (Pub/Sub) Pattern
Section titled “The Publisher-Subscriber (Pub/Sub) Pattern”The Publisher-Subscriber pattern is arguably the most essential pattern for modern edge applications. It decouples components that produce data (publishers) from those that consume it (subscribers). Instead of direct communication, publishers send messages to an intermediary channel or “topic,” and subscribers listen to these topics without any knowledge of the publisher’s identity.

Figure 1. Scalability! (Work Chronicles)
This design pattern is covered in detail in the follow-on EEN1071 Connected Embedded module where we cover MQTT in detail. Why it’s critical for the edge:
- Decoupling: A sensor reading component (publisher) doesn’t need to know about the data logging, analytics, or cloud sync components (subscribers). This modularity simplifies development and allows for services to be added or removed dynamically.
- Asynchronicity: It’s a natural fit for event-driven architectures. A publisher can emit an event (e.g., “temperature threshold exceeded”) without waiting for subscribers to process it.
- Scalability: New subscribers can be added to the system to handle new tasks without modifying the existing publishers.
Example Scenario: A Smart Weather Station
An edge weather station needs to read data from multiple sensors (temperature, humidity, pressure), process it locally, display it on an LCD, and send it to a cloud service.
C++ Implementation
In C++, this pattern can be implemented using standard library features like std::function for callbacks and std::vector or std::map to manage subscribers. This approach is flexible but requires careful memory management and thread safety considerations.
#include <iostream>#include <vector>#include <string>#include <functional>#include <map>#include <mutex>
// A generic message brokerclass MessageBroker {public: using Subscriber = std::function<void(const std::string&)>;
void subscribe(const std::string& topic, Subscriber subscriber) { std::lock_guard<std::mutex> lock(mtx_); subscribers_[topic].push_back(subscriber); }
void publish(const std::string& topic, const std::string& message) { std::lock_guard<std::mutex> lock(mtx_); if (subscribers_.count(topic)) { for (const auto& sub : subscribers_.at(topic)) { sub(message); // Invoke the callback } } }
private: std::map<std::string, std::vector<Subscriber>> subscribers_; std::mutex mtx_; // Protects access to the subscribers map};
// Publisher: Sensor componentclass TemperatureSensor {public: TemperatureSensor(MessageBroker& broker) : broker_(broker) {}
void read_and_publish() { // Simulate reading a temperature float temp = 21.5f; std::string message = "Temperature: " + std::to_string(temp) + " C"; std::cout << "[Sensor] Publishing to 'sensors/temp'\n"; broker_.publish("sensors/temp", message); }
private: MessageBroker& broker_;};
// Subscriber: Servicesvoid cloud_uploader(const std::string& data) { std::cout << "[CloudUploader] Received: " << data << ". Uploading to cloud...\n";}
void lcd_display(const std::string& data) { std::cout << "[LCDDisplay] Displaying: " << data << "\n";}
int main() { MessageBroker broker;
// Services subscribe to the topic broker.subscribe("sensors/temp", cloud_uploader); broker.subscribe("sensors/temp", lcd_display);
TemperatureSensor sensor(broker); sensor.read_and_publish();
return 0;}Analysis: The C++ version is powerful and flexible. The use of std::function allows any callable entity (free functions, lambdas, member functions) to be a subscriber. However, the developer is responsible for managing thread safety, which is achieved here with a std::mutex. This manual control is typical of C++ and offers high performance but carries the risk of deadlocks or race conditions if not implemented carefully.
Rust Implementation
Rust’s ownership model and emphasis on thread safety make it exceptionally well-suited for the Pub/Sub pattern. Using channels from libraries like tokio or async-std provides a robust, built-in mechanism for message passing.
use tokio::sync::broadcast;use std::time::Duration;
// The message type must be Clone-able for broadcast channels#[derive(Debug, Clone)]struct SensorReading { source: String, value: f32, unit: String,}
// Publisher: Sensor componentasync fn temperature_sensor(tx: broadcast::Sender<SensorReading>) { // Simulate reading a temperature every second loop { let reading = SensorReading { source: "dht22".to_string(), value: 21.5, unit: "C".to_string(), }; println!("[Sensor] Publishing reading: {:?}", reading); if let Err(_) = tx.send(reading) { println!("[Sensor] No active subscribers."); } tokio::time::sleep(Duration::from_secs(1)).await; }}
// Subscriber: Cloud uploader serviceasync fn cloud_uploader(mut rx: broadcast::Receiver<SensorReading>) { loop { match rx.recv().await { Ok(reading) => { println!("[CloudUploader] Received: {:?}. Uploading...", reading); } Err(e) => { println!("[CloudUploader] Error receiving message: {}", e); break; } } }}
// Subscriber: LCD display serviceasync fn lcd_display(mut rx: broadcast::Receiver<SensorReading>) { loop { match rx.recv().await { Ok(reading) => { println!("[LCDDisplay] Displaying: {} {}", reading.value, reading.unit); } Err(e) => { println!("[LCDDisplay] Error receiving message: {}", e); break; } } }}
#[tokio::main]async fn main() { // Create a broadcast channel. Capacity of 32 messages. let (tx, _rx) = broadcast::channel::<SensorReading>(32);
// Spawn the publisher and subscribers as concurrent tasks let sensor_handle = tokio::spawn(temperature_sensor(tx.clone())); let cloud_handle = tokio::spawn(cloud_uploader(tx.subscribe())); let display_handle = tokio::spawn(lcd_display(tx.subscribe()));
// Let it run for a few seconds tokio::time::sleep(Duration::from_secs(3)).await;
// Normally you'd await the handles, but we'll just exit for this example // sensor_handle.await.unwrap();}Analysis: The Rust version uses tokio’s broadcast channel, which is inherently thread-safe and designed for a one-to-many communication pattern. The compiler enforces safety rules, eliminating entire classes of bugs like data races at compile time. The async/await syntax provides a clean way to handle concurrent tasks without manual thread management. This makes the Rust code arguably safer and easier to reason about in a highly concurrent edge environment.
The Command Pattern
Section titled “The Command Pattern”The Command pattern encapsulates a request as an object, thereby letting you parameterise clients with different requests, queue or log requests, and support undoable operations.
Why it’s critical for the edge:
- Offline Operation: Commands (e.g., “update configuration,” “send data”) can be queued when the device is offline and executed sequentially when connectivity is restored.
- Resilience: If a command fails, it can be easily retried or logged for later analysis.
- Decoupling: The object that invokes the operation is decoupled from the object that knows how to perform it. For example, a generic “command scheduler” can execute any command without knowing its specific details.
Example Scenario: A Remotely Controlled Actuator
An edge device controls a pump. It receives commands via MQTT to turn the pump on or off. These commands must be queued and executed reliably, even if the connection is temporarily lost.
C++ Implementation
A C++ implementation typically involves an abstract Command base class with a virtual execute() method. Concrete command classes then inherit from this base class.
#include <iostream>#include <vector>#include <memory>#include <queue>
// The "Receiver" - knows how to perform the workclass Pump {public: void turn_on() { is_on_ = true; std::cout << "Pump is now ON.\n"; } void turn_off() { is_on_ = false; std::cout << "Pump is now OFF.\n"; }private: bool is_on_ = false;};
// Abstract Command interfaceclass Command {public: virtual ~Command() = default; virtual void execute() = 0;};
// Concrete Command for turning the pump onclass TurnOnCommand : public Command {public: TurnOnCommand(Pump& pump) : pump_(pump) {} void execute() override { pump_.turn_on(); }private: Pump& pump_;};
// Concrete Command for turning the pump offclass TurnOffCommand : public Command {public: TurnOffCommand(Pump& pump) : pump_(pump) {} void execute() override { pump_.turn_off(); }private: Pump& pump_;};
// The "Invoker" - holds a queue of commandsclass CommandProcessor {public: void add_command(std::unique_ptr<Command> cmd) { command_queue_.push(std::move(cmd)); }
void process_commands() { while (!command_queue_.empty()) { command_queue_.front()->execute(); command_queue_.pop(); } }private: std::queue<std::unique_ptr<Command>> command_queue_;};
int main() { Pump my_pump; CommandProcessor processor;
// Simulate receiving commands (e.g., from MQTT) processor.add_command(std::make_unique<TurnOnCommand>(my_pump)); processor.add_command(std::make_unique<TurnOffCommand>(my_pump));
std::cout << "Processing queued commands...\n"; processor.process_commands();
return 0;}Analysis: This classic object-oriented approach is very clear. Using std::unique_ptr for the commands ensures proper memory management without manual new/delete. The use of polymorphism (virtual functions) provides great flexibility but introduces a small runtime overhead due to vtable lookups. This is a well-established and powerful pattern in C++.
Rust Implementation
In Rust, the Command pattern is often implemented using traits and enums. A trait defines the common execute behaviour, and an enum can represent the set of all possible commands, which is often more memory-efficient and safer than open-ended inheritance.
// The "Receiver"struct Pump { is_on: bool,}
impl Pump { fn new() -> Self { Pump { is_on: false } } fn turn_on(&mut self) { self.is_on = true; println!("Pump is now ON."); } fn turn_off(&mut self) { self.is_on = false; println!("Pump is now OFF."); }}
// Using a trait for the Command interfacetrait Command { fn execute(&self, pump: &mut Pump);}
// Concrete command structsstruct TurnOnCommand;impl Command for TurnOnCommand { fn execute(&self, pump: &mut Pump) { pump.turn_on(); }}
struct TurnOffCommand;impl Command for TurnOffCommand { fn execute(&self, pump: &mut Pump) { pump.turn_off(); }}
// Alternative and often more idiomatic Rust: use an enumenum PumpCommand { TurnOn, TurnOff,}
impl PumpCommand { // The execute method is on the enum itself fn execute(&self, pump: &mut Pump) { match self { PumpCommand::TurnOn => pump.turn_on(), PumpCommand::TurnOff => pump.turn_off(), } }}
// The "Invoker"struct CommandProcessor { command_queue: Vec<Box<dyn Command>>, // Or using the enum approach: // command_queue: Vec<PumpCommand>,}
impl CommandProcessor { fn new() -> Self { CommandProcessor { command_queue: Vec::new() } }
fn add_command(&mut self, cmd: Box<dyn Command>) { self.command_queue.push(cmd); }
fn process_commands(&mut self, pump: &mut Pump) { for cmd in self.command_queue.drain(..) { cmd.execute(pump); } }}
fn main() { let mut my_pump = Pump::new(); let mut processor = CommandProcessor::new();
// Using the trait object approach processor.add_command(Box::new(TurnOnCommand)); processor.add_command(Box::new(TurnOffCommand));
println!("Processing queued commands..."); processor.process_commands(&mut my_pump);
// --- Enum approach example --- let mut enum_queue: Vec<PumpCommand> = Vec::new(); enum_queue.push(PumpCommand::TurnOn); enum_queue.push(PumpCommand::TurnOff); println!("\nProcessing enum commands..."); for cmd in enum_queue { cmd.execute(&mut my_pump); }}Analysis: Rust offers two excellent ways to implement this. The first, using Box<dyn Command>, is analogous to the C++ polymorphic approach. It’s dynamic and extensible. The second approach, using an enum, is often preferred in Rust when the set of commands is known at compile time. This is more memory-efficient (no vtable pointer) and can be faster due to the potential for compiler optimisations via the match statement. The Rust compiler’s borrow checker also ensures that ownership of the Pump is managed safely (note the &mut Pump parameter).
The State Pattern
Section titled “The State Pattern”The State pattern is a behavioural pattern that allows an object to alter its behaviour when its internal state changes. The object appears to change its class. Why it’s critical for the edge:
- Managing Complexity: Edge devices often operate as a finite state machine (FSM). For example, a device might be in a
Connecting,Connected,Syncing,Idle, orErrorstate. The State pattern avoids massiveif/elseorswitchstatements, making the code cleaner and easier to maintain. - Encapsulation: The behaviour associated with a particular state is localised in its own class or struct, promoting better organisation.
- Robustness: It makes state transitions explicit and easier to manage, reducing the likelihood of bugs where the device gets into an invalid state.
Example Scenario: A Cellular Modem Connection Manager
An edge device uses a cellular modem to connect to the internet. The connection logic involves multiple states: Disconnected, Connecting, Connected, and Retrying.
C++ Implementation
The C++ implementation uses a similar polymorphic structure to the Command pattern, with an abstract State base class and concrete classes for each state. The context (ConnectionManager) holds a pointer to the current state object.
#include <iostream>#include <memory>#include <chrono>#include <thread>
class ConnectionManager; // Forward declaration
// Abstract State base classclass State {public: virtual ~State() = default; virtual void handle(ConnectionManager& manager) = 0; void set_manager(ConnectionManager* manager) { manager_ = manager; }protected: ConnectionManager* manager_;};
// Context class that holds the current stateclass ConnectionManager {public: ConnectionManager() : state_(nullptr) {}
void transition_to(std::unique_ptr<State> state) { std::cout << "Transitioning state...\n"; state_ = std::move(state); state_->set_manager(this); }
void request() { if (state_) { state_->handle(*this); } }private: std::unique_ptr<State> state_;};
// --- Concrete States ---
class DisconnectedState : public State {public: void handle(ConnectionManager& manager) override;};
class ConnectingState : public State {public: void handle(ConnectionManager& manager) override;};
class ConnectedState : public State {public: void handle(ConnectionManager& manager) override { std::cout << "State: Connected. All is well. Listening for data...\n"; // In a real app, might transition to Disconnected if connection is lost }};
// Implementations that transition between statesvoid DisconnectedState::handle(ConnectionManager& manager) { std::cout << "State: Disconnected. Trying to connect...\n"; manager.transition_to(std::make_unique<ConnectingState>());}
void ConnectingState::handle(ConnectionManager& manager) { std::cout << "State: Connecting... "; std::this_thread::sleep_for(std::chrono::seconds(1)); // Simulate connection attempt
// Simulate a successful connection bool success = true; if (success) { std::cout << "Success!\n"; manager.transition_to(std::make_unique<ConnectedState>()); } else { std::cout << "Failed.\n"; manager.transition_to(std::make_unique<DisconnectedState>()); }}
int main() { ConnectionManager manager; manager.transition_to(std::make_unique<DisconnectedState>());
manager.request(); // Tries to connect manager.request(); // Is now connecting manager.request(); // Is now connected
return 0;}Analysis: This C++ code cleanly separates the logic for each state. The ConnectionManager is simplified; it doesn’t need to know the details of connecting, only how to delegate to the current state object. The transitions are managed by the state objects themselves, creating a well-defined flow.
Rust Implementation
Once again, Rust can use traits for a dynamic FSM or an enum for a compile-time-defined FSM. The enum approach is particularly powerful for state machines as the compiler can check for exhaustiveness, ensuring all states are handled in match statements.
// The Contextstruct ConnectionManager { state: State,}
// An enum to represent all possible statesenum State { Disconnected, Connecting { attempts: u32 }, Connected { connection_id: String }, Error { message: String },}
impl ConnectionManager { fn new() -> Self { ConnectionManager { state: State::Disconnected } }
// A single method handles requests and manages transitions fn handle_request(&mut self) { // Use 'self.state = ...' to transition match self.state { State::Disconnected => { println!("[State] Disconnected. Initiating connection..."); self.state = State::Connecting { attempts: 1 }; } State::Connecting { attempts } => { println!("[State] Connecting (attempt {})...", attempts); // Simulate connection success after 2 attempts if attempts < 2 { self.state = State::Connecting { attempts: attempts + 1 }; } else { println!("[State] Connection successful!"); self.state = State::Connected { connection_id: "conn-123".to_string() }; } } State::Connected { ref connection_id } => { println!("[State] Already connected with ID: {}. No action needed.", connection_id); } State::Error { ref message } => { println!("[State] In error state: {}. Attempting to recover...", message); self.state = State::Disconnected; } } }}
fn main() { let mut manager = ConnectionManager::new();
manager.handle_request(); // Disconnected -> Connecting(1) manager.handle_request(); // Connecting(1) -> Connecting(2) manager.handle_request(); // Connecting(2) -> Connected manager.handle_request(); // Connected -> No change}Analysis: The Rust enum-based FSM is extremely robust. The state and its associated data (like attempts or connection_id) are bundled together. This prevents invalid states, for example, having a connection_id when the state is Disconnected. The match statement is exhaustive, meaning if a new state were added to the State enum, the Rust compiler would flag an error until the handle_request function was updated to handle the new case. This makes refactoring and extending the state machine much safer than in the C++ version.
Conclusion and Recommendations
Section titled “Conclusion and Recommendations”For developers programming at the edge, design patterns are indispensable for managing complexity and building resilient systems.
- The Publisher-Subscriber pattern is foundational for creating modular, event-driven applications that can easily scale.
- The Command pattern is essential for applications that need to function under intermittent connectivity, allowing for robust queuing and retrying of operations.
- The State pattern provides a clean and safe way to manage the complex lifecycles of edge devices, preventing bugs and making code more maintainable.
Beyond these three patterns, RAII (Resource Acquisition Is Initialisation) is perhaps the most natural and pervasive design pattern in embedded Rust. When a type acquires a resource (i.e., a peripheral lock, a DMA buffer, an I²C transaction guard) in its constructor and releases it automatically in its Drop implementation, correctness is structural rather than discipline-dependent. The resource is always released, even on early returns or panics. The HAL types already apply RAII (a Serial struct owns the UART; dropping it frees the peripheral), and the same principle applies to your own abstractions: wrap any resource that needs deterministic clean-up in a struct with a Drop implementation. This pattern eliminates entire categories of resource-leak bugs that are common in C and C++ embedded code.
Language Choice for Design Patterns
Section titled “Language Choice for Design Patterns”C++ offers unparalleled performance and control. Its object-oriented features map directly to classic pattern implementations. However, this power comes with the responsibility of manual memory management (even with smart pointers) and thread safety, which can be complex to get right. It remains an excellent choice for performance-critical applications where the development team has strong C++ expertise.
Rust presents a compelling modern alternative. Its focus on safety (guaranteed at compile time) eliminates entire categories of common bugs (null pointer dereferences, data races, buffer overflows) that are particularly dangerous in long-running edge deployments. Its implementation of patterns using traits and enums is often more memory-efficient and type-safe than traditional polymorphic approaches. For new edge projects where reliability and security are paramount, Rust is an increasingly strong choice.
🧩 Knowledge Check
Section titled “🧩 Knowledge Check”Match the Edge Design Pattern
The State Pattern with Rust Enums
How does Embassy's async model achieve significant power savings on edge devices compared to traditional OS-based approaches?
© 2026 Derek Molloy, Dublin City University. All rights reserved.