Mastering SOLID Principles for Maintainable Software

Vicky Maulana
6 min readFeb 27, 2024

--

If you are familiar with Object Oriented Programming, then you must have encountered the term “SOLID principles”. These principles are like secret rules that help developers build software that’s easy to keep up with and improve over time. But what exactly are they, and why are they so important for big software projects?

This article explores SOLID design principles with simple explanations and demonstrates their application using my current group project, ‘Nani,’ from my software development course.

The Five SOLID “Rules”: SOLID is a fancy way of remembering five key design principles:

1. Single Responsibility:

Rules: Each piece of code (called a “class”) should only have one job. This means it’s clear what the code does, and it doesn’t try to do too many things at once.

In the example below, The TriviaApi class has a single, well-defined responsibility: fetching trivia data from an API. It doesn’t concern itself with other tasks like data storage, UI interactions, or other unrelated logic. This makes it easier to understand, maintain, and test.

class TriviaApi {
// dio instance
final DioClient _dioClient;

// injecting dio instance
TriviaApi(this._dioClient);

/// Returns list of post in response
Future<TriviaList> getTrivia() async {
try {
final res = await _dioClient.dio.get(Endpoints.getTrivia);
return TriviaList.fromJson(res.data);
} catch (e) {
print(e.toString());
rethrow;
}
}
}

2. Open / Close:

Rules: Your code should be easy to add new features to, but hard to change the existing parts. Imagine building with building blocks, you can add new blocks without needing to change the old ones.

In the example below the TriviaRepository abstract class defines a contract for fetching trivia data, acting as an interface that can be extended for future modifications. The TriviaRepositoryImpl class implements the abstract class, providing a specific implementation for fetching trivia using both a REST API and a local data source.

  • Open for Extension: If a new data source needs to be added (e.g., a local cache), you can create a new class that implements the TriviaRepository interface, providing a different implementation for getTrivia(). This allows for extensions without changing the existing code that relies on the interface.
  • Closed for Modification: Changes to the trivia fetching logic can be made within the TriviaRepositoryImpl class without affecting the abstract TriviaRepository or other parts of the code that depend on it. This isolation protects against unintended side effects.
abstract class TriviaRepository {
Future<TriviaList> getTrivia();
}


class TriviaRepositoryImpl extends TriviaRepository {
// data source object
final TriviaDataSource _triviaDataSource;

// api objects
final TriviaApi _triviaApi;

// constructor
TriviaRepositoryImpl(this._triviaApi, this._triviaDataSource);

// Post: ---------------------------------------------------------------------
@override
Future<TriviaList> getTrivia() async {
// check to see if posts are present in database, then fetch from database
// else make a network call to get all posts, store them into database for
// later use
return await _triviaApi.getTrivia().then((triviaList) {
triviaList.trivias?.forEach((trivia) {
_triviaDataSource.insert(trivia);
});

return triviaList;
}).catchError((error) => throw error);
}
}

3. Liskov Substitution:

Rules: If you have different types of code (called “subtypes” or “subclass”) that inherit from another type (called a “base type”), you should be able to use them interchangeably without causing problems. Think of it like having different types of tools (hammers, screwdivers) that all fit in the same toolbox (the base type).

LSP emphasizes that subclasses should uphold the contract established by their parent class. In this case, GetTriviaUseCase must abide by the call() method's signature and behavior defined in UseCase. Objects of a subclass should be seamlessly replaceable with objects of their superclass without affecting the correctness of the program. In this example, any code that interacts with UseCase should function correctly even if a GetTriviaUseCase object is passed instead.

abstract class UseCase<T, P> {
FutureOr<T> call({ required P params});
}


class GetTriviaUseCase extends UseCase<TriviaList, void> {
final TriviaRepository _triviaRepository;

GetTriviaUseCase(this._triviaRepository);

@override
Future<TriviaList> call({required params}) {
return _triviaRepository.getTrivia();
}
}

4. Inteface Segregation:

Rules: Don’t force code to rely on features it doesn’t need. Imagine having different tools in your toolbox, but you only need to carry the ones you’re using for a specific job. This keeps things simple and organized.

The code below defines three distinct interfaces, each with a focused responsibility:

  • TriviaRetrievalUseCase: Solely for retrieving a list of TriviaObject.
  • TriviaDetailsUseCase: Exclusively for getting details of a specific TriviaObject.
  • TriviaFavoritingUseCase: Dedicated to marking a TriviaObject as favorite.

Clients (other parts of the code) only depend on the interfaces that align with their specific needs:

  • A component that displays a list of trivia would only depend on TriviaRetrievalUseCase.
  • A component that shows detailed trivia information would depend on TriviaDetailsUseCase.
  • A component that handles favoriting would depend on TriviaFavoritingUseCase.
abstract class TriviaRetrievalUseCase {
Future<List<TriviaObject>> getTrivia();
}

abstract class TriviaDetailsUseCase {
Future<TriviaObject> getTriviaDetails(int triviaId);
}

abstract class TriviaFavoritingUseCase {
Future<void> markTriviaAsFavorite(TriviaObject trivia);
}

// Implementation for TriviaRetrievalUseCase
class GetRandomTriviaUseCase implements TriviaRetrievalUseCase {
final TriviaRepository _triviaRepository;

GetRandomTriviaUseCase(this._triviaRepository);

@override
Future<List<TriviaObject>> getTrivia() {
return _triviaRepository.getRandomTrivia();
}
}

// Implementation for TriviaDetailsUseCase
class GetSpecificTriviaDetailsUseCase implements TriviaDetailsUseCase {
final TriviaRepository _triviaRepository;

GetSpecificTriviaDetailsUseCase(this._triviaRepository);

@override
Future<TriviaObject> getTriviaDetails(int triviaId) {
return _triviaRepository.getTriviaDetails(triviaId);
}
}

// Implementation for TriviaFavoritingUseCase
class FavoriteTriviaUseCase implements TriviaFavoritingUseCase {
final UserRepository _userRepository;

FavoriteTriviaUseCase(this._userRepository);

@override
Future<void> markTriviaAsFavorite(TriviaObject trivia) async {
await _userRepository.addToFavorites(trivia);
}
}

5. Dependency Inversion:

Rules: High-level code (like the main parts of your program) shouldn’t depend on low-level code (like specific details). Instead, they should both rely on general guidelines (called “abstraction”). This makes your code more flexible and easier to test.

In this example below, the TriviaStore can be considered a high-level module responsible for managing trivia data and state and the GetTriviaUseCase can be seen as a lower-level module responsible for fetching trivia data. The TriviaStore doesn't directly depend on a concrete implementation of the GetTriviaUseCase interface. It is injected with an instance of this interface during construction (_TriviaStore(this._getTriviaUseCase, this.errorStore)).

part 'trivia_store.g.dart';

class TriviaStore = _TriviaStore with _$TriviaStore;

abstract class _TriviaStore with Store {
// constructor:---------------------------------------------------------------
_TriviaStore(this._getTriviaUseCase, this.errorStore);

// use cases:-----------------------------------------------------------------
final GetTriviaUseCase _getTriviaUseCase;

// stores:--------------------------------------------------------------------
// store for handling errors
final ErrorStore errorStore;

// store variables:-----------------------------------------------------------
static ObservableFuture<TriviaList?> emptyTriviaResponse =
ObservableFuture.value(null);

@observable
ObservableFuture<TriviaList?> fetchTriviaFuture =
ObservableFuture<TriviaList?>(emptyTriviaResponse);

@observable
TriviaList? triviaList;

@observable
bool success = false;

@computed
bool get loading => fetchTriviaFuture.status == FutureStatus.pending;

// actions:-------------------------------------------------------------------
@action
Future getTrivia() async {
final future = _getTriviaUseCase.call(params: null);
fetchTriviaFuture = ObservableFuture(future);

future.then((triviaList) {
this.triviaList = triviaList;
}).catchError((error) {
errorStore.errorMessage = DioErrorUtil.handleError(error);
});
}
}

The Power of SOLID:

By following these SOLID rules, your code will be:
- Less complicated: It will be easier to understand and manage, even for new developers.
- More Flexible: You can easily add new features and fix problems without breaking other parts of your code.
- Easier to test: You can check that your code works correctly without needing to test everything at once.
- Easier to keep up with: As your project grows, it will be easier to make changes and improvements.

Advanced Application and Best Practices:

Implementing SOLID principles aligns with adopting best practices specific to the programming language used. For example, in a Java project, effectively utilizing interfaces for abstraction aligns with these principles, while in Python, the use of abstract base classes serves a similar purpose.

Furthermore, incorporating design patterns like the Strategy Pattern, Factory Pattern, and Observer Pattern complements SOLID principles and enhances the overall architecture of the software. These advanced techniques not only follow SOLID principles but also provide more sophisticated solutions to common programming challenges.

Conclusion:

In conclusion, implementing SOLID principles in software development, especially in large-scale projects, is crucial for building a robust and easily maintainable system. Applying these principles, along with language-specific best practices and advanced design patterns, not only ensures the sustainability of the codebase but also its adaptability to evolving needs.

References:

--

--

Vicky Maulana
Vicky Maulana

Written by Vicky Maulana

Computer Science Student at University of Indonesia

No responses yet