Foundational software design principles applied specifically to robotics module development. Use this skill when designing robot software modules, structuring codebases, making architecture decisions, reviewing robotics code, or building reusable robotics libraries. Trigger whenever the user mentions SOLID principles for robots, modular robotics software, clean architecture for robots, dependency injection in robotics, interface design for hardware, real-time design constraints, error handling strategies for robots, configuration management, separation of concerns in perception-planning- control, composability of robot behaviors, or any discussion of software craftsmanship in a robotics context. Also trigger for code reviews of robotics code, refactoring robot software, or designing APIs for robotics libraries.
Foundational software design principles applied specifically to robotics module development. Use this skill when designing robot software modules, structuring codebases, making architecture decisions, reviewing robotics code, or building reusable robotics libraries. Trigger whenever the user mentions SOLID principles for robots, modular robotics software, clean architecture for robots, dependency injection in robotics, interface design for hardware, real-time design constraints, error handling strategies for robots, configuration management, separation of concerns in perception-planning- control, composability of robot behaviors, or any discussion of software craftsmanship in a robotics context. Also trigger for code reviews of robotics code, refactoring robot software, or designing APIs for robotics libraries.
Robotics Software Design Principles
Why Robotics Software Is Different
Robotics code operates under constraints that most software never faces:
Physical consequences — A bug doesn't just crash a process, it crashes a robot into a wall
Real-time deadlines — Missing a 1ms control loop deadline can cause oscillation or damage
Sensor uncertainty — All inputs are noisy, delayed, and occasionally wrong
Hardware diversity — Same algorithm must work on 10 different grippers from 5 vendors
Sim-to-real gap — Code must run identically in simulation and on real hardware
Long-running operation — Robots run for hours/days; memory leaks and drift matter
Safety criticality — Some failures must NEVER happen, regardless of software state
These constraints demand disciplined design. Below are principles that account for them.
Principle 1: Single Responsibility — One Module, One Job
Every module (node, class, function) should have exactly ONE reason to change.
: A perception module that also does control means a camera driver update can break your arm controller. In safety-critical systems, this coupling is unacceptable.
Why it matters in robotics
# ❌ BAD: God module — perception + planning + control + loggingclassRobotController:
def__init__(self):
self.camera = RealSenseCamera()
self.detector = YOLODetector()
self.planner = RRTPlanner()
self.arm = UR5Driver()
self.logger = DataLogger()
defrun(self):
image = self.camera.capture()
objects = self.detector.detect(image)
path = self.planner.plan(objects[0].pose)
self.arm.execute(path)
self.logger.log(image, objects, path)
# If ANY of these changes, you touch this class# ✅ GOOD: Separated responsibilities with clear interfacesclassPerceptionModule:
"""ONLY responsibility: raw sensor data → detected objects"""def__init__(self, camera: CameraInterface, detector: DetectorInterface):
self.camera = camera
self.detector = detector
defget_detections(self) -> List[Detection]:
image = self.camera.capture()
returnself.detector.detect(image)
classPlanningModule:
"""ONLY responsibility: goal + world state → trajectory"""def__init__(self, planner: PlannerInterface):
self.planner = planner
defplan_to(self, target: Pose, obstacles: List[Obstacle]) -> Trajectory:
returnself.planner.plan(target, obstacles)
classExecutionModule:
"""ONLY responsibility: trajectory → hardware commands"""def__init__(self, arm: ArmInterface):
self.arm = arm
defexecute(self, trajectory: Trajectory) -> ExecutionResult:
returnself.arm.follow_trajectory(trajectory)
Test: Can you describe what a module does WITHOUT using "and"? If not, split it.
Principle 2: Dependency Inversion — Depend on Abstractions, Not Hardware
High-level modules (planning, behavior) should never depend on low-level modules (drivers, hardware). Both should depend on abstractions.
Why it matters in robotics: This is the foundation of sim-to-real. If your planner imports UR5Driver directly, it can't run in simulation. If it depends on ArmInterface, you swap implementations freely.
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing importList, Optionalimport numpy as np
# ─── ABSTRACTIONS (the contracts) ────────────────────────────classArmInterface(ABC):
"""Abstract arm — every arm implementation must honor this contract""" @abstractmethoddefget_joint_positions(self) -> np.ndarray:
"""Returns current joint positions in radians"""
...
@abstractmethoddefget_ee_pose(self) -> Pose:
"""Returns current end-effector pose"""
...
@abstractmethoddefmove_to_joints(self, positions: np.ndarray,
velocity: float = 0.5) -> bool:
"""Move to joint positions. Returns True on success."""
...
@abstractmethoddefstop(self) -> None:
"""Immediately stop all motion"""
...
@property @abstractmethoddefjoint_limits(self) -> List[tuple]:
"""Returns [(min, max)] for each joint"""
...
classCameraInterface(ABC):
"""Abstract camera — any RGB camera must honor this""" @abstractmethoddefcapture(self) -> np.ndarray:
"""Returns (H, W, 3) uint8 RGB image"""
...
@abstractmethoddefget_intrinsics(self) -> CameraIntrinsics:
"""Returns camera intrinsic parameters"""
...
@property @abstractmethoddefresolution(self) -> tuple:
"""Returns (width, height)"""
...
classGripperInterface(ABC):
@abstractmethoddefopen(self, width: float = 1.0) -> bool: ...
@abstractmethoddefclose(self, force: float = 0.5) -> bool: ...
@abstractmethoddefget_width(self) -> float: ...
@abstractmethoddefis_grasping(self) -> bool: ...
# ─── CONCRETE IMPLEMENTATIONS ────────────────────────────────classUR5Arm(ArmInterface):
"""Real UR5 via RTDE protocol"""def__init__(self, ip: str):
self.rtde = RTDEControl(ip)
self.rtde_receive = RTDEReceive(ip)
defget_joint_positions(self) -> np.ndarray:
return np.array(self.rtde_receive.getActualQ())
defmove_to_joints(self, positions, velocity=0.5):
self.rtde.moveJ(positions.tolist(), velocity)
returnTruedefstop(self):
self.rtde.stopScript()
@propertydefjoint_limits(self):
return [(-2*np.pi, 2*np.pi)] * 6classMuJoCoArm(ArmInterface):
"""Simulated arm in MuJoCo — SAME interface"""def__init__(self, model_path: str, joint_names: List[str]):
self.model = mujoco.MjModel.from_xml_path(model_path)
self.data = mujoco.MjData(self.model)
self.joint_ids = [mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, n)
for n in joint_names]
defget_joint_positions(self) -> np.ndarray:
return np.array([self.data.qpos[jid] for jid inself.joint_ids])
defmove_to_joints(self, positions, velocity=0.5):
# Simulate motion with position controlself.data.ctrl[:len(positions)] = positions
for _ inrange(100):
mujoco.mj_step(self.model, self.data)
returnTruedefstop(self):
self.data.ctrl[:] = 0# ─── HIGH-LEVEL CODE DEPENDS ONLY ON ABSTRACTIONS ────────────classPickPlaceTask:
"""This class works with ANY arm + gripper + camera.
It never knows or cares if it's sim or real."""def__init__(self, arm: ArmInterface, gripper: GripperInterface,
camera: CameraInterface, detector: DetectorInterface):
self.arm = arm
self.gripper = gripper
self.camera = camera
self.detector = detector
defexecute(self, target_class: str) -> bool:
image = self.camera.capture()
detections = self.detector.detect(image)
target = next((d for d in detections if d.label == target_class), None)
if target isNone:
returnFalseself.arm.move_to_joints(self.ik(target.pose))
self.gripper.close()
self.arm.move_to_joints(self.place_joints)
self.gripper.open()
returnTrue
Arrows point inward. High-level policy never imports low-level drivers.
Principle 3: Open-Closed — Extend Without Modifying
Modules should be open for extension but closed for modification. Add new capabilities by adding new code, not changing existing code.
Why it matters in robotics: You constantly add new sensors, new robots, new tasks. If adding a new camera requires modifying your perception pipeline, you'll break existing deployments.
# ❌ BAD: Adding a new sensor requires modifying existing codeclassPerceptionPipeline:
defprocess(self, sensor_type: str, data):
if sensor_type == 'realsense':
returnself._process_realsense(data)
elif sensor_type == 'zed':
returnself._process_zed(data)
elif sensor_type == 'oakd': # New sensor = modify this classreturnself._process_oakd(data)
# ✅ GOOD: Plugin architecture — add sensors without touching coreclassSensorPlugin(ABC):
"""Base class for all sensor plugins""" @abstractmethoddefname(self) -> str: ...
@abstractmethoddefprocess(self, raw_data) -> ProcessedData: ...
@abstractmethoddefget_intrinsics(self) -> dict: ...
classRealSensePlugin(SensorPlugin):
defname(self): return'realsense'defprocess(self, raw_data):
# RealSense-specific processingreturn ProcessedData(...)
classZEDPlugin(SensorPlugin):
defname(self): return'zed'defprocess(self, raw_data):
# ZED-specific processingreturn ProcessedData(...)
# Core pipeline never changes when you add sensorsclassPerceptionPipeline:
def__init__(self):
self._plugins: dict[str, SensorPlugin] = {}
defregister_sensor(self, plugin: SensorPlugin):
"""Extend the pipeline without modifying it"""self._plugins[plugin.name()] = plugin
defprocess(self, sensor_name: str, data):
if sensor_name notinself._plugins:
raise ValueError(f"Unknown sensor: {sensor_name}")
returnself._plugins[sensor_name].process(data)
# Adding OAK-D = add a file, register at startup. Zero changes to core.classOAKDPlugin(SensorPlugin):
defname(self): return'oakd'defprocess(self, raw_data):
return ProcessedData(...)
pipeline = PerceptionPipeline()
pipeline.register_sensor(RealSensePlugin())
pipeline.register_sensor(OAKDPlugin()) # No core code changed
Don't force modules to depend on interfaces they don't use. Many small interfaces beat one large one.
Why it matters in robotics: A simple 1-DOF gripper shouldn't implement a 6-DOF dexterous hand interface. A fixed camera shouldn't implement pan-tilt methods.
# ❌ BAD: Fat interface — every camera must implement ALL of theseclassCameraInterface(ABC):
@abstractmethoddefcapture_rgb(self) -> np.ndarray: ...
@abstractmethoddefcapture_depth(self) -> np.ndarray: ...
@abstractmethoddefcapture_pointcloud(self) -> np.ndarray: ...
@abstractmethoddefset_exposure(self, value: float): ...
@abstractmethoddefset_pan_tilt(self, pan: float, tilt: float): ...
@abstractmethoddefstream_video(self) -> Iterator[np.ndarray]: ...
# A simple USB webcam can't do half of these!# ✅ GOOD: Segregated interfaces — implement only what you supportclassRGBCamera(ABC):
"""Any camera that produces RGB images""" @abstractmethoddefcapture_rgb(self) -> np.ndarray: ...
@property @abstractmethoddefresolution(self) -> tuple: ...
classDepthCamera(ABC):
"""Cameras that also produce depth""" @abstractmethoddefcapture_depth(self) -> np.ndarray: ...
@abstractmethoddefget_depth_intrinsics(self) -> DepthIntrinsics: ...
classControllableCamera(ABC):
"""Cameras with adjustable settings""" @abstractmethoddefset_exposure(self, value: float): ...
@abstractmethoddefset_white_balance(self, value: float): ...
classPTZCamera(ABC):
"""Pan-tilt-zoom cameras""" @abstractmethoddefset_pan_tilt(self, pan: float, tilt: float): ...
@abstractmethoddefset_zoom(self, level: float): ...
# A RealSense implements RGB + Depth, but not PTZclassRealSenseD435(RGBCamera, DepthCamera, ControllableCamera):
defcapture_rgb(self): ...
defcapture_depth(self): ...
defset_exposure(self, value): ...
# No PTZ methods — it's not a PTZ camera!# A webcam implements only RGBclassUSBWebcam(RGBCamera):
defcapture_rgb(self): ...
# Nothing else required# Perception code that only needs RGB doesn't pull in depth dependenciesclassObjectDetector:
def__init__(self, camera: RGBCamera): # Only needs RGBself.camera = camera
defdetect(self) -> List[Detection]:
image = self.camera.capture_rgb()
returnself.model.predict(image)
Any implementation of an interface must be substitutable without the caller knowing. If your code works with ArmInterface, it must work with ANY arm that implements it.
Why it matters in robotics: Sim-to-real transfer, hardware swaps, and multi-robot support all depend on this.
# ❌ BAD: Violates substitution — caller must know the implementationclassFrankaArm(ArmInterface):
defmove_to_joints(self, positions, velocity=0.5):
iflen(positions) != 7:
raise ValueError("Franka has 7 joints!") # Franka-specific# ...classUR5Arm(ArmInterface):
defmove_to_joints(self, positions, velocity=0.5):
iflen(positions) != 6:
raise ValueError("UR5 has 6 joints!") # UR5-specific# ...# Caller must know which arm it's using to pass correct joint count!# This breaks substitutability.# ✅ GOOD: Self-describing implementationsclassArmInterface(ABC):
@property @abstractmethoddefnum_joints(self) -> int: ...
@property @abstractmethoddefjoint_limits(self) -> List[tuple]: ...
@abstractmethoddefmove_to_joints(self, positions: np.ndarray, velocity: float = 0.5) -> bool:
"""Positions must have length == self.num_joints"""
...
classFrankaArm(ArmInterface):
@propertydefnum_joints(self): return7defmove_to_joints(self, positions, velocity=0.5):
assertlen(positions) == self.num_joints
# ...# Caller code is generic — works with any armdefmove_to_home(arm: ArmInterface):
home = np.zeros(arm.num_joints) # Queries the arm, doesn't assume
arm.move_to_joints(home)
Substitution test: Take every line of caller code. Replace UR5 with Franka with SimArm. Does it still work? If not, your abstraction leaks.
Principle 6: Separation of Rates — Respect Timing Boundaries
Different subsystems run at different rates. Never couple them.
Component Typical Rate Criticality
─────────────────────────────────────────────────
Safety monitor 1000 Hz HARD real-time
Joint controller 500-1000 Hz HARD real-time
Trajectory exec 100-200 Hz Firm real-time
State estimation 50-200 Hz Firm real-time
Perception 10-30 Hz Soft real-time
Planning 1-10 Hz Best effort
Task management 0.1-1 Hz Best effort
Logging 1-30 Hz Best effort
UI/Dashboard 1-10 Hz Best effort
# ❌ BAD: Perception blocks the control loopclassRobot:
defcontrol_loop(self): # Must run at 100Hz = 10ms budget
image = self.camera.capture() # 5ms
objects = self.detector.detect(image) # 200ms ← BLOCKS!
pose = self.estimate_pose(objects) # 2ms
cmd = self.controller.compute(pose) # 0.1msself.arm.send_command(cmd) # 0.5ms# Total: 207ms. Control runs at 5Hz instead of 100Hz!# ✅ GOOD: Decoupled rates with async boundariesclassRobot:
def__init__(self):
self.latest_detections = []
self.detection_lock = threading.Lock()
# Perception runs in its own thread at its own rateself.perception_thread = threading.Thread(
target=self._perception_loop, daemon=True)
self.perception_thread.start()
def_perception_loop(self):
"""Runs at ~10Hz — as fast as the detector allows"""whileself.running:
image = self.camera.capture()
detections = self.detector.detect(image)
withself.detection_lock:
self.latest_detections = detections
defcontrol_loop(self):
"""Runs at 100Hz — NEVER blocked by perception"""
rate = Rate(100) # 10ms periodwhileself.running:
withself.detection_lock:
detections = self.latest_detections # Latest available
pose = self.estimate_pose(detections)
cmd = self.controller.compute(pose)
self.arm.send_command(cmd)
rate.sleep()
Rule: If subsystem A is slower than subsystem B, A must communicate to B via a buffer (topic, shared variable, queue) — never by direct call.
Principle 7: Fail-Safe Defaults — Safe Until Proven Otherwise
Every module should default to the safest possible behavior. Safety is not a feature you add — it's the default you degrade from.
# ❌ BAD: Unsafe defaultsclassArmController:
def__init__(self):
self.max_velocity = 3.14# Full speed by default!self.collision_check = False# Off by default!self.workspace_limits = None# No limits by default!# ✅ GOOD: Safe defaults — must explicitly opt into dangerclassArmController:
def__init__(self):
self.max_velocity = 0.1# Crawl speed by defaultself.collision_check = True# Always onself.workspace_limits = DEFAULT_SAFE_WORKSPACE # Conservative boxself.require_enable = True# Must be explicitly enabledself._enabled = Falsedefenable(self, operator_confirmed: bool = False):
"""Explicit enable step — requires operator confirmation for real hardware"""ifnot operator_confirmed andnotself.is_simulation:
raise SafetyError(
"Real hardware requires operator confirmation to enable")
self._enabled = Truedefmove_to(self, target: np.ndarray, velocity: float = None):
ifnotself._enabled:
raise SafetyError("Controller not enabled")
velocity = velocity orself.max_velocity
# Clamp velocity to safe range
velocity = min(velocity, self.max_velocity)
# Check workspace limits BEFORE movingifnotself.workspace_limits.contains(target):
raise WorkspaceViolation(f"Target {target} outside safe workspace")
# Check for collisions BEFORE movingifself.collision_check:
ifself.collision_detector.would_collide(target):
raise CollisionRisk(f"Collision predicted for target {target}")
returnself._execute_move(target, velocity)
The rule: What happens when a module receives no input, invalid input, or loses communication? It should stop safely, not continue blindly.
classSafetyDefaults:
"""Centralized safe defaults for the entire system"""# Communication loss → stop
HEARTBEAT_TIMEOUT_MS = 500
ACTION_ON_TIMEOUT = 'stop'# Not 'continue_last_command'# Unknown state → stop
ACTION_ON_UNKNOWN_STATE = 'stop'# Not 'assume_safe'# Sensor failure → stop
ACTION_ON_SENSOR_LOSS = 'stop'# Not 'use_last_reading'# Joint limit approach → slow down
JOINT_LIMIT_MARGIN_RAD = 0.05# Stop 0.05 rad before limit
VELOCITY_NEAR_LIMITS = 0.05# Crawl near limits# Default workspace (conservative bounding box)
WORKSPACE_MIN = np.array([-0.5, -0.5, 0.0]) # meters
WORKSPACE_MAX = np.array([0.5, 0.5, 0.8]) # meters
Principle 8: Configuration Over Code — Externalize Everything That Changes
Anything that might differ between deployments, robots, or environments should be in configuration, not code.
What goes in config: robot IP addresses, joint limits, sensor parameters, safety thresholds, workspace boundaries, task-specific constants, file paths, feature flags.
What stays in code: algorithms, control logic, data structures, interface definitions, error handling.
Principle 9: Idempotent Operations — Safe to Retry
Every command should be safe to send twice. Network drops, message duplicates, and retries are facts of life in robotics.
# ❌ BAD: Non-idempotent — sending twice moves the robot twice as fardefmove_relative(self, delta: np.ndarray):
current = self.get_position()
self.move_to(current + delta)
# If this message is sent twice due to a retry,# the robot moves 2x the intended distance!# ✅ GOOD: Idempotent — sending twice has the same effect as oncedefmove_to_absolute(self, target: np.ndarray, command_id: str):
if command_id == self._last_executed_command:
return# Already executed this command, skipself._last_executed_command = command_id
self.move_to(target)
# Sending this twice is harmless — same target, same result# ✅ GOOD: Idempotent gripper commandsdefset_gripper(self, width: float):
"""Set gripper to absolute width — not open/close toggle"""self.gripper.move_to_width(width)
# Calling set_gripper(0.04) ten times still results in 0.04m width
Principle 10: Observe Everything — You Can't Debug What You Can't See
Every module should emit structured telemetry. When a robot behaves unexpectedly at 2 AM, logs are all you have.