The SOLID principle are a set of five designs guidelines that help developers create more maintainable, flexible, and scalable software.
Here's an overview of SOLID principles:
A class should have one, and only one, reason to change.
- Before Implementation
type OrderServiceNotSOP struct {
Orders []entity.Order
}
func (o *OrderServiceNotSOP) PlaceOrder(order entity.Order) {
o.Orders = append(o.Orders, order)
}
func (o *OrderServiceNotSOP) GenerateInvoice(order entity.Order) {
fmt.Printf("Generate Invoice For Order ID: %s, User ID: %d and Total Amount: %f", order.OrderID, order.UserID, order.TotalAmount)
}
OrderServiceNotSOP has two responsibilities: handling orders and generating invoices.
- After Implementation
type OrderServiceSOP struct {
Orders []entity.Order
}
func (o *OrderServiceSOP) PlaceOrder(order entity.Order) {
o.Orders = append(o.Orders, order)
}
type InvoiceGeneratorSOP struct{}
func (i *InvoiceGeneratorSOP) GenerateInvoice(order entity.Order) {
fmt.Printf("Generate Invoice For Order ID: %s, User ID: %d and Total Amount: %f", order.OrderID, order.UserID, order.TotalAmount)
}
Now, the OrderServiceSOP is solely responsible for managing orders, while the InvoiceGeneratorSOP takes care of generating invoices.
Software entities should be open for extension but closed for modification.
- Before Implementation
func ApplyDiscount(product entity.Product, isVIP bool) float64 {
if isVIP {
return product.Price * 0.20 // 20% discount for VIP
}
return product.Price * 0.10 // 10% discount otherwise
}
Imagine you have a simple discount system that applies a 10% discount to all products. Later, you want to add a special 20% discount for VIP customers.
- After Implementation
type DiscountRule interface {
ApplyDiscount(product entity.Product) float64
}
type StandardDiscount struct{}
func (sd StandardDiscount) ApplyDiscount(product entity.Product) float64 {
return product.Price * 0.10 // 10% discount
}
type VIPDiscount struct{}
func (vd VIPDiscount) ApplyDiscount(product entity.Product) float64 {
return product.Price * 0.20 // 20% discount for VIP
}
func CalculatePrice(product entity.Product, rule DiscountRule) float64 {
return product.Price - rule.ApplyDiscount(product)
}
Now, when you want to add a new discount rule, you simply create a new type that implements the DiscountRule
interface. The existing code doesn't have to change, adhering to OCP.
Subtypes must be substitutable for their base types without altering the correctness of the program.
type ProductNotLSP interface {
ApplyDiscount()
}
type DiscountedProductNotLSP struct {
Name string
Price float64
}
func (dp *DiscountedProductNotLSP) ApplyDiscount() {
dp.Price *= 0.90 // 10% discount
}
type RegularProductNotLSP struct {
Name string
Price float64
}
func (rp *RegularProductNotLSP) ApplyDiscount() {
// No discount for regular products
}
func ApplyDiscountsNotLSP(products []ProductNotLSP) {
for _, product := range products {
product.ApplyDiscount()
}
}
The issue here is that this design doesn't make it clear which products are eligible for a discount, leading to confusion and potential errors. If a new category is added, the discount logic might need to change in many places.
- After Implementation
type Discountable interface {
ApplyDiscount()
}
type ProductLSP struct {
Name string
Price float64
}
type DiscountedProductLSP struct {
ProductLSP
}
func (dp *DiscountedProductLSP) ApplyDiscount() {
dp.Price *= 0.90 // 10% discount
}
func ApplyDiscounts(products []Discountable) {
for _, product := range products {
product.ApplyDiscount()
}
}
RegularProductLSP
is excluded from the discountable behavior by not implementing the Discountable
interface. This design clearly separates products that can have discounts from those that cannot, reducing confusion and making the code easier to maintain and extend.
Let's breakdown the code based on the definition:
- Base types (Interface):
Discountable
interface. - Struct (Possible Subtype):
ProductLSP
struct. - Embedded Struct (Subtype): The
DiscountedProductLSP
struct embedsProductLSP
. This effectively meansDiscountedProductLSP
inherits properties fromProductLSP
and then adds its own functionality, specifically theApplyDiscount()
method, making it adhere to theDiscountable
interface. Hence,DiscountedProductLSP
is a subtype of theDiscountable
interface.
Given the LSP, you should be able to replace an instance of the Discountable
interface (base type) with an instance of DiscountedProductLSP
(subtype) without affecting the program's correctness.
func ApplyDiscounts(products []Discountable) {
for _, product := range products {
product.ApplyDiscount()
}
}
You can pass a slice of Discountable
items, and as long as every item in that slice adheres to the Discountable
interface (i.e., it has an ApplyDiscount()
method), the function will work correctly. Here, DiscountedProductLSP
can be used as a substitutable type for Discountable
because it implements the required method.
This means that, if in the future you introduce another product type with a different discount strategy (say, a ClearanceProduct
), as long as it implements the Discountable interface, you can pass it to the ApplyDiscounts
function without any issues. This is the essence of the Liskov Substitution Principle.
No client should be forced to depend on interfaces it does not use.
- Before Implementation
type UserManagerNotISP interface {
Register(username, password string) error
Login(username, password string) (*entity.User, error)
UpdateProfile(userID int, profile entity.Profile) error
DeleteAccount(userID int) error
GetUserOrders(userID int) ([]entity.Order, error)
}
UserManagerNotISP
interface handles everything related to a user - registration, login, profile updating, account deletion, and fetching orders.
If a component is only interested in handling the registration, it still has to know about other methods, which is unnecessary.
- After Implementation
type UserRegistrar interface {
Register(username, password string) error
}
type UserAuthenticator interface {
Login(username, password string) (*entity.User, error)
}
type UserProfileManager interface {
UpdateProfile(userID int, profile entity.Profile) error
}
type UserAccountManager interface {
DeleteAccount(userID int) error
}
type UserOrderViewer interface {
GetUserOrders(userID int) ([]entity.Order, error)
}
With this breakdown, every component or module in the system can depend solely on the aspects it truly requires, which makes the application cleaner, more maintainable, and potentially less error-prone.
High-level modules should not depend on low-level modules. Both should depend on abstractions.
- Before Implementation
type EmailNotifierNotDIP struct {
// some fields for email configuration
}
func (en *EmailNotifierNotDIP) SendEmail(to string, subject string, body string) {
// logic to send an email
}
type OrderProcessorNotDIP struct {
notifier EmailNotifierNotDIP
}
func (op *OrderProcessorNotDIP) CompletePurchase(userEmail string, product string) {
// logic to complete purchase
op.notifier.SendEmail(userEmail, "Purchase Completed", "Thank you for purchasing "+product)
}
The OrderProcessor
is tightly coupled with the EmailNotifier
.
If we decide to notify the user through another medium, like SMS or push notifications, this design would require significant changes to OrderProcessor
.
- After Implementation
type Notifier interface {
Notify(to string, subject string, message string)
}
type EmailNotifier struct {
// some fields for email configuration
}
func (en *EmailNotifier) Notify(to string, subject string, message string) {
// logic to send an email
}
type SMSNotifier struct {
// some fields for SMS configuration
}
func (sn *SMSNotifier) Notify(to string, subject string, message string) {
// logic to send an SMS (Note: In reality, SMS might not have a "subject", this is just for illustrative purposes)
}
type OrderProcessor struct {
notifier Notifier // depends on the interface, not concrete implementation
}
func (op *OrderProcessor) CompletePurchase(userContact string, product string) {
// logic to complete purchase
op.notifier.Notify(userContact, "Purchase Completed", "Thank you for purchasing "+product)
}
The OrderProcessor
depends on the abstraction (Notifier
) and not on the concrete implementation (EmailNotifier
or SMSNotifier
).
If we decide to change our notification method, or even introduce a new one, we can do so easily without altering OrderProcessor
— we simply introduce a new implementation of the Notifier interface.