DEV Community

Cover image for SOLID Principles: Explained with Golang Examples
Ansu Jain
Ansu Jain

Posted on

SOLID Principles: Explained with Golang Examples

As software systems become more complex, it’s important to write code that is modular, flexible, and easy to understand. One way to achieve this is by following SOLID principles. These principles were introduced by Robert C. Martin to help developers create code that is easier to maintain, test, and extend.

This article will provide an overview of each SOLID principle and illustrate their application in a trade ecosystem through examples written in Golang.

Single Responsibility Principle (SRP):This principle states that a class should have only one reason to change. If we violate this principle, the class will have multiple responsibilities, making it harder to maintain, test and extend. This can lead to code that is tightly coupled, difficult to reuse, and prone to errors.

In a trade ecosystem, a Trade class should be responsible for storing and processing trade data. Another class, like TradeValidator, could be responsible for validating trades based on business rules. By separating these concerns, each class can be more easily tested and maintained.

type Trade struct {
    TradeID int
    Symbol string
    Quantity float64
    Price float64
}

type TradeRepository struct {
    db *sql.DB
}

func (tr *TradeRepository) Save(trade *Trade) error {
    _, err := tr.db.Exec("INSERT INTO trades (trade_id, symbol, quantity, price) VALUES (?, ?, ?, ?)", trade.TradeID, trade.Symbol, trade.Quantity, trade.Price)
    if err != nil {
        return err
    }
    return nil
}

type TradeValidator struct {}

func (tv *TradeValidator) Validate(trade *Trade) error {
    if trade.Quantity <= 0 {
        return errors.New("Trade quantity must be greater than zero")
    }
    if trade.Price <= 0 {
        return errors.New("Trade price must be greater than zero")
    }
    return nil
}

Enter fullscreen mode Exit fullscreen mode

Open-Closed Principle (OCP): This principle states that classes should be open for extension but closed for modification. If we violate this principle, we may have to modify existing code to add new functionality, which can introduce bugs and make it difficult to maintain the code. This can also result in code that is difficult to test and reuse.

In a trade ecosystem, a TradeProcessor class should be designed to be open for extension but closed for modification. This means that if new trade types are added, the TradeProcessor class should be able to handle them without needing to modify the existing code. This can be achieved by defining an interface for processing trades and implementing it for each trade type.

type TradeProcessor interface {
    Process(trade *Trade) error
}

type FutureTradeProcessor struct {}

func (ftp *FutureTradeProcessor) Process(trade *Trade) error {
    // process future trade
    return nil
}

type OptionTradeProcessor struct {}

func (otp *OptionTradeProcessor) Process(trade *Trade) error {
    // process option trade
    return nil
}

Enter fullscreen mode Exit fullscreen mode

Liskov Substitution Principle (LSP): This principle states that subtypes should be substitutable for their base types. If we violate this principle, we may introduce behavior that is unexpected and inconsistent, which can lead to errors that are difficult to track down. This can also make it difficult to write code that works with a variety of different types.

In a trade ecosystem, a FutureTrade class should be a subtype of a Trade class, which means that it should be able to be used in place of the Trade class without causing any issues. For example, if a TradeProcessor class expects a Trade object but receives a FutureTrade object, it should still be able to process the trade without any issues.

type Trade interface {
    Process() error
}

type FutureTrade struct {
    Trade
}

func (ft *FutureTrade) Process() error {
    // process future trade
    return nil
}

Enter fullscreen mode Exit fullscreen mode

Interface Segregation Principle (ISP): This principle states that clients should not be forced to depend on interfaces they do not use. If we violate this principle, we may have interfaces that are too large and contain methods that are not relevant to some clients, which can lead to code that is difficult to understand and maintain. This can also result in code that is not reusable, and that can cause unnecessary coupling between modules.

In a trade ecosystem, a Trade interface should only include methods that are relevant to all types of trades. Additional interfaces, like OptionTrade or FutureTrade, could be created to include methods that are specific to those trade types. This way, code that only needs to work with a specific type of trade can depend on the appropriate interface, rather than a larger interface that includes unnecessary methods.

type Trade interface {
    Process() error
}

type OptionTrade interface {
    CalculateImpliedVolatility() error
}

type FutureTrade struct {
    Trade
}

func (ft *FutureTrade) Process() error {
    // process future trade
    return nil
}

type OptionTrade struct {
    Trade
}

func (ot *OptionTrade) Process() error {
    // process option trade
    return nil
}

func (ot *OptionTrade) CalculateImpliedVolatility() error {
    // calculate implied volatility
    return nil
}

Enter fullscreen mode Exit fullscreen mode

Dependency Inversion Principle (DIP): This principle states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. If we violate this principle, we may have code that is difficult to test and reuse, and that is tightly coupled. This can also result in code that is difficult to maintain and extend.

In a trade ecosystem, a TradeProcessor class should depend on an interface, like TradeService, rather than a concrete implementation, like SqlServerTradeRepository. This way, different implementations of the TradeService interface can be swapped in and out without affecting the TradeProcessor class, which can make it easier to maintain and test. For example, a MongoDBTradeRepository could be used instead of the SqlServerTradeRepository without modifying the TradeProcessor class.

type TradeService interface {
    Save(trade *Trade) error
}

type TradeProcessor struct {
    tradeService TradeService
}

func (tp *TradeProcessor) Process(trade *Trade) error {
    err := tp.tradeService.Save(trade)
    if err != nil {
        return err
    }
    // process trade
    return nil
}

type SqlServerTradeRepository struct {
    db *sql.DB
}

func (str *SqlServerTradeRepository) Save(trade *Trade) error {
    _, err := str.db.Exec("INSERT INTO trades (trade_id, symbol, quantity, price) VALUES (?, ?, ?, ?)", trade.TradeID, trade.Symbol, trade.Quantity, trade.Price)
    if err != nil {
        return err
    }
    return nil
}

type MongoDbTradeRepository struct {
    session *mgo.Session
}

func (mdtr *MongoDbTradeRepository) Save(trade *Trade) error {
    collection := mdtr.session.DB("trades").C("trade")
    err := collection.Insert(trade)
    if err != nil {
        return err
    }
    return nil
}

Enter fullscreen mode Exit fullscreen mode

In summary, if we don’t use SOLID principles, we may end up with code that is difficult to maintain, test, and reuse. This can lead to bugs, poor performance, and an inability to add new features to the code. By following these principles, we can create code that is more modular, flexible, and easier to understand, which can lead to better software overall.

Thanks for reading this article. Hope you would have liked it!. Please clap, share and follow me to support.

Oldest comments (3)

Collapse
 
benbpyle profile image
Benjamen Pyle

Nice article and well explained.

Collapse
 
agusescobar profile image
agustin

nice article :)

Collapse
 
amittendulkar profile image
Amit Tendulkar

The Interface Segregation Principle example is confusing. It is using OptionTrade as an interface as well as a struct. Can you please correct it?