Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

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.

Stop Sign

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 broker
class 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 component
class 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: Services
void 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 component
async 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 service
async 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 service
async 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 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 work
class 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 interface
class Command {
public:
virtual ~Command() = default;
virtual void execute() = 0;
};
// Concrete Command for turning the pump on
class TurnOnCommand : public Command {
public:
TurnOnCommand(Pump& pump) : pump_(pump) {}
void execute() override {
pump_.turn_on();
}
private:
Pump& pump_;
};
// Concrete Command for turning the pump off
class TurnOffCommand : public Command {
public:
TurnOffCommand(Pump& pump) : pump_(pump) {}
void execute() override {
pump_.turn_off();
}
private:
Pump& pump_;
};
// The "Invoker" - holds a queue of commands
class 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 interface
trait Command {
fn execute(&self, pump: &mut Pump);
}
// Concrete command structs
struct 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 enum
enum 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 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, or Error state. The State pattern avoids massive if/else or switch statements, 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 class
class 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 state
class 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 states
void 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 Context
struct ConnectionManager {
state: State,
}
// An enum to represent all possible states
enum 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.

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.

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.

Concept Match

Match the Edge Design Pattern

Code Cloze
Rust

The State Pattern with Rust Enums

Quiz
Select 0/1

How does Embassy's async model achieve significant power savings on edge devices compared to traditional OS-based approaches?