In the previous article of the "Mastering Code Design" series, we explored the foundations of building robust software. We touched upon the importance of software architecture and introduced key principles like SOLID and design patterns that can guide you toward creating resilient and maintainable systems. Today, we will delve deeper into the world of best practices with a special focus on the SOLID principles through a practical example to illustrate their significance.
The Importance of Best Practices
Adhering to best practices in software design is crucial for creating systems that are not only functional but also maintainable, scalable, and adaptable to change. Best practices serve as a blueprint for developers, providing them with a roadmap to solve common design challenges effectively. When these practices are ignored, systems can become fragile, tightly coupled, and difficult to extend or modify, leading to higher maintenance costs and an increased likelihood of bugs.
Introducing SOLID Principles
One of the cornerstone sets of best practices in software development is the SOLID principles. These principles offer robust guidelines for object-oriented design and programming. Here's a brief overview of each principle:
- Single Responsibility Principle (SRP): A class should have one, and only one, reason to change, meaning it should have only one job or responsibility.
- Open/Closed Principle (OCP): Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
- Liskov Substitution Principle (LSP): Objects of a superclass should be replaceable with objects of a subclass without affecting the functioning of the program.
- Interface Segregation Principle (ISP): No client should be forced to depend on methods it does not use; interfaces should be specific to the clients that use them.
- Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
Although created for object-oriented programming, the SOLID principles can be applied even in languages that do not support OOP, such as Go.
To illustrate how adhering to the SOLID principles can transform code, let’s walk through a practical example using the Go programming language.
Example in Go: Measuring Power Factor
Contextualization
Electrical power is a complex variable: S = P + Qi, where S is apparent power, P is active power, and Q is the reactive power. For better illustration, see the image below:
Where:
- P is the active power, the portion of electrical energy transformed into work.
- Q is the reactive power, the portion of electrical energy stored in magnetic/electric fields.
- S is the apparent power, which is the total power supplied by the power company.
The power factor (PF = cos θ = P / S) is the ratio between active power and apparent power. Ideally, the power factor is 1, meaning all apparent power is active. The presence of reactive power decreases the power factor. A power factor less than 1 damages the entire energy distribution network, causing distortions and harmonics. Power companies, supported by legislation, can apply fees to customers that worsen the energy quality by decreasing the power factor.
Fortunately, there is a way to compensate for the reactive power and increase the power factor. Reactive power can be inductive or capacitive. One important point is that the inductive power phase is +90°, while capacitive is -90° relative to active power. The 180° between them means that they subtract each other, so by convention, we say inductive is positive and capacitive is negative.
Usually, the power factor of an industry is affected by electric motors (inductive), so to correct the power factor, we need a capacitive load to compensate. There are several alternative solutions:
- Capacitor banks: The most common solution, compensating for inductive loads.
- Synchronous condensers: Used in larger applications.
- Active power factor correction (PFC) devices: Used when there are significant harmonic distortions.
Each solution depends on budget and scale.
Problem Scenario
A company is interested in monitoring the power factor of its electrical loads to decide the best approach for correcting the power factor. Several power meters were installed across the plant, and data were collected. The power meters return the power in terms of active and reactive power (rectangular form).
Software Engineers were asked to write a program that collects measurements from power meters and generates a JSON file in the following format:
{
"measurements": [
{"power_meter_id": 1, "apparent_power": 600.4, "power_factor": 0.7},
{"power_meter_id": 2, "apparent_power": 398.5, "power_factor": 0.8},
{"power_meter_id": 3, "apparent_power": 779.3, "power_factor": 0.6}
]
}
Let's tell this story in three chapters.
First Chapter
The initial implementation solved the problem, but did not adhere to SOLID principles.
package main
import (
"encoding/json"
"fmt"
"math"
)
type Measurement struct {
PowerMeterId int
ActivePower float64
ReactivePower float64
}
type ProcessedMeasurement struct {
PowerMeterId int `json:"power_meter_id"`
ApparentPower float64 `json:"apparent_power"`
PowerFactor float64 `json:"power_factor"`
}
func CollectMeasurements() []Measurement {
// For the purpose of the example, we are returning fake data
return []Measurement{
{PowerMeterId: 1, ActivePower: 420.28, ReactivePower: 429.92},
{PowerMeterId: 2, ActivePower: 318.8, ReactivePower: 238.65},
{PowerMeterId: 3, ActivePower: 238.65, ReactivePower: 614.94},
}
}
func ProcessMeasurements(measurements []Measurement) (string, error) {
processedMeasurements := []ProcessedMeasurement{}
for _, measurement := range measurements {
p := measurement.ActivePower
q := measurement.ReactivePower
s := math.Sqrt(p*p + q*q)
powerFactor := p / s
processedMeasurement := ProcessedMeasurement{
PowerMeterId: measurement.PowerMeterId,
ApparentPower: s,
PowerFactor: powerFactor,
}
processedMeasurements = append(processedMeasurements, processedMeasurement)
}
data := struct {
Measurements []ProcessedMeasurement `json:"measurements"`
}{Measurements: processedMeasurements}
jsonData, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(jsonData), nil
}
func main() {
measurements := CollectMeasurements()
jsonString, err := ProcessMeasurements(measurements)
if err != nil {
fmt.Println("Error processing measurements:", err)
return
}
fmt.Printf("Result: %s", jsonString)
}
Nice, right?
Actually Not. Although the code looks nice (Go syntax contributes to this), it’s not adhering to SOLID principles. Here are some points:
-
Single Responsibility Principle (SRP): The function
ProcessMeasurements
has too many responsibilities: it’s calculating, preparing the struct, and converting to JSON. If we decide, for example, to return the result in other formats (YAML, XML, CSV), we get into trouble. -
Open/Closed Principle (OCP): What would happen if we install a new power meter whose measurement is not compatible with the
Measurement
struct? Several parts of the code should be adapted. That means the code is neither open to extension nor closed to modification. - Liskov Substitution Principle (LSP): Go has no inheritance, but LSP can be implemented by interfaces. However, the entire solution is not implementing interfaces.
- Interface Segregation Principle (ISP): There are no interfaces in the code.
-
Dependency Inversion Principle (DIP): There are no interfaces in the code, and
ProcessMeasurements
is deeply dependent on the Measurement struct.
Second Chapter
Some power meters broke and were replaced by different models. The new models measure the power in polar form (apparent power and phase angle in radians). Here’s the new version of the code.
package main
import (
"encoding/json"
"fmt"
"math"
)
type MeasurementRectangular struct {
PowerMeterId int
ActivePower float64
ReactivePower float64
}
type MeasurementPolar struct {
PowerMeterId int
ApparentPower float64
PhaseRadians float64
}
type ProcessedMeasurement struct {
PowerMeterId int `json:"power_meter_id"`
ApparentPower float64 `json:"apparent_power"`
PowerFactor float64 `json:"power_factor"`
}
func CollectMeasurementsRectangular() []MeasurementRectangular {
// For the purpose of the example, we are returning fake data
return []MeasurementRectangular{
{PowerMeterId: 1, ActivePower: 420.28, ReactivePower: 429.92},
{PowerMeterId: 2, ActivePower: 318.8, ReactivePower: 238.65},
{PowerMeterId: 3, ActivePower: 238.65, ReactivePower: 614.94},
}
}
func CollectMeasurementsPolar() []MeasurementPolar {
// For the purpose of the example, we are returning fake data
return []MeasurementPolar{
{PowerMeterId: 4, ApparentPower: 602.48, PhaseRadians: 0.80},
{PowerMeterId: 5, ApparentPower: 398.61, PhaseRadians: 0.64},
{PowerMeterId: 6, ApparentPower: 661.32, PhaseRadians: 1.19},
}
}
func ProcessMeasurements(
rect []MeasurementRectangular,
polar []MeasurementPolar,
) (string, error) {
processedMeasurements := []ProcessedMeasurement{}
for _, measurement := range rect {
p := measurement.ActivePower
q := measurement.ReactivePower
s := math.Sqrt(p*p + q*q)
powerFactor := p / s
processedMeasurement := ProcessedMeasurement{
PowerMeterId: measurement.PowerMeterId,
ApparentPower: s,
PowerFactor: powerFactor,
}
processedMeasurements = append(processedMeasurements, processedMeasurement)
}
for _, measurement := range polar {
s := measurement.ApparentPower
powerFactor := math.Cos(measurement.PhaseRadians)
processedMeasurement := ProcessedMeasurement{
PowerMeterId: measurement.PowerMeterId,
ApparentPower: s,
PowerFactor: powerFactor,
}
processedMeasurements = append(processedMeasurements, processedMeasurement)
}
data := struct {
Measurements []ProcessedMeasurement `json:"measurements"`
}{Measurements: processedMeasurements}
jsonData, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(jsonData), nil
}
func main() {
measurementsRectangular := CollectMeasurementsRectangular()
measurementsPolar := CollectMeasurementsPolar()
jsonString, err := ProcessMeasurements(measurementsRectangular, measurementsPolar)
if err != nil {
fmt.Println("Error processing measurements:", err)
return
}
fmt.Printf("Result: %s", jsonString)
}
Do you see how the original code had to change due to a new customer requirement? Here are the changes:
-
Measurement
struct was transformed intoMeasurementRectangular
andMeasurementPolar
. -
ProcessMeasurements
function received new arguments to be compatible with the new measurement format. -
ProcessMeasurements
code was increased to support the new measurement format.
Although well-structured, the code was dramatically changed due to a simple new requirement. Code that doesn’t adhere to SOLID principles grows this way and sometimes becomes impractical to maintain.
Third Chapter
The customer is happy with the system and shared with the development team some features that should be incorporated soon:
- Support for power meters in polar mode, but with a phase angle in degrees.
- Support for power meters that inform the power factor directly.
- Support for YAML format as output.
- Support for XML format as output.
- Support to write measurements in a time series DB.
Can you imagine how this code will look in the future? Unless every single change is made carefully, the code will become a mess. The team knows that, so they decide to refactor the code, adhering to all SOLID principles:
package main
import (
"encoding/json"
"fmt"
"math"
)
type Measurement interface {
GetPowerMeterId() int
GetApparentPower() float64
GetPowerFactor() float64
}
type MeasurementRectangular struct {
PowerMeterId int
ActivePower float64
ReactivePower float64
}
func (mr *MeasurementRectangular) GetPowerMeterId() int {
return mr.PowerMeterId
}
func (mr *MeasurementRectangular) GetApparentPower() float64 {
p := mr.ActivePower
q := mr.ReactivePower
return math.Sqrt(p*p + q*q)
}
func (mr *MeasurementRectangular) GetPowerFactor() float64 {
p := mr.ActivePower
s := mr.GetApparentPower()
if s == 0 {
return 0
} else {
return p / s
}
}
type MeasurementPolar struct {
PowerMeterId int
ApparentPower float64
PhaseRadians float64
}
func (mp *MeasurementPolar) GetPowerMeterId() int {
return mp.PowerMeterId
}
func (mp *MeasurementPolar) GetApparentPower() float64 {
return mp.ApparentPower
}
func (mp *MeasurementPolar) GetPowerFactor() float64 {
return math.Cos(mp.PhaseRadians)
}
type ProcessedMeasurement struct {
PowerMeterId int `json:"power_meter_id"`
ApparentPower float64 `json:"apparent_power"`
PowerFactor float64 `json:"power_factor"`
}
func CollectMeasurements() []Measurement {
// For the purpose of the example, we are returning fake data
return []Measurement{
&MeasurementRectangular{PowerMeterId: 1, ActivePower: 420.28, ReactivePower: 429.92},
&MeasurementRectangular{PowerMeterId: 2, ActivePower: 318.8, ReactivePower: 238.65},
&MeasurementRectangular{PowerMeterId: 3, ActivePower: 238.65, ReactivePower: 614.94},
&MeasurementPolar{PowerMeterId: 4, ApparentPower: 602.48, PhaseRadians: 0.80},
&MeasurementPolar{PowerMeterId: 5, ApparentPower: 398.61, PhaseRadians: 0.64},
&MeasurementPolar{PowerMeterId: 6, ApparentPower: 661.32, PhaseRadians: 1.19},
}
}
func ProcessMeasurements(measurements []Measurement) []ProcessedMeasurement {
processedMeasurements := []ProcessedMeasurement{}
for _, measurement := range measurements {
processedMeasurement := ProcessedMeasurement{
PowerMeterId: measurement.GetPowerMeterId(),
ApparentPower: measurement.GetApparentPower(),
PowerFactor: measurement.GetPowerFactor(),
}
processedMeasurements = append(processedMeasurements, processedMeasurement)
}
return processedMeasurements
}
func ConvertProcessedMeasurementsToJSON(processedMeasurements []ProcessedMeasurement) (string, error) {
data := struct {
Measurements []ProcessedMeasurement `json:"measurements"`
}{Measurements: processedMeasurements}
jsonData, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(jsonData), nil
}
func main() {
measurements := CollectMeasurements()
processedMeasurements := ProcessMeasurements(measurements)
jsonString, err := ConvertProcessedMeasurementsToJSON(processedMeasurements)
if err != nil {
fmt.Println("Error converting processed measurements to JSON:", err)
return
}
fmt.Printf("Result: %s", jsonString)
}
Do you see what happened? Now the code is much better, the new requirements can be easily met, and developers can work in parallel without conflicts. None of the new requirements will require changes in the current structure, but just extensions (creation of new structs, methods, and functions).
Let's review the SOLID principles for this new approach:
- Single Responsibility Principle (SRP): Functions/methods are short and do exactly one thing.
- Open/Closed Principle (OCP): The system can be extended (e.g., inclusion of new power meters and output formats) without changing the currently implemented functions, but by creating new structs, methods, and functions.
- Liskov Substitution Principle (LSP): We can easily substitute power meter models.
-
Interface Segregation Principle (ISP): While not directly illustrated, we can envision a use case where only power meter IDs are needed by a function or module. In such cases, a new interface could be created only with the
GetPowerMeterId
method. -
Dependency Inversion Principle (DIP):
ProcessMeasurements
(higher level) depends on theMeasurement
interface, not lower-level structs anymore.
Conclusion
Following SOLID principles significantly improves code quality by promoting better organization, maintainability, and scalability. As shown through the evolution of the power factor measurement system, adhering to these principles allows for easier integration of new features with minimal disruption to the existing codebase. By using interfaces and adhering to SRP, OCP, LSP, ISP, and DIP, your code becomes more modular and adaptable to future changes. Ultimately, SOLID principles lead to cleaner, more efficient, and robust software that can stand the test of time.
Top comments (0)